merged (and resolved conflict)

This commit is contained in:
CocoByte 2024-07-24 14:20:06 -06:00
commit 5fd9c02c2e
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
50 changed files with 1343 additions and 461 deletions

View file

@ -19,12 +19,13 @@ There are several tools we use locally that you will need to have.
- If you are using Windows, installation information can be found [here](https://github.com/cloudfoundry/cli/wiki/V8-CLI-Installation-Guide#installers-and-compressed-binaries)
- Alternatively, for Windows, [consider using chocolately](https://community.chocolatey.org/packages/cloudfoundry-cli/7.2.0)
- [ ] Make sure you have `gpg` >2.1.7. Run `gpg --version` to check. If not, [install gnupg](https://formulae.brew.sh/formula/gnupg)
- Alternatively, you can skip this step and [use ssh keys](#setting-up-commit-signing-with-ssh) instead
- [ ] Install the [Github CLI](https://cli.github.com/)
## Access
### Steps for the onboardee
- [ ] Setup [commit signing in Github](#setting-up-commit-signing) and with git locally.
- [ ] Setup commit signing in Github and with git locally using either [gpg](#setting-up-commit-signing-with-gpg) or [ssh](#setting-up-commit-signing-with-ssh).
- [ ] [Create a cloud.gov account](https://cloud.gov/docs/getting-started/accounts/)
- [ ] Email github@cisa.dhs.gov (cc: Cameron) to add you to the [CISA Github organization](https://github.com/getgov) and [.gov Team](https://github.com/orgs/cisagov/teams/gov).
- [ ] Ensure you can login to your cloud.gov account via the CLI
@ -51,7 +52,7 @@ cf login -a api.fr.cloud.gov --sso
- [ ] [Contributing Policy](https://github.com/cisagov/dotgov/tree/main/CONTRIBUTING.md)
## Setting up commit signing
## Setting up commit signing with GPG
Follow the instructions [here](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) to generate a new GPG key (default configurations are okay) and add it to your GPG keys on Github.
@ -72,6 +73,22 @@ when setting up your key in Github.
Now test commit signing is working by checking out a branch (`yourname/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your GPG credential) and push it to Github. Look on Github at your branch and ensure the commit is `verified`.
## Setting up commit signing with SSH
Follow the instructions [here](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent#generating-a-new-ssh-key) to generate a new SSH key and [add it to your SSH keys on Github](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account). Note that you need to add the key as a signing key.
Configure your key locally:
```bash
git config --global gpg.format ssh
git config --global commit.gpgsign true
git config --global user.signingkey <YOUR KEY>
```
Where `<YOUR KEY>` is the path to the private key you generated when running `ssh-keygen`. Usually this is located in ~\.ssh\.
Now test commit signing is working by checking out a branch (`yourinitials/test-commit-signing`) and making some small change to a file. Commit the change (it should prompt you for your key passphrase) and push it to Github. Look on Github at your branch and ensure the commit is `verified`.
### MacOS
**Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error:
```zsh

View file

@ -28,6 +28,7 @@ on:
- ab
- rjm
- dk
- ms
jobs:
createcachetable:

View file

@ -28,6 +28,7 @@ on:
- bob
- hotgov
- litterbox
- ms
# GitHub Actions has no "good" way yet to dynamically input branches
branch:
description: 'Branch to deploy'

View file

@ -429,6 +429,10 @@ class ViewsTest(TestCase):
# Create a mock request
request = self.factory.get("/some-url")
request.session = {"acr_value": ""}
# Mock user and its attributes
mock_user = MagicMock()
mock_user.is_authenticated = True
request.user = mock_user
# Ensure that the CLIENT instance used in login_callback is the mock
# patch _requires_step_up_auth to return False
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(

View file

@ -37,6 +37,7 @@ from django_admin_multiple_choice_list_filter.list_filters import MultipleChoice
from import_export import resources
from import_export.admin import ImportExportModelAdmin
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
@ -92,6 +93,31 @@ class UserResource(resources.ModelResource):
model = models.User
class FilteredSelectMultipleArrayWidget(FilteredSelectMultiple):
"""Custom widget to allow for editing an ArrayField in a widget similar to filter_horizontal widget"""
def __init__(self, verbose_name, is_stacked=False, choices=(), **kwargs):
super().__init__(verbose_name, is_stacked, **kwargs)
self.choices = choices
def value_from_datadict(self, data, files, name):
values = super().value_from_datadict(data, files, name)
return values or []
def get_context(self, name, value, attrs):
if value is None:
value = []
elif isinstance(value, str):
value = value.split(",")
# alter self.choices to be a list of selected and unselected choices, based on value;
# order such that selected choices come before unselected choices
self.choices = [(choice, label) for choice, label in self.choices if choice in value] + [
(choice, label) for choice, label in self.choices if choice not in value
]
context = super().get_context(name, value, attrs)
return context
class MyUserAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs.
@ -104,6 +130,14 @@ class MyUserAdminForm(UserChangeForm):
widgets = {
"groups": NoAutocompleteFilteredSelectMultiple("groups", False),
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
"portfolio_roles": FilteredSelectMultipleArrayWidget(
"portfolio_roles", is_stacked=False, choices=User.UserPortfolioRoleChoices.choices
),
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
"portfolio_additional_permissions",
is_stacked=False,
choices=User.UserPortfolioPermissionChoices.choices,
),
}
def __init__(self, *args, **kwargs):
@ -654,18 +688,49 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"is_superuser",
"groups",
"user_permissions",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
)
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
autocomplete_fields = [
"portfolio",
]
readonly_fields = ("verification_type",)
# Hide Username (uuid), Groups and Permissions
# Q: Now that we're using Groups and Permissions,
# do we expose those to analysts to view?
analyst_fieldsets = (
(
None,
{
"fields": (
"status",
"verification_type",
)
},
),
("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
(
"Permissions",
{
"fields": (
"is_active",
"groups",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
)
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
# TODO: delete after we merge organization feature
analyst_fieldsets_no_portfolio = (
(
None,
{
@ -712,6 +777,26 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"Important dates",
"last_login",
"date_joined",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
]
# TODO: delete after we merge organization feature
analyst_readonly_fields_no_portfolio = [
"User profile",
"first_name",
"middle_name",
"last_name",
"title",
"email",
"phone",
"Permissions",
"is_active",
"groups",
"Important dates",
"last_login",
"date_joined",
]
list_filter = (
@ -786,8 +871,12 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
# Show all fields for all access users
return super().get_fieldsets(request, obj)
elif request.user.has_perm("registrar.analyst_access_permission"):
if flag_is_active(request, "organization_feature"):
# show analyst_fieldsets for analysts
return self.analyst_fieldsets
else:
# TODO: delete after we merge organization feature
return self.analyst_fieldsets_no_portfolio
else:
# any admin user should belong to either full_access_group
# or cisa_analyst_group
@ -801,7 +890,11 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
else:
# Return restrictive Read-only fields for analysts and
# users who might not belong to groups
if flag_is_active(request, "organization_feature"):
return self.analyst_readonly_fields
else:
# TODO: delete after we merge organization feature
return self.analyst_readonly_fields_no_portfolio
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add user's related domains and requests to context"""
@ -1004,6 +1097,16 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def save_model(self, request, obj, form, change):
# Clear warning messages before saving
storage = messages.get_messages(request)
storage.used = False
for message in storage:
if message.level == messages.WARNING:
storage.used = True
return super().save_model(request, obj, form, change)
class SeniorOfficialAdmin(ListHeaderAdmin):
"""Custom Senior Official Admin class."""
@ -1288,10 +1391,11 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
]
# Readonly fields for analysts and superusers
readonly_fields = ("other_contacts", "is_election_board", "federal_agency")
readonly_fields = ("other_contacts", "is_election_board")
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
"federal_agency",
"creator",
"type_of_work",
"more_organization_information",
@ -1604,12 +1708,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"current_websites",
"alternative_domains",
"is_election_board",
"federal_agency",
"status_history",
)
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
"federal_agency",
"creator",
"about_your_organization",
"requested_domain",

View file

@ -305,6 +305,8 @@ function addOrRemoveSessionBoolean(name, add){
// "to" select list
checkToListThenInitWidget('id_groups_to', 0);
checkToListThenInitWidget('id_user_permissions_to', 0);
checkToListThenInitWidget('id_portfolio_roles_to', 0);
checkToListThenInitWidget('id_portfolio_additional_permissions_to', 0);
})();
// Function to check for the existence of the "to" select list element in the DOM, and if and when found,

View file

@ -1140,6 +1140,7 @@ document.addEventListener('DOMContentLoaded', function() {
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter');
const noPortfolioFlag = document.getElementById('no-portfolio-js-flag');
/**
* Loads rows in the domains list, as well as updates pagination around the domains list
@ -1173,8 +1174,20 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url;
const suborganization = domain.suborganization ? domain.suborganization : '';
const row = document.createElement('tr');
let markupForSuborganizationRow = '';
if (!noPortfolioFlag) {
markupForSuborganizationRow = `
<td>
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span>
</td>
`
}
row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name">
${domain.name}
@ -1195,6 +1208,7 @@ document.addEventListener('DOMContentLoaded', function() {
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
</svg>
</td>
${markupForSuborganizationRow}
<td>
<a href="${actionUrl}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">

View file

@ -29,52 +29,14 @@ body {
#wrapper.dashboard {
background-color: color('primary-lightest');
padding-top: units(5);
padding-top: units(5)!important;
}
.usa-logo {
@include at-media(desktop) {
margin-top: units(2);
}
#wrapper.dashboard--portfolio {
background-color: color('gray-1');
padding-top: units(4)!important;
}
.usa-logo__text {
@include typeset('sans', 'xl', 2);
color: color('primary-darker');
}
.usa-nav__primary {
margin-top:units(1);
}
.usa-nav__primary-username {
display: inline-block;
padding: units(1) units(2);
max-width: 208px;
overflow: hidden;
text-overflow: ellipsis;
@include at-media(desktop) {
padding: units(2);
max-width: 500px;
}
}
@include at-media(desktop) {
.usa-nav__primary-item:not(:first-child) {
position: relative;
}
.usa-nav__primary-item:not(:first-child)::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 0; /* No width since it's a border */
height: 40%;
border-left: solid 1px color('base-light');
transform: translateY(-50%);
}
}
.section--outlined {
background-color: color('white');
@ -136,10 +98,6 @@ footer {
color: color('primary');
}
.usa-identifier__logo {
height: units(7);
}
abbr[title] {
// workaround for underlining abbr element
border-bottom: none;
@ -179,47 +137,35 @@ abbr[title] {
cursor: pointer;
}
.input-with-edit-button {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
&.input-with-edit-button__error {
svg.usa-icon {
color: #{$dhs-red};
}
div.input-with-edit-button__readonly-field {
color: #{$dhs-red};
}
}
}
// We need to deviate from some default USWDS styles here
// in this particular case, so we have to override this.
.usa-form .usa-button.readonly-edit-button {
margin-top: 0px !important;
padding-top: 0px !important;
svg {
width: 1.25em !important;
height: 1.25em !important;
}
}
// Define some styles for the .gov header/logo
.usa-logo button {
color: #{$dhs-dark-gray-85};
font-weight: 700;
font-family: family('sans');
font-size: 1.6rem;
line-height: 1.1;
}
.usa-logo button.usa-button--unstyled.disabled-button:hover{
color: #{$dhs-dark-gray-85};
}
.padding--8-8-9 {
padding: 8px 8px 9px !important;
}
.ellipsis {
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ellipsis--23 {
max-width: 23ch;
}
.ellipsis--30 {
max-width: 30ch;
}
.ellipsis--50 {
max-width: 50ch;
}
.vertical-align-middle {
vertical-align: middle;
}
@include at-media(desktop) {
.ellipsis--desktop-50 {
max-width: 50ch;
}
}

View file

@ -162,6 +162,34 @@ a.usa-button--unstyled:visited {
}
}
.input-with-edit-button {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
&.input-with-edit-button__error {
svg.usa-icon {
color: #{$dhs-red};
}
div.readonly-field {
color: #{$dhs-red};
}
}
}
// We need to deviate from some default USWDS styles here
// in this particular case, so we have to override this.
.usa-form .usa-button.readonly-edit-button {
margin-top: 0px !important;
padding-top: 0px !important;
svg {
width: 1.25em !important;
height: 1.25em !important;
}
}
.usa-button--filter {
width: auto;
// For mobile stacking

View file

@ -0,0 +1,121 @@
@use "uswds-core" as *;
@use "cisa_colors" as *;
// Define some styles for the .gov header/logo
.usa-logo button {
color: #{$dhs-dark-gray-85};
font-weight: 700;
font-family: family('sans');
font-size: 1.6rem;
line-height: 1.1;
}
.usa-logo button:hover{
color: #{$dhs-dark-gray-85};
}
.usa-header {
.usa-logo {
@include at-media(desktop) {
margin-top: units(2);
}
}
.usa-logo__text {
@include typeset('sans', 'xl', 2);
}
.usa-nav__username {
max-width: 208px;
min-height: units(2);
@include at-media(desktop) {
max-width: 500px;
}
}
.padding-y-0 {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
}
.usa-header--basic {
.usa-logo__text {
color: color('primary-darker');
}
.usa-nav__username {
padding: units(1) units(2);
@include at-media(desktop) {
padding: units(2);
}
}
.usa-nav__primary {
margin-top:units(1);
}
@include at-media(desktop) {
.usa-nav__primary-item:not(:first-child) {
position: relative;
}
.usa-nav__primary-item:not(:first-child)::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 0; /* No width since it's a border */
height: 40%;
border-left: solid 1px color('base-light');
transform: translateY(-50%);
}
}
}
.usa-header--extended {
@include at-media(desktop) {
background-color: color('primary-darker');
border-top: solid 1px color('base-light');
border-bottom: solid 1px color('base-lighter');
.usa-logo__text a,
.usa-logo__text button,
.usa-logo__text button:hover {
color: color('white');
}
.usa-nav {
background-color: color('primary-lightest');
}
.usa-nav__primary-item:last-child {
margin-left: auto;
.usa-nav-link {
margin-right: units(-2);
}
}
.usa-nav__primary {
.usa-nav-link,
.usa-nav-link:hover,
.usa-nav-link:active {
color: color('primary');
font-weight: font-weight('normal');
font-size: 16px;
}
.usa-current,
.usa-current:hover,
.usa-current:active {
font-weight: font-weight('bold');
}
}
.usa-nav__secondary {
// I don't know why USWDS has this at 2 rem, which puts it out of alignment
right: 3rem;
color: color('white');
bottom: 4.3rem;
.usa-nav-link,
.usa-nav-link:hover,
.usa-nav-link:active {
font-weight: font-weight('bold');
color: color('primary-lighter');
font-size: 16px;
}
}
> .usa-navbar {
// This is a dangerous override to USWDS, necessary because we have a tooltip on the logo
overflow: visible;
}
}
}

View file

@ -0,0 +1,9 @@
@use "uswds-core" as *;
.usa-banner {
background-color: color('primary-darker');
}
.usa-identifier__logo {
height: units(7);
}

View file

@ -34,22 +34,6 @@
pointer-events: none;
}
}
// Ticket #1510
// @include at-media('desktop') {
// th:first-child {
// width: 220px;
// }
// th:nth-child(2) {
// width: 175px;
// }
// th:nth-child(3) {
// width: 130px;
// }
// th:nth-child(5) {
// width: 130px;
// }
// }
}
.dotgov-table {
@ -96,46 +80,3 @@
}
}
}
@media (min-width: 1040px){
.domain-requests__table {
th:nth-of-type(1) {
width: 200px;
}
th:nth-of-type(2) {
width: 158px;
}
th:nth-of-type(3) {
width: 120px;
}
th:nth-of-type(4) {
width: 95px;
}
th:nth-of-type(5) {
width: 85px;
}
}
}
@media (min-width: 1040px){
.domains__table {
th:nth-of-type(1) {
width: 200px;
}
th:nth-of-type(2) {
width: 158px;
}
th:nth-of-type(3) {
width: 215px;
}
th:nth-of-type(4) {
width: 95px;
}
}
}

View file

@ -21,6 +21,8 @@
@forward "alerts";
@forward "tables";
@forward "sidenav";
@forward "identifier";
@forward "header";
@forward "register-form";
/*--------------------------------------------------

View file

@ -240,6 +240,11 @@ TEMPLATES = [
"registrar.context_processors.canonical_path",
"registrar.context_processors.is_demo_site",
"registrar.context_processors.is_production",
"registrar.context_processors.org_user_status",
"registrar.context_processors.add_portfolio_to_context",
"registrar.context_processors.add_path_to_context",
"registrar.context_processors.add_has_profile_feature_flag_to_context",
"registrar.context_processors.portfolio_permissions",
],
},
},

View file

@ -25,7 +25,7 @@ from registrar.views.domain_request import Step
from registrar.views.domain_requests_json import get_domain_requests_json
from registrar.views.domains_json import get_domains_json
from registrar.views.utility import always_404
from registrar.views.portfolios import portfolio_domains, portfolio_domain_requests
from registrar.views.portfolios import PortfolioDomainsView, PortfolioDomainRequestsView, PortfolioOrganizationView
from api.views import available, get_current_federal, get_current_full
@ -61,14 +61,19 @@ urlpatterns = [
path("", views.index, name="home"),
path(
"portfolio/<int:portfolio_id>/domains/",
portfolio_domains,
PortfolioDomainsView.as_view(),
name="portfolio-domains",
),
path(
"portfolio/<int:portfolio_id>/domain_requests/",
portfolio_domain_requests,
PortfolioDomainRequestsView.as_view(),
name="portfolio-domain-requests",
),
path(
"portfolio/<int:portfolio_id>/organization/",
PortfolioOrganizationView.as_view(),
name="portfolio-organization",
),
path(
"admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False),

View file

@ -1,4 +1,5 @@
from django.conf import settings
from waffle.decorators import flag_is_active
def language_code(request):
@ -36,3 +37,49 @@ def is_demo_site(request):
def is_production(request):
"""Add a boolean if this is our production site."""
return {"IS_PRODUCTION": settings.IS_PRODUCTION}
def org_user_status(request):
if request.user.is_authenticated:
is_org_user = request.user.is_org_user(request)
else:
is_org_user = False
return {
"is_org_user": is_org_user,
}
def add_portfolio_to_context(request):
return {"portfolio": getattr(request, "portfolio", None)}
def add_path_to_context(request):
return {"path": getattr(request, "path", None)}
def add_has_profile_feature_flag_to_context(request):
return {"has_profile_feature_flag": flag_is_active(request, "profile_feature")}
def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context"""
try:
if not request.user or not request.user.is_authenticated:
return {
"has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": False,
}
return {
"has_base_portfolio_permission": request.user.has_base_portfolio_permission(),
"has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(),
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(),
}
except AttributeError:
# Handles cases where request.user might not exist
return {
"has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": False,
}

View file

@ -0,0 +1,80 @@
# Generated by Django 4.2.10 on 2024-07-22 19:19
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0112_remove_contact_registrar_c_user_id_4059c4_idx_and_more"),
]
operations = [
migrations.AddField(
model_name="user",
name="portfolio",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="user",
to="registrar.portfolio",
),
),
migrations.AddField(
model_name="user",
name="portfolio_additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "View all domains and domain reports"),
("view_managed_domains", "View managed domains"),
("edit_domains", "User is a manager on a domain"),
("view_member", "View members"),
("edit_member", "Create and edit members"),
("view_all_requests", "View all requests"),
("view_created_requests", "View created requests"),
("edit_requests", "Create and edit requests"),
("view_portfolio", "View organization"),
("edit_portfolio", "Edit organization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
migrations.AddField(
model_name="user",
name="portfolio_roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("organization_admin", "Admin"),
("organization_admin_read_only", "Admin read only"),
("organization_member", "Member"),
],
max_length=50,
),
blank=True,
help_text="Select one or more roles.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="portfolio",
name="creator",
field=models.ForeignKey(
help_text="Associated user",
on_delete=django.db.models.deletion.PROTECT,
related_name="created_portfolios",
to=settings.AUTH_USER_MODEL,
),
),
]

View file

@ -23,7 +23,13 @@ class Portfolio(TimeStampedModel):
# Stores who created this model. If no creator is specified in DJA,
# then the creator will default to the current request user"""
creator = models.ForeignKey("registrar.User", on_delete=models.PROTECT, help_text="Associated user", unique=False)
creator = models.ForeignKey(
"registrar.User",
on_delete=models.PROTECT,
help_text="Associated user",
related_name="created_portfolios",
unique=False,
)
notes = models.TextField(
null=True,

View file

@ -11,6 +11,8 @@ from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff
from .domain import Domain
from .domain_request import DomainRequest
from django.contrib.postgres.fields import ArrayField
from waffle.decorators import flag_is_active
from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -60,6 +62,57 @@ class User(AbstractUser):
# after they login.
FIXTURE_USER = "fixture_user", "Created by fixtures"
class UserPortfolioRoleChoices(models.TextChoices):
"""
Roles make it easier for admins to look at
"""
ORGANIZATION_ADMIN = "organization_admin", "Admin"
ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only"
ORGANIZATION_MEMBER = "organization_member", "Member"
class UserPortfolioPermissionChoices(models.TextChoices):
""" """
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
# EDIT_DOMAINS is really self.domains. We add is hear and leverage it in has_permission
# so we have one way to test for portfolio and domain edit permissions
# Do we need to check for portfolio domains specifically?
# NOTE: A user on an org can currently invite a user outside the org
EDIT_DOMAINS = "edit_domains", "User is a manager on a domain"
VIEW_MEMBER = "view_member", "View members"
EDIT_MEMBER = "edit_member", "Create and edit members"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER,
UserPortfolioPermissionChoices.EDIT_MEMBER,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
],
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
],
}
# #### Constants for choice fields ####
RESTRICTED = "restricted"
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
@ -80,6 +133,34 @@ class User(AbstractUser):
related_name="users",
)
portfolio = models.ForeignKey(
"registrar.Portfolio",
null=True,
blank=True,
related_name="user",
on_delete=models.SET_NULL,
)
portfolio_roles = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioRoleChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more roles.",
)
portfolio_additional_permissions = ArrayField(
models.CharField(
max_length=50,
choices=UserPortfolioPermissionChoices.choices,
),
null=True,
blank=True,
help_text="Select one or more additional permissions.",
)
phone = PhoneNumberField(
null=True,
blank=True,
@ -170,6 +251,57 @@ class User(AbstractUser):
def has_contact_info(self):
return bool(self.title or self.email or self.phone)
def _get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles.
"""
portfolio_permissions = set() # Use a set to avoid duplicate permissions
if self.portfolio_roles:
for role in self.portfolio_roles:
if role in self.PORTFOLIO_ROLE_PERMISSIONS:
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS[role])
if self.portfolio_additional_permissions:
portfolio_permissions.update(self.portfolio_additional_permissions)
return list(portfolio_permissions) # Convert back to list if necessary
def _has_portfolio_permission(self, portfolio_permission):
"""The views should only call this function when testing for perms and not rely on roles."""
# EDIT_DOMAINS === user is a manager on a domain (has UserDomainRole)
# NOTE: Should we check whether the domain is in the portfolio?
if portfolio_permission == self.UserPortfolioPermissionChoices.EDIT_DOMAINS and self.domains.exists():
return True
if not self.portfolio:
return False
portfolio_permissions = self._get_portfolio_permissions()
return portfolio_permission in portfolio_permissions
# the methods below are checks for individual portfolio permissions. they are defined here
# to make them easier to call elsewhere throughout the application
def has_base_portfolio_permission(self):
return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO)
def has_domains_portfolio_permission(self):
return (
self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
# or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_DOMAINS)
)
def has_edit_domains_portfolio_permission(self):
return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_DOMAINS)
def has_domain_requests_portfolio_permission(self):
return (
self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
# or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.EDIT_REQUESTS)
)
@classmethod
def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification
@ -288,3 +420,7 @@ class User(AbstractUser):
"""
self.check_domain_invitations_on_login()
def is_org_user(self, request):
has_organization_feature_flag = flag_is_active(request, "organization_feature")
return has_organization_feature_flag and self.has_base_portfolio_permission()

View file

@ -6,7 +6,6 @@ import logging
from urllib.parse import parse_qs
from django.urls import reverse
from django.http import HttpResponseRedirect
from registrar.models.portfolio import Portfolio
from registrar.models.user import User
from waffle.decorators import flag_is_active
@ -141,14 +140,20 @@ class CheckPortfolioMiddleware:
def process_view(self, request, view_func, view_args, view_kwargs):
current_path = request.path
has_organization_feature_flag = flag_is_active(request, "organization_feature")
if current_path == self.home and request.user.is_authenticated and request.user.is_org_user(request):
if request.user.has_base_portfolio_permission():
portfolio = request.user.portfolio
# Add the portfolio to the request object
request.portfolio = portfolio
if request.user.has_domains_portfolio_permission():
portfolio_redirect = reverse("portfolio-domains", kwargs={"portfolio_id": portfolio.id})
else:
# View organization is the lowest access
portfolio_redirect = reverse("portfolio-organization", kwargs={"portfolio_id": portfolio.id})
return HttpResponseRedirect(portfolio_redirect)
if current_path == self.home:
if has_organization_feature_flag:
if request.user.is_authenticated:
user_portfolios = Portfolio.objects.filter(creator=request.user)
if user_portfolios.exists():
first_portfolio = user_portfolios.first()
home_with_portfolio = reverse("portfolio-domains", kwargs={"portfolio_id": first_portfolio.id})
return HttpResponseRedirect(home_with_portfolio)
return None

View file

@ -133,48 +133,10 @@
</section>
{% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %}
{% block banner %}
<header class="usa-header usa-header--basic">
<div class="usa-nav-container">
<div class="usa-navbar">
{% block logo %}
{% include "includes/gov_extended_logo.html" with logo_clickable=True %}
{% endblock %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<button type="button" class="usa-nav__close">
<img src="/public/img/usa-icons/close.svg" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if user.is_authenticated %}
<span class="usa-nav__primary-username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__primary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if request.path == user_profile_url or request.path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
<span class="text-primary">Your profile</span>
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
{% else %}
<a href="{% url 'login' %}"><span>Sign in</span></a>
{% endif %}
</li>
</ul>
</nav>
{% block usa_nav_secondary %}{% endblock %}
{% endblock %}
</div>
</header>
{% endblock banner %}
<div class="usa-overlay"></div>
{% block header %}
{% include "includes/header_selector.html" with logo_clickable=True %}
{% endblock header %}
{% block wrapper %}
<div id="wrapper">

View file

@ -4,8 +4,8 @@
{% block title %} Finish setting up your profile | {% endblock %}
{# Disable the redirect #}
{% block logo %}
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %}
{% block header %}
{% include "includes/header_selector.html" with logo_clickable=user_finished_setup %}
{% endblock %}
{# Add the new form #}

View file

@ -10,11 +10,11 @@
{# the entire logged in page goes here #}
{% block homepage_content %}
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<h1>Manage your domains</h1>
{% comment %}
@ -32,26 +32,8 @@
{% include "includes/domains_table.html" %}
{% include "includes/domain_requests_table.html" %}
{# Note: Reimplement this after MVP #}
<!--
<section class="section--outlined tablet:grid-col-11 desktop:grid-col-10">
<h2>Archived domains</h2>
<p>You don't have any archived domains</p>
</section>
-->
<!-- Note: Uncomment below when this is being implemented post-MVP -->
<!-- <section class="tablet:grid-col-11 desktop:grid-col-10">
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2>
<p>Download a list of your domains and their statuses as a csv file.</p>
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
Export domains as csv
</a>
</section>
-->
</div>
{% endblock %}
</div>
{% else %} {# not user.is_authenticated #}
{# the entire logged out page goes here #}

View file

@ -2,6 +2,7 @@
<section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row">
<!-- Use portfolio_base_permission when merging into 2366 and then delete this comment -->
{% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
@ -12,6 +13,9 @@
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>

View file

@ -2,16 +2,21 @@
<section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row">
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domains-header" class="flex-6">Domains</h2>
</div>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domains search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-search display-none" type="button">
<button class="usa-button usa-button--unstyled margin-right-3 domains__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<label class="usa-sr-only" for="domains__search-field">Search by domain name</label>
@ -33,9 +38,10 @@
</section>
</div>
</div>
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio %}
<div class="display-flex flex-align-center margin-top-1">
<span class="margin-right-2 margin-top-neg-1 text-base-darker">Filter by</span>
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading">
<button
@ -136,6 +142,10 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
{% endif %}
<th
scope="col"
role="columnheader"

View file

@ -0,0 +1,39 @@
{% load static %}
<header class="usa-header usa-header--basic">
<div class="usa-nav-container">
<div class="usa-navbar">
{% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if user.is_authenticated %}
<span class="usa-nav__username ellipsis">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__primary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if request.path == user_profile_url or request.path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
<span class="text-primary">Your profile</span>
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="{% url 'logout' %}"><span class="text-primary">Sign out</span></a>
{% else %}
<a href="{% url 'login' %}"><span>Sign in</span></a>
{% endif %}
</li>
</ul>
</nav>
{% block usa_nav_secondary %}{% endblock %}
{% endblock %}
</div>
</header>

View file

@ -0,0 +1,77 @@
{% load static %}
<header class="usa-header usa-header--extended">
<div class="usa-navbar">
{% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<div class="usa-nav__inner">
<button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button>
<ul class="usa-nav__primary usa-accordion">
{% if has_domains_portfolio_permission %}
<li class="usa-nav__primary-item">
{% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" class="usa-nav-link{% if request.path == url %} usa-current{% endif %}">
Domains
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Domain groups
</a>
</li>
{% if has_domain_requests_portfolio_permission %}
<li class="usa-nav__primary-item">
{% url 'portfolio-domain-requests' portfolio.id as url %}
<a href="{{ url }}" class="usa-nav-link{% if request.path == url %} usa-current{% endif %}">
Domain requests
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Members
</a>
</li>
<li class="usa-nav__primary-item">
{% url 'portfolio-organization' portfolio.id as url %}
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
<a href="{{ url }}" class="usa-nav-link padding-y-0">
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
{{ portfolio.organization_name }}
</span>
</a>
</li>
</ul>
<div class="usa-nav__secondary">
<ul class="usa-nav__secondary-links">
<li class="usa-nav__secondary-item">
{% if user.is_authenticated %}
<span class="ellipsis usa-nav__username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__secondary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if path == user_profile_url or path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
Your profile
</a>
</li>
{% endif %}
<li class="usa-nav__secondary-item">
<a class="usa-nav-link" href="{% url 'logout' %}">Sign out</a>
{% else %}
<a class="usa-nav-link" href="{% url 'login' %}">Sign in</a>
{% endif %}
</li>
</ul>
</div>
</div>
</nav>
{% endblock %}
</header>

View file

@ -0,0 +1,5 @@
{% if not is_org_user %}
{% include "includes/header_basic.html" with logo_clickable=logo_clickable %}
{% else %}
{% include "includes/header_extended.html" with logo_clickable=logo_clickable %}
{% endif %}

View file

@ -1,24 +0,0 @@
{% extends 'home.html' %}
{% load static %}
{% block homepage_content %}
<div class="tablet:grid-col-12">
<div class="grid-row grid-gap">
<div class="tablet:grid-col-3">
{% include "portfolio_sidebar.html" with portfolio=portfolio %}
</div>
<div class="tablet:grid-col-9">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
{# Note: Reimplement commented out functionality #}
{% block portfolio_content %}
{% endblock %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block wrapper %}
<div id="wrapper" class="dashboard--portfolio">
{% block content %}
<main id="main-content" class="grid-container">
{% if user.is_authenticated %}
{# the entire logged in page goes here #}
<div class="tablet:grid-col-12">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
{% block portfolio_content %}{% endblock %}
</div>
{% else %} {# not user.is_authenticated #}
{# the entire logged out page goes here #}
<p><a class="usa-button" href="{% url 'login' %}">
Sign in
</a></p>
{% endif %}
</main>
{% endblock %}
<div role="complementary">{% block complementary %}{% endblock %}</div>
{% block content_bottom %}{% endblock %}
</div>
{% endblock wrapper %}

View file

@ -1,7 +1,9 @@
{% extends 'portfolio.html' %}
{% extends 'portfolio_base.html' %}
{% load static %}
{% block title %} Domains | {% endblock %}
{% block portfolio_content %}
<h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %}

View file

@ -0,0 +1,8 @@
{% extends 'portfolio_base.html' %}
{% load static %}
{% block portfolio_content %}
<h1>Organization</h1>
{% endblock %}

View file

@ -1,7 +1,9 @@
{% extends 'portfolio.html' %}
{% extends 'portfolio_base.html' %}
{% load static %}
{% block title %} Domain requests | {% endblock %}
{% block portfolio_content %}
<h1 id="domain-requests-header">Domain requests</h1>

View file

@ -1,37 +0,0 @@
{% load static url_helpers %}
<div class="margin-bottom-4 tablet:margin-bottom-0">
<nav aria-label="">
<h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2>
<ul class="usa-sidenav usa-sidenav--portfolio">
<li class="usa-sidenav__item">
{% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
Domains
</a>
</li>
<li class="usa-sidenav__item">
{% url 'portfolio-domain-requests' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>
Domain requests
</a>
</li>
<li class="usa-sidenav__item">
<a href="#">
Members
</a>
</li>
<li class="usa-sidenav__item">
<a href="#">
Organization
</a>
</li>
<li class="usa-sidenav__item">
<a href="#">
Senior official
</a>
</li>
</ul>
</nav>
</div>

View file

@ -6,8 +6,8 @@ Edit your User Profile |
{% load static url_helpers %}
{# Disable the redirect #}
{% block logo %}
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %}
{% block header %}
{% include "includes/header_selector.html" with logo_clickable=user_finished_setup %}
{% endblock %}
{% block content %}

View file

@ -2305,7 +2305,6 @@ class TestDomainRequestAdmin(MockEppLib):
"current_websites",
"alternative_domains",
"is_election_board",
"federal_agency",
"status_history",
"id",
"created_at",
@ -2366,8 +2365,8 @@ class TestDomainRequestAdmin(MockEppLib):
"current_websites",
"alternative_domains",
"is_election_board",
"federal_agency",
"status_history",
"federal_agency",
"creator",
"about_your_organization",
"requested_domain",
@ -2397,7 +2396,6 @@ class TestDomainRequestAdmin(MockEppLib):
"current_websites",
"alternative_domains",
"is_election_board",
"federal_agency",
"status_history",
]
@ -3659,7 +3657,15 @@ class TestMyUserAdmin(MockDb):
},
),
("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
("Permissions", {"fields": ("is_active", "groups")}),
(
"Permissions",
{
"fields": (
"is_active",
"groups",
)
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
self.assertEqual(fieldsets, expected_fieldsets)
@ -3755,6 +3761,22 @@ class TestMyUserAdmin(MockDb):
expected_href = reverse("admin:registrar_domain_change", args=[domain_deleted.pk])
self.assertNotContains(response, expected_href)
def test_analyst_cannot_see_selects_for_portfolio_role_and_permissions_in_user_form(self):
"""Can only test for the presence of a base element. The multiselects and the h2->h3 conversion are all
dynamically generated."""
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/user/{}/change/".format(self.meoward_user.id),
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Portfolio roles:")
self.assertNotContains(response, "Portfolio additional permissions:")
class AuditedAdminTest(TestCase):
def setUp(self):

View file

@ -19,6 +19,7 @@ from registrar.models import (
)
import boto3_mocking
from registrar.models.portfolio import Portfolio
from registrar.models.transition_domain import TransitionDomain
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
from registrar.utility.constants import BranchChoices
@ -1214,6 +1215,68 @@ class TestUser(TestCase):
self.user.phone = None
self.assertFalse(self.user.has_contact_info())
def test_has_portfolio_permission(self):
"""
0. Returns False when user does not have a permission
1. Returns False when a user does not have a portfolio
2. Returns True when user has direct permission
3. Returns True when user has permission through a role
4. Returns True EDIT_DOMAINS when user does not have the perm but has UserDomainRole
Note: This tests _get_portfolio_permissions as a side effect
"""
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
self.user.save()
self.user.refresh_from_db()
user_can_view_all_domains = self.user.has_domains_portfolio_permission()
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission()
user_can_edit_domains = self.user.has_edit_domains_portfolio_permission()
self.assertFalse(user_can_view_all_domains)
self.assertFalse(user_can_view_all_requests)
self.assertFalse(user_can_edit_domains)
self.user.portfolio = portfolio
self.user.save()
self.user.refresh_from_db()
user_can_view_all_domains = self.user.has_domains_portfolio_permission()
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission()
user_can_edit_domains = self.user.has_edit_domains_portfolio_permission()
self.assertTrue(user_can_view_all_domains)
self.assertFalse(user_can_view_all_requests)
self.assertFalse(user_can_edit_domains)
self.user.portfolio_roles = [User.UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
self.user.save()
self.user.refresh_from_db()
user_can_view_all_domains = self.user.has_domains_portfolio_permission()
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission()
user_can_edit_domains = self.user.has_edit_domains_portfolio_permission()
self.assertTrue(user_can_view_all_domains)
self.assertTrue(user_can_view_all_requests)
self.assertFalse(user_can_edit_domains)
UserDomainRole.objects.all().get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
)
user_can_view_all_domains = self.user.has_domains_portfolio_permission()
user_can_view_all_requests = self.user.has_domain_requests_portfolio_permission()
user_can_edit_domains = self.user.has_edit_domains_portfolio_permission()
self.assertTrue(user_can_view_all_domains)
self.assertTrue(user_can_view_all_requests)
self.assertTrue(user_can_edit_domains)
Portfolio.objects.all().delete()
class TestContact(TestCase):
def setUp(self):

View file

@ -559,7 +559,6 @@ class ExportDataTest(MockDb, MockEppLib):
csv_file.seek(0)
# Read the content into a variable
csv_content = csv_file.read()
print(csv_content)
expected_content = (
# Header
"Domain request,Status,Domain type,Federal type,"

View file

@ -1044,23 +1044,6 @@ class PortfoliosTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@less_console_noise_decorator
def test_middleware_redirects_to_portfolio_homepage(self):
"""Tests that a user is redirected to the portfolio homepage when organization_feature is on and
a portfolio belongs to the user, test for the special h1s which only exist in that version
of the homepage"""
self.app.set_user(self.user.username)
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
portfolio_page = self.app.get(reverse("home")).follow()
self._set_session_cookie()
# Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
@less_console_noise_decorator
def test_no_redirect_when_org_flag_false(self):
"""No redirect so no follow,

View file

@ -0,0 +1,192 @@
from django.urls import reverse
from api.tests.common import less_console_noise_decorator
from registrar.models.portfolio import Portfolio
from django_webtest import WebTest # type: ignore
from registrar.models import (
DomainRequest,
Domain,
DomainInformation,
UserDomainRole,
User,
)
from .test_views import TestWithUser
from waffle.testutils import override_flag
import logging
logger = logging.getLogger(__name__)
class TestPortfolioViews(TestWithUser, WebTest):
def setUp(self):
super().setUp()
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
)
@less_console_noise_decorator
def test_middleware_does_not_redirect_if_no_permission(self):
"""Test that user with no portfolio permission is not redirected when attempting to access home"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
portfolio_page = self.app.get(reverse("home"))
# Assert that we're on the right page
self.assertNotContains(portfolio_page, self.portfolio.organization_name)
@less_console_noise_decorator
def test_middleware_does_not_redirect_if_no_portfolio(self):
"""Test that user with no assigned portfolio is not redirected when attempting to access home"""
self.app.set_user(self.user.username)
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
portfolio_page = self.app.get(reverse("home"))
# Assert that we're on the right page
self.assertNotContains(portfolio_page, self.portfolio.organization_name)
@less_console_noise_decorator
def test_middleware_redirects_to_portfolio_organization_page(self):
"""Test that user with VIEW_PORTFOLIO is redirected to portfolio organization page"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
portfolio_page = self.app.get(reverse("home")).follow()
# Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Organization</h1>")
@less_console_noise_decorator
def test_middleware_redirects_to_portfolio_domains_page(self):
"""Test that user with VIEW_PORTFOLIO and VIEW_ALL_DOMAINS is redirected to portfolio domains page"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
]
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
portfolio_page = self.app.get(reverse("home")).follow()
# Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertNotContains(portfolio_page, "<h1>Organization</h1>")
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
@less_console_noise_decorator
def test_portfolio_domains_page_403_when_user_not_have_permission(self):
"""Test that user without proper permission is denied access to portfolio domain view"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
response = self.app.get(
reverse("portfolio-domains", kwargs={"portfolio_id": self.portfolio.pk}), status=403
)
# Assert the response is a 403 Forbidden
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
def test_portfolio_domain_requests_page_403_when_user_not_have_permission(self):
"""Test that user without proper permission is denied access to portfolio domain view"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
response = self.app.get(
reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk}), status=403
)
# Assert the response is a 403 Forbidden
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
def test_portfolio_organization_page_403_when_user_not_have_permission(self):
"""Test that user without proper permission is not allowed access to portfolio organization page"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
response = self.app.get(
reverse("portfolio-organization", kwargs={"portfolio_id": self.portfolio.pk}), status=403
)
# Assert the response is a 403 Forbidden
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
def test_navigation_links_hidden_when_user_not_have_permission(self):
"""Test that navigation links are hidden when user does not have portfolio permissions"""
self.app.set_user(self.user.username)
self.user.portfolio = self.portfolio
self.user.portfolio_additional_permissions = [
User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
]
self.user.save()
self.user.refresh_from_db()
with override_flag("organization_feature", active=True):
# This will redirect the user to the portfolio page.
# Follow implicity checks if our redirect is working.
portfolio_page = self.app.get(reverse("home")).follow()
# Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertNotContains(portfolio_page, "<h1>Organization</h1>")
self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertContains(
portfolio_page, reverse("portfolio-domains", kwargs={"portfolio_id": self.portfolio.pk})
)
self.assertContains(
portfolio_page, reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk})
)
# reducing portfolio permissions to just VIEW_PORTFOLIO, which should remove domains
# and domain requests from nav
self.user.portfolio_additional_permissions = [User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO]
self.user.save()
self.user.refresh_from_db()
portfolio_page = self.app.get(reverse("home")).follow()
self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Organization</h1>")
self.assertNotContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
self.assertNotContains(
portfolio_page, reverse("portfolio-domains", kwargs={"portfolio_id": self.portfolio.pk})
)
self.assertNotContains(
portfolio_page, reverse("portfolio-domain-requests", kwargs={"portfolio_id": self.portfolio.pk})
)
def tearDown(self):
Portfolio.objects.all().delete()
UserDomainRole.objects.all().delete()
DomainRequest.objects.all().delete()
DomainInformation.objects.all().delete()
Domain.objects.all().delete()
super().tearDown()

View file

@ -59,7 +59,7 @@ from epplibwrapper import (
from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
from waffle.decorators import flag_is_active, waffle_flag
from waffle.decorators import waffle_flag
logger = logging.getLogger(__name__)
@ -102,13 +102,6 @@ class DomainBaseView(DomainPermissionView):
domain_pk = "domain:" + str(self.kwargs.get("pk"))
self.session[domain_pk] = self.object
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainFormBaseView(DomainBaseView, FormMixin):
"""

View file

@ -228,10 +228,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
if request.path_info == self.NEW_URL_NAME:
# Clear context so the prop getter won't create a request here.
# Creating a request will be handled in the post method for the
# intro page. Only TEMPORARY context needed is has_profile_flag
has_profile_flag = flag_is_active(self.request, "profile_feature")
context_stuff = {"has_profile_feature_flag": has_profile_flag}
return render(request, "domain_request_intro.html", context=context_stuff)
# intro page.
return render(request, "domain_request_intro.html", {})
else:
return self.goto(self.steps.first)
@ -380,7 +378,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def get_context_data(self):
"""Define context for access on all wizard pages."""
has_profile_flag = flag_is_active(self.request, "profile_feature")
context_stuff = {}
if DomainRequest._form_complete(self.domain_request, self.request):
@ -397,8 +394,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"modal_description": "Once you submit this request, you wont be able to edit it until we review it.\
Youll only be able to withdraw your request.",
"review_form_is_complete": True,
# Use the profile waffle feature flag to toggle profile features throughout domain requests
"has_profile_feature_flag": has_profile_flag,
"user": self.request.user,
}
else: # form is not complete
@ -414,7 +409,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"modal_description": 'This request cannot be submitted yet.\
Return to the request and visit the steps that are marked as "incomplete."',
"review_form_is_complete": False,
"has_profile_feature_flag": has_profile_flag,
"user": self.request.user,
}
return context_stuff
@ -740,13 +734,6 @@ class Finished(DomainRequestWizard):
class DomainRequestStatus(DomainRequestPermissionView):
template_name = "domain_request_status.html"
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
"""This page will ask user to confirm if they want to withdraw
@ -757,13 +744,6 @@ class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
template_name = "domain_request_withdraw_confirmation.html"
def get_context_data(self, **kwargs):
"""Extend get_context_data to add has_profile_feature_flag to context"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
return context
class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
# this view renders no template

View file

@ -1,3 +1,4 @@
import logging
from django.http import JsonResponse
from django.core.paginator import Paginator
from registrar.models import UserDomainRole, Domain
@ -5,89 +6,29 @@ from django.contrib.auth.decorators import login_required
from django.urls import reverse
from django.db.models import Q
logger = logging.getLogger(__name__)
@login_required
def get_domains_json(request):
"""Given the current request,
get all domains that are associated with the UserDomainRole object"""
user_domain_roles = UserDomainRole.objects.filter(user=request.user)
user_domain_roles = UserDomainRole.objects.filter(user=request.user).select_related("domain_info__sub_organization")
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
objects = Domain.objects.filter(id__in=domain_ids)
unfiltered_total = objects.count()
# Handle sorting
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc'
# Handle search term
search_term = request.GET.get("search_term")
if search_term:
objects = objects.filter(Q(name__icontains=search_term))
# Handle state
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
# if unknown is in status_list, append 'dns needed' since both
# unknown and dns needed display as DNS Needed, and both are
# searchable via state parameter of 'unknown'
if "unknown" in status_list:
status_list.append("dns needed")
# Split the status list into normal states and custom states
normal_states = [state for state in status_list if state in Domain.State.values]
custom_states = [state for state in status_list if state == "expired"]
# Construct Q objects for normal states that can be queried through ORM
state_query = Q()
if normal_states:
state_query |= Q(state__in=normal_states)
# Handle custom states in Python, as expired can not be queried through ORM
if "expired" in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
state_query |= Q(id__in=expired_domain_ids)
# Apply the combined query
objects = objects.filter(state_query)
# If there are filtered states, and expired is not one of them, domains with
# state_display of 'Expired' must be removed
if "expired" not in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
objects = objects.exclude(id__in=expired_domain_ids)
if sort_by == "state_display":
# Fetch the objects and sort them in Python
objects = list(objects) # Evaluate queryset to a list
objects.sort(key=lambda domain: domain.state_display(), reverse=(order == "desc"))
else:
if order == "desc":
sort_by = f"-{sort_by}"
objects = objects.order_by(sort_by)
objects = apply_search(objects, request)
objects = apply_state_filter(objects, request)
objects = apply_sorting(objects, request)
paginator = Paginator(objects, 10)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Convert objects to JSON-serializable format
domains = [
{
"id": domain.id,
"name": domain.name,
"expiration_date": domain.expiration_date,
"state": domain.state,
"state_display": domain.state_display(),
"get_state_help_text": domain.get_state_help_text(),
"action_url": reverse("domain", kwargs={"pk": domain.id}),
"action_label": ("View" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "Manage"),
"svg_icon": ("visibility" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "settings"),
}
for domain in page_obj.object_list
]
domains = [serialize_domain(domain) for domain in page_obj.object_list]
return JsonResponse(
{
@ -100,3 +41,80 @@ def get_domains_json(request):
"unfiltered_total": unfiltered_total,
}
)
def apply_search(queryset, request):
search_term = request.GET.get("search_term")
if search_term:
queryset = queryset.filter(Q(name__icontains=search_term))
return queryset
def apply_state_filter(queryset, request):
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
# if unknown is in status_list, append 'dns needed' since both
# unknown and dns needed display as DNS Needed, and both are
# searchable via state parameter of 'unknown'
if "unknown" in status_list:
status_list.append("dns needed")
# Split the status list into normal states and custom states
normal_states = [state for state in status_list if state in Domain.State.values]
custom_states = [state for state in status_list if state == "expired"]
# Construct Q objects for normal states that can be queried through ORM
state_query = Q()
if normal_states:
state_query |= Q(state__in=normal_states)
# Handle custom states in Python, as expired can not be queried through ORM
if "expired" in custom_states:
expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"]
state_query |= Q(id__in=expired_domain_ids)
# Apply the combined query
queryset = queryset.filter(state_query)
# If there are filtered states, and expired is not one of them, domains with
# state_display of 'Expired' must be removed
if "expired" not in custom_states:
expired_domain_ids = [domain.id for domain in queryset if domain.state_display() == "Expired"]
queryset = queryset.exclude(id__in=expired_domain_ids)
return queryset
def apply_sorting(queryset, request):
sort_by = request.GET.get("sort_by", "id")
order = request.GET.get("order", "asc")
if sort_by == "state_display":
objects = list(queryset)
objects.sort(key=lambda domain: domain.state_display(), reverse=(order == "desc"))
return objects
else:
if order == "desc":
sort_by = f"-{sort_by}"
return queryset.order_by(sort_by)
def serialize_domain(domain):
suborganization_name = None
try:
domain_info = domain.domain_info
if domain_info:
suborganization = domain_info.sub_organization
if suborganization:
suborganization_name = suborganization.name
except Domain.domain_info.RelatedObjectDoesNotExist:
domain_info = None
logger.debug(f"Issue in domains_json: We could not find domain_info for {domain}")
return {
"id": domain.id,
"name": domain.name,
"expiration_date": domain.expiration_date,
"state": domain.state,
"state_display": domain.state_display(),
"get_state_help_text": domain.get_state_help_text(),
"action_url": reverse("domain", kwargs={"pk": domain.id}),
"action_label": ("View" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "Manage"),
"svg_icon": ("visibility" if domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD] else "settings"),
"suborganization": suborganization_name,
}

View file

@ -1,5 +1,4 @@
from django.shortcuts import render
from waffle.decorators import flag_is_active
def index(request):
@ -7,10 +6,6 @@ def index(request):
context = {}
if request.user.is_authenticated:
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
# This controls the creation of a new domain request in the wizard
request.session["new_request"] = True

View file

@ -1,39 +1,58 @@
from django.shortcuts import get_object_or_404, render
from registrar.models.portfolio import Portfolio
from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView,
PortfolioBasePermissionView,
)
from waffle.decorators import flag_is_active
from django.contrib.auth.decorators import login_required
from django.views.generic import View
@login_required
def portfolio_domains(request, portfolio_id):
class PortfolioDomainsView(PortfolioDomainsPermissionView, View):
template_name = "portfolio_domains.html"
def get(self, request, portfolio_id):
context = {}
if request.user.is_authenticated:
# This is a django waffle flag which toggles features based off of the "flag" table
if self.request.user.is_authenticated:
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
# Retrieve the portfolio object based on the provided portfolio_id
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
context["portfolio"] = portfolio
return render(request, "portfolio_domains.html", context)
@login_required
def portfolio_domain_requests(request, portfolio_id):
class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
template_name = "portfolio_requests.html"
def get(self, request, portfolio_id):
context = {}
if request.user.is_authenticated:
# This is a django waffle flag which toggles features based off of the "flag" table
if self.request.user.is_authenticated:
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
# Retrieve the portfolio object based on the provided portfolio_id
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
context["portfolio"] = portfolio
# This controls the creation of a new domain request in the wizard
request.session["new_request"] = True
return render(request, "portfolio_requests.html", context)
class PortfolioOrganizationView(PortfolioBasePermissionView, View):
template_name = "portfolio_organization.html"
def get(self, request, portfolio_id):
context = {}
if self.request.user.is_authenticated:
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
context["has_organization_feature_flag"] = flag_is_active(request, "organization_feature")
portfolio = get_object_or_404(Portfolio, id=portfolio_id)
context["portfolio"] = portfolio
return render(request, "portfolio_organization.html", context)

View file

@ -11,7 +11,7 @@ from django.urls import NoReverseMatch, reverse
from registrar.models.user import User
from registrar.models.utility.generic_helper import replace_url_queryparams
from registrar.views.utility.permission_views import UserProfilePermissionView
from waffle.decorators import flag_is_active, waffle_flag
from waffle.decorators import waffle_flag
logger = logging.getLogger(__name__)
@ -51,10 +51,8 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
"""Extend get_context_data to include has_profile_feature_flag"""
"""Extend get_context_data"""
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
# Set the profile_back_button_text based on the redirect parameter
if kwargs.get("redirect") == "domain-request:":
@ -134,7 +132,7 @@ class FinishProfileSetupView(UserProfileView):
base_view_name = "finish-user-profile-setup"
def get_context_data(self, **kwargs):
"""Extend get_context_data to include has_profile_feature_flag"""
"""Extend get_context_data"""
context = super().get_context_data(**kwargs)
# Show back button conditional on user having finished setup

View file

@ -14,14 +14,12 @@ Rather than dealing with that, we keep everything centralized in one location.
"""
from django.shortcuts import render
from waffle.decorators import flag_is_active
def custom_500_error_view(request, context=None):
"""Used to redirect 500 errors to a custom view"""
if context is None:
context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "500.html", context=context, status=500)
@ -29,7 +27,6 @@ def custom_401_error_view(request, context=None):
"""Used to redirect 401 errors to a custom view"""
if context is None:
context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "401.html", context=context, status=401)
@ -37,5 +34,4 @@ def custom_403_error_view(request, exception=None, context=None):
"""Used to redirect 403 errors to a custom view"""
if context is None:
context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "403.html", context=context, status=403)

View file

@ -398,3 +398,49 @@ class UserProfilePermission(PermissionsLoginMixin):
return False
return True
class PortfolioBasePermission(PermissionsLoginMixin):
"""Permission mixin that redirects to portfolio pages if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]
"""
if not self.request.user.is_authenticated:
return False
return self.request.user.has_base_portfolio_permission()
class PortfolioDomainsPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio domain pages if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to domains for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
if not self.request.user.is_authenticated:
return False
return self.request.user.has_domains_portfolio_permission()
class PortfolioDomainRequestsPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio domain request pages if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to domain requests for this portfolio.
The user is in self.request.user and the portfolio can be looked
up from the portfolio's primary key in self.kwargs["pk"]"""
if not self.request.user.is_authenticated:
return False
return self.request.user.has_domain_requests_portfolio_permission()

View file

@ -3,7 +3,7 @@
import abc # abstract base class
from django.views.generic import DetailView, DeleteView, TemplateView
from registrar.models import Domain, DomainRequest, DomainInvitation
from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio
from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole
@ -13,8 +13,11 @@ from .mixins import (
DomainRequestPermissionWithdraw,
DomainInvitationPermission,
DomainRequestWizardPermission,
PortfolioDomainRequestsPermission,
PortfolioDomainsPermission,
UserDeleteDomainRolePermission,
UserProfilePermission,
PortfolioBasePermission,
)
import logging
@ -163,3 +166,38 @@ class UserProfilePermissionView(UserProfilePermission, DetailView, abc.ABC):
@abc.abstractmethod
def template_name(self):
raise NotImplementedError
class PortfolioBasePermissionView(PortfolioBasePermission, DetailView, abc.ABC):
"""Abstract base view for portfolio views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
# DetailView property for what model this is viewing
model = Portfolio
# variable name in template context for the model object
context_object_name = "portfolio"
# Abstract property enforces NotImplementedError on an attribute.
@property
@abc.abstractmethod
def template_name(self):
raise NotImplementedError
class PortfolioDomainsPermissionView(PortfolioDomainsPermission, PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for portfolio domains views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""
class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, PortfolioBasePermissionView, abc.ABC):
"""Abstract base view for portfolio domain request views that enforces permissions.
This abstract view cannot be instantiated. Actual views must specify
`template_name`.
"""

View file

@ -70,6 +70,7 @@
10038 OUTOFSCOPE http://app:8080/org-name-address
10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/
10038 OUTOFSCOPE http://app:8080/organization/
# This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers