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) - 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) - 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) - [ ] 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/) - [ ] Install the [Github CLI](https://cli.github.com/)
## Access ## Access
### Steps for the onboardee ### 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/) - [ ] [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). - [ ] 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 - [ ] 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) - [ ] [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. 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`. 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 ### MacOS
**Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error: **Note:** if you are on a mac and not able to successfully create a signed commit, getting the following error:
```zsh ```zsh

View file

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

View file

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

View file

@ -429,6 +429,10 @@ class ViewsTest(TestCase):
# Create a mock request # Create a mock request
request = self.factory.get("/some-url") request = self.factory.get("/some-url")
request.session = {"acr_value": ""} 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 # Ensure that the CLIENT instance used in login_callback is the mock
# patch _requires_step_up_auth to return False # patch _requires_step_up_auth to return False
with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch( 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 import resources
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -92,6 +93,31 @@ class UserResource(resources.ModelResource):
model = models.User 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): class MyUserAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs. """This form utilizes the custom widget for its class's ManyToMany UIs.
@ -104,6 +130,14 @@ class MyUserAdminForm(UserChangeForm):
widgets = { widgets = {
"groups": NoAutocompleteFilteredSelectMultiple("groups", False), "groups": NoAutocompleteFilteredSelectMultiple("groups", False),
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", 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): def __init__(self, *args, **kwargs):
@ -654,18 +688,49 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"is_superuser", "is_superuser",
"groups", "groups",
"user_permissions", "user_permissions",
"portfolio",
"portfolio_roles",
"portfolio_additional_permissions",
) )
}, },
), ),
("Important dates", {"fields": ("last_login", "date_joined")}), ("Important dates", {"fields": ("last_login", "date_joined")}),
) )
autocomplete_fields = [
"portfolio",
]
readonly_fields = ("verification_type",) 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 = ( 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, None,
{ {
@ -712,6 +777,26 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"Important dates", "Important dates",
"last_login", "last_login",
"date_joined", "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 = ( list_filter = (
@ -786,8 +871,12 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
# Show all fields for all access users # Show all fields for all access users
return super().get_fieldsets(request, obj) return super().get_fieldsets(request, obj)
elif request.user.has_perm("registrar.analyst_access_permission"): elif request.user.has_perm("registrar.analyst_access_permission"):
# show analyst_fieldsets for analysts if flag_is_active(request, "organization_feature"):
return self.analyst_fieldsets # show analyst_fieldsets for analysts
return self.analyst_fieldsets
else:
# TODO: delete after we merge organization feature
return self.analyst_fieldsets_no_portfolio
else: else:
# any admin user should belong to either full_access_group # any admin user should belong to either full_access_group
# or cisa_analyst_group # or cisa_analyst_group
@ -801,7 +890,11 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
else: else:
# Return restrictive Read-only fields for analysts and # Return restrictive Read-only fields for analysts and
# users who might not belong to groups # users who might not belong to groups
return self.analyst_readonly_fields 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): def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add user's related domains and requests to context""" """Add user's related domains and requests to context"""
@ -1004,6 +1097,16 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Get the filtered values # Get the filtered values
return super().changelist_view(request, extra_context=extra_context) 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): class SeniorOfficialAdmin(ListHeaderAdmin):
"""Custom Senior Official Admin class.""" """Custom Senior Official Admin class."""
@ -1288,10 +1391,11 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
] ]
# Readonly fields for analysts and superusers # 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 # Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [ analyst_readonly_fields = [
"federal_agency",
"creator", "creator",
"type_of_work", "type_of_work",
"more_organization_information", "more_organization_information",
@ -1604,12 +1708,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency",
"status_history", "status_history",
) )
# Read only that we'll leverage for CISA Analysts # Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [ analyst_readonly_fields = [
"federal_agency",
"creator", "creator",
"about_your_organization", "about_your_organization",
"requested_domain", "requested_domain",

View file

@ -305,6 +305,8 @@ function addOrRemoveSessionBoolean(name, add){
// "to" select list // "to" select list
checkToListThenInitWidget('id_groups_to', 0); checkToListThenInitWidget('id_groups_to', 0);
checkToListThenInitWidget('id_user_permissions_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, // 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 statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator'); const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter'); 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 * 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 expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url; const actionUrl = domain.action_url;
const suborganization = domain.suborganization ? domain.suborganization : '';
const row = document.createElement('tr'); 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 = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name"> <th scope="row" role="rowheader" data-label="Domain name">
${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> <use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
</svg> </svg>
</td> </td>
${markupForSuborganizationRow}
<td> <td>
<a href="${actionUrl}"> <a href="${actionUrl}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">

View file

@ -29,52 +29,14 @@ body {
#wrapper.dashboard { #wrapper.dashboard {
background-color: color('primary-lightest'); background-color: color('primary-lightest');
padding-top: units(5); padding-top: units(5)!important;
}
.usa-logo {
@include at-media(desktop) {
margin-top: units(2);
}
}
.usa-logo__text {
@include typeset('sans', 'xl', 2);
color: color('primary-darker');
} }
.usa-nav__primary { #wrapper.dashboard--portfolio {
margin-top:units(1); background-color: color('gray-1');
padding-top: units(4)!important;
} }
.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 { .section--outlined {
background-color: color('white'); background-color: color('white');
@ -136,10 +98,6 @@ footer {
color: color('primary'); color: color('primary');
} }
.usa-identifier__logo {
height: units(7);
}
abbr[title] { abbr[title] {
// workaround for underlining abbr element // workaround for underlining abbr element
border-bottom: none; border-bottom: none;
@ -179,47 +137,35 @@ abbr[title] {
cursor: pointer; 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--8-8-9 {
padding: 8px 8px 9px !important; 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 { .usa-button--filter {
width: auto; width: auto;
// For mobile stacking // 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; 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 { .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 "alerts";
@forward "tables"; @forward "tables";
@forward "sidenav"; @forward "sidenav";
@forward "identifier";
@forward "header";
@forward "register-form"; @forward "register-form";
/*-------------------------------------------------- /*--------------------------------------------------

View file

@ -240,6 +240,11 @@ TEMPLATES = [
"registrar.context_processors.canonical_path", "registrar.context_processors.canonical_path",
"registrar.context_processors.is_demo_site", "registrar.context_processors.is_demo_site",
"registrar.context_processors.is_production", "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.domain_requests_json import get_domain_requests_json
from registrar.views.domains_json import get_domains_json from registrar.views.domains_json import get_domains_json
from registrar.views.utility import always_404 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 from api.views import available, get_current_federal, get_current_full
@ -61,14 +61,19 @@ urlpatterns = [
path("", views.index, name="home"), path("", views.index, name="home"),
path( path(
"portfolio/<int:portfolio_id>/domains/", "portfolio/<int:portfolio_id>/domains/",
portfolio_domains, PortfolioDomainsView.as_view(),
name="portfolio-domains", name="portfolio-domains",
), ),
path( path(
"portfolio/<int:portfolio_id>/domain_requests/", "portfolio/<int:portfolio_id>/domain_requests/",
portfolio_domain_requests, PortfolioDomainRequestsView.as_view(),
name="portfolio-domain-requests", name="portfolio-domain-requests",
), ),
path(
"portfolio/<int:portfolio_id>/organization/",
PortfolioOrganizationView.as_view(),
name="portfolio-organization",
),
path( path(
"admin/logout/", "admin/logout/",
RedirectView.as_view(pattern_name="logout", permanent=False), RedirectView.as_view(pattern_name="logout", permanent=False),

View file

@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from waffle.decorators import flag_is_active
def language_code(request): def language_code(request):
@ -36,3 +37,49 @@ def is_demo_site(request):
def is_production(request): def is_production(request):
"""Add a boolean if this is our production site.""" """Add a boolean if this is our production site."""
return {"IS_PRODUCTION": settings.IS_PRODUCTION} 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, # Stores who created this model. If no creator is specified in DJA,
# then the creator will default to the current request user""" # 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( notes = models.TextField(
null=True, null=True,

View file

@ -11,6 +11,8 @@ from .transition_domain import TransitionDomain
from .verified_by_staff import VerifiedByStaff from .verified_by_staff import VerifiedByStaff
from .domain import Domain from .domain import Domain
from .domain_request import DomainRequest 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 from phonenumber_field.modelfields import PhoneNumberField # type: ignore
@ -60,6 +62,57 @@ class User(AbstractUser):
# after they login. # after they login.
FIXTURE_USER = "fixture_user", "Created by fixtures" 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 #### # #### Constants for choice fields ####
RESTRICTED = "restricted" RESTRICTED = "restricted"
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),) STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
@ -80,6 +133,34 @@ class User(AbstractUser):
related_name="users", 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( phone = PhoneNumberField(
null=True, null=True,
blank=True, blank=True,
@ -170,6 +251,57 @@ class User(AbstractUser):
def has_contact_info(self): def has_contact_info(self):
return bool(self.title or self.email or self.phone) 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 @classmethod
def needs_identity_verification(cls, email, uuid): def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification """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() 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 urllib.parse import parse_qs
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from registrar.models.portfolio import Portfolio
from registrar.models.user import User from registrar.models.user import User
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
@ -141,14 +140,20 @@ class CheckPortfolioMiddleware:
def process_view(self, request, view_func, view_args, view_kwargs): def process_view(self, request, view_func, view_args, view_kwargs):
current_path = request.path 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 return None

View file

@ -133,48 +133,10 @@
</section> </section>
{% block usa_overlay %}<div class="usa-overlay"></div>{% endblock %} <div class="usa-overlay"></div>
{% block banner %} {% block header %}
<header class="usa-header usa-header--basic"> {% include "includes/header_selector.html" with logo_clickable=True %}
<div class="usa-nav-container"> {% endblock header %}
<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 %}
{% block wrapper %} {% block wrapper %}
<div id="wrapper"> <div id="wrapper">

View file

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

View file

@ -10,11 +10,11 @@
{# the entire logged in page goes here #} {# the entire logged in page goes here #}
{% block homepage_content %} {% block homepage_content %}
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1"> <div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
{% block messages %} {% block messages %}
{% include "includes/form_messages.html" %} {% include "includes/form_messages.html" %}
{% endblock %} {% endblock %}
<h1>Manage your domains</h1> <h1>Manage your domains</h1>
{% comment %} {% comment %}
@ -32,26 +32,8 @@
{% include "includes/domains_table.html" %} {% include "includes/domains_table.html" %}
{% include "includes/domain_requests_table.html" %} {% include "includes/domain_requests_table.html" %}
{# Note: Reimplement this after MVP #} </div>
<!--
<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>
-->
{% endblock %} {% endblock %}
</div>
{% else %} {# not user.is_authenticated #} {% else %} {# not user.is_authenticated #}
{# the entire logged out page goes here #} {# the entire logged out page goes here #}

View file

@ -2,6 +2,7 @@
<section class="section--outlined domain-requests" id="domain-requests"> <section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row"> <div class="grid-row">
<!-- Use portfolio_base_permission when merging into 2366 and then delete this comment -->
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2> <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"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button"> <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 Reset
</button> </button>
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label> <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"> <section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row"> <div class="grid-row">
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domains-header" class="flex-6">Domains</h2> <h2 id="domains-header" class="flex-6">Domains</h2>
</div> </div>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% endif %} {% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domains search component" class="flex-6 margin-y-2"> <section aria-label="Domains search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% 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 Reset
</button> </button>
<label class="usa-sr-only" for="domains__search-field">Search by domain name</label> <label class="usa-sr-only" for="domains__search-field">Search by domain name</label>
@ -33,9 +38,10 @@
</section> </section>
</div> </div>
</div> </div>
<!-- Use portfolio_base_permission when merging into 2366 then delete this comment -->
{% if portfolio %} {% if portfolio %}
<div class="display-flex flex-align-center margin-top-1"> <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 usa-accordion--select margin-right-2">
<div class="usa-accordion__heading"> <div class="usa-accordion__heading">
<button <button
@ -136,6 +142,10 @@
<th data-sortable="name" scope="col" role="columnheader">Domain name</th> <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="expiration_date" scope="col" role="columnheader">Expires</th>
<th data-sortable="state_display" scope="col" role="columnheader">Status</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 <th
scope="col" scope="col"
role="columnheader" 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 %} {% load static %}
{% block title %} Domains | {% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<h1 id="domains-header">Domains</h1> <h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %} {% 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 %} {% load static %}
{% block title %} Domain requests | {% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<h1 id="domain-requests-header">Domain requests</h1> <h1 id="domain-requests-header">Domain requests</h1>
@ -16,6 +18,6 @@
Start a new domain request Start a new domain request
</a> </a>
</p> </p>
{% include "includes/domain_requests_table.html" with portfolio=portfolio %} {% include "includes/domain_requests_table.html" with portfolio=portfolio %}
{% endblock %} {% endblock %}

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 %} {% load static url_helpers %}
{# Disable the redirect #} {# Disable the redirect #}
{% block logo %} {% block header %}
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %} {% include "includes/header_selector.html" with logo_clickable=user_finished_setup %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View file

@ -2305,7 +2305,6 @@ class TestDomainRequestAdmin(MockEppLib):
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency",
"status_history", "status_history",
"id", "id",
"created_at", "created_at",
@ -2366,8 +2365,8 @@ class TestDomainRequestAdmin(MockEppLib):
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency",
"status_history", "status_history",
"federal_agency",
"creator", "creator",
"about_your_organization", "about_your_organization",
"requested_domain", "requested_domain",
@ -2397,7 +2396,6 @@ class TestDomainRequestAdmin(MockEppLib):
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
"is_election_board", "is_election_board",
"federal_agency",
"status_history", "status_history",
] ]
@ -3659,7 +3657,15 @@ class TestMyUserAdmin(MockDb):
}, },
), ),
("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), ("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")}), ("Important dates", {"fields": ("last_login", "date_joined")}),
) )
self.assertEqual(fieldsets, expected_fieldsets) self.assertEqual(fieldsets, expected_fieldsets)
@ -3755,6 +3761,22 @@ class TestMyUserAdmin(MockDb):
expected_href = reverse("admin:registrar_domain_change", args=[domain_deleted.pk]) expected_href = reverse("admin:registrar_domain_change", args=[domain_deleted.pk])
self.assertNotContains(response, expected_href) 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): class AuditedAdminTest(TestCase):
def setUp(self): def setUp(self):

View file

@ -19,6 +19,7 @@ from registrar.models import (
) )
import boto3_mocking import boto3_mocking
from registrar.models.portfolio import Portfolio
from registrar.models.transition_domain import TransitionDomain from registrar.models.transition_domain import TransitionDomain
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
from registrar.utility.constants import BranchChoices from registrar.utility.constants import BranchChoices
@ -1214,6 +1215,68 @@ class TestUser(TestCase):
self.user.phone = None self.user.phone = None
self.assertFalse(self.user.has_contact_info()) 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): class TestContact(TestCase):
def setUp(self): def setUp(self):

View file

@ -559,7 +559,6 @@ class ExportDataTest(MockDb, MockEppLib):
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
csv_content = csv_file.read() csv_content = csv_file.read()
print(csv_content)
expected_content = ( expected_content = (
# Header # Header
"Domain request,Status,Domain type,Federal type," "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] session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) 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 @less_console_noise_decorator
def test_no_redirect_when_org_flag_false(self): def test_no_redirect_when_org_flag_false(self):
"""No redirect so no follow, """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.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
from waffle.decorators import flag_is_active, waffle_flag from waffle.decorators import waffle_flag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -102,13 +102,6 @@ class DomainBaseView(DomainPermissionView):
domain_pk = "domain:" + str(self.kwargs.get("pk")) domain_pk = "domain:" + str(self.kwargs.get("pk"))
self.session[domain_pk] = self.object 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): class DomainFormBaseView(DomainBaseView, FormMixin):
""" """

View file

@ -228,10 +228,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
if request.path_info == self.NEW_URL_NAME: if request.path_info == self.NEW_URL_NAME:
# Clear context so the prop getter won't create a request here. # Clear context so the prop getter won't create a request here.
# Creating a request will be handled in the post method for the # Creating a request will be handled in the post method for the
# intro page. Only TEMPORARY context needed is has_profile_flag # intro page.
has_profile_flag = flag_is_active(self.request, "profile_feature") return render(request, "domain_request_intro.html", {})
context_stuff = {"has_profile_feature_flag": has_profile_flag}
return render(request, "domain_request_intro.html", context=context_stuff)
else: else:
return self.goto(self.steps.first) return self.goto(self.steps.first)
@ -380,7 +378,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
def get_context_data(self): def get_context_data(self):
"""Define context for access on all wizard pages.""" """Define context for access on all wizard pages."""
has_profile_flag = flag_is_active(self.request, "profile_feature")
context_stuff = {} context_stuff = {}
if DomainRequest._form_complete(self.domain_request, self.request): 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.\ "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.", Youll only be able to withdraw your request.",
"review_form_is_complete": True, "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, "user": self.request.user,
} }
else: # form is not complete else: # form is not complete
@ -414,7 +409,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
"modal_description": 'This request cannot be submitted yet.\ "modal_description": 'This request cannot be submitted yet.\
Return to the request and visit the steps that are marked as "incomplete."', Return to the request and visit the steps that are marked as "incomplete."',
"review_form_is_complete": False, "review_form_is_complete": False,
"has_profile_feature_flag": has_profile_flag,
"user": self.request.user, "user": self.request.user,
} }
return context_stuff return context_stuff
@ -740,13 +734,6 @@ class Finished(DomainRequestWizard):
class DomainRequestStatus(DomainRequestPermissionView): class DomainRequestStatus(DomainRequestPermissionView):
template_name = "domain_request_status.html" 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): class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
"""This page will ask user to confirm if they want to withdraw """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" 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): class DomainRequestWithdrawn(DomainRequestPermissionWithdrawView):
# this view renders no template # this view renders no template

View file

@ -1,3 +1,4 @@
import logging
from django.http import JsonResponse from django.http import JsonResponse
from django.core.paginator import Paginator from django.core.paginator import Paginator
from registrar.models import UserDomainRole, Domain 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.urls import reverse
from django.db.models import Q from django.db.models import Q
logger = logging.getLogger(__name__)
@login_required @login_required
def get_domains_json(request): def get_domains_json(request):
"""Given the current request, """Given the current request,
get all domains that are associated with the UserDomainRole object""" 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) domain_ids = user_domain_roles.values_list("domain_id", flat=True)
objects = Domain.objects.filter(id__in=domain_ids) objects = Domain.objects.filter(id__in=domain_ids)
unfiltered_total = objects.count() unfiltered_total = objects.count()
# Handle sorting objects = apply_search(objects, request)
sort_by = request.GET.get("sort_by", "id") # Default to 'id' objects = apply_state_filter(objects, request)
order = request.GET.get("order", "asc") # Default to 'asc' objects = apply_sorting(objects, request)
# 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)
paginator = Paginator(objects, 10) paginator = Paginator(objects, 10)
page_number = request.GET.get("page") page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
# Convert objects to JSON-serializable format domains = [serialize_domain(domain) for domain in page_obj.object_list]
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
]
return JsonResponse( return JsonResponse(
{ {
@ -100,3 +41,80 @@ def get_domains_json(request):
"unfiltered_total": unfiltered_total, "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 django.shortcuts import render
from waffle.decorators import flag_is_active
def index(request): def index(request):
@ -7,10 +6,6 @@ def index(request):
context = {} context = {}
if request.user.is_authenticated: 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 # This controls the creation of a new domain request in the wizard
request.session["new_request"] = True request.session["new_request"] = True

View file

@ -1,39 +1,58 @@
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from registrar.models.portfolio import Portfolio from registrar.models.portfolio import Portfolio
from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView,
PortfolioDomainsPermissionView,
PortfolioBasePermissionView,
)
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
from django.contrib.auth.decorators import login_required from django.views.generic import View
@login_required class PortfolioDomainsView(PortfolioDomainsPermissionView, View):
def portfolio_domains(request, portfolio_id):
context = {}
if request.user.is_authenticated: template_name = "portfolio_domains.html"
# 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")
# Retrieve the portfolio object based on the provided portfolio_id def get(self, request, portfolio_id):
portfolio = get_object_or_404(Portfolio, id=portfolio_id) context = {}
context["portfolio"] = portfolio
return render(request, "portfolio_domains.html", 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_domains.html", context)
@login_required class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
def portfolio_domain_requests(request, portfolio_id):
context = {}
if request.user.is_authenticated: template_name = "portfolio_requests.html"
# 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")
# Retrieve the portfolio object based on the provided portfolio_id def get(self, request, portfolio_id):
portfolio = get_object_or_404(Portfolio, id=portfolio_id) context = {}
context["portfolio"] = portfolio
# This controls the creation of a new domain request in the wizard if self.request.user.is_authenticated:
request.session["new_request"] = True 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
request.session["new_request"] = True
return render(request, "portfolio_requests.html", context) 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.user import User
from registrar.models.utility.generic_helper import replace_url_queryparams from registrar.models.utility.generic_helper import replace_url_queryparams
from registrar.views.utility.permission_views import UserProfilePermissionView 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__) logger = logging.getLogger(__name__)
@ -51,10 +51,8 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **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) 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 # Set the profile_back_button_text based on the redirect parameter
if kwargs.get("redirect") == "domain-request:": if kwargs.get("redirect") == "domain-request:":
@ -134,7 +132,7 @@ class FinishProfileSetupView(UserProfileView):
base_view_name = "finish-user-profile-setup" base_view_name = "finish-user-profile-setup"
def get_context_data(self, **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) context = super().get_context_data(**kwargs)
# Show back button conditional on user having finished setup # 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 django.shortcuts import render
from waffle.decorators import flag_is_active
def custom_500_error_view(request, context=None): def custom_500_error_view(request, context=None):
"""Used to redirect 500 errors to a custom view""" """Used to redirect 500 errors to a custom view"""
if context is None: if context is None:
context = {} context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "500.html", context=context, status=500) 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""" """Used to redirect 401 errors to a custom view"""
if context is None: if context is None:
context = {} context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "401.html", context=context, status=401) 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""" """Used to redirect 403 errors to a custom view"""
if context is None: if context is None:
context = {} context = {}
context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature")
return render(request, "403.html", context=context, status=403) return render(request, "403.html", context=context, status=403)

View file

@ -398,3 +398,49 @@ class UserProfilePermission(PermissionsLoginMixin):
return False return False
return True 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 import abc # abstract base class
from django.views.generic import DetailView, DeleteView, TemplateView 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 import User
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
@ -13,8 +13,11 @@ from .mixins import (
DomainRequestPermissionWithdraw, DomainRequestPermissionWithdraw,
DomainInvitationPermission, DomainInvitationPermission,
DomainRequestWizardPermission, DomainRequestWizardPermission,
PortfolioDomainRequestsPermission,
PortfolioDomainsPermission,
UserDeleteDomainRolePermission, UserDeleteDomainRolePermission,
UserProfilePermission, UserProfilePermission,
PortfolioBasePermission,
) )
import logging import logging
@ -163,3 +166,38 @@ class UserProfilePermissionView(UserProfilePermission, DetailView, abc.ABC):
@abc.abstractmethod @abc.abstractmethod
def template_name(self): def template_name(self):
raise NotImplementedError 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/org-name-address
10038 OUTOFSCOPE http://app:8080/domain_requests/ 10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/ 10038 OUTOFSCOPE http://app:8080/domains/
10038 OUTOFSCOPE http://app:8080/organization/
# This URL always returns 404, so include it as well. # This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo 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 # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers