Merge branch 'main' into za/2671-show-portfolios-on-user-table

This commit is contained in:
zandercymatics 2024-09-09 13:03:44 -06:00
commit 765a706f4d
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
65 changed files with 1609 additions and 264 deletions

View file

@ -63,3 +63,4 @@ The class also provides helper methods:
- `get_class_name`: Returns a display-friendly class name for the terminal prompt - `get_class_name`: Returns a display-friendly class name for the terminal prompt
- `get_failure_message`: Returns the message to display if a record fails to update - `get_failure_message`: Returns the message to display if a record fails to update
- `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped) - `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped)
- `custom_filter`: Allows for additional filters that cannot be expressed using django queryset field lookups

View file

@ -817,6 +817,28 @@ Example: `cf ssh getgov-za`
|:-:|:-------------------------- |:-----------------------------------------------------------------------------------| |:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
| 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is | | 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is |
## Update First Ready Values
This section outlines how to run the populate_first_ready script
### Running on sandboxes
#### Step 1: Login to CloudFoundry
```cf login -a api.fr.cloud.gov --sso```
#### Step 2: SSH into your environment
```cf ssh getgov-{space}```
Example: `cf ssh getgov-za`
#### Step 3: Create a shell instance
```/tmp/lifecycle/shell```
#### Step 4: Running the script
```./manage.py update_first_ready```
### Running locally
```docker-compose exec app ./manage.py update_first_ready```
## Populate Domain Request Dates ## Populate Domain Request Dates
This section outlines how to run the populate_domain_request_dates script This section outlines how to run the populate_domain_request_dates script

View file

@ -11,6 +11,7 @@ from django.conf import settings
from django.shortcuts import redirect from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_information import DomainInformation from registrar.models.domain_information import DomainInformation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
from django.contrib import admin, messages from django.contrib import admin, messages
@ -2970,11 +2971,7 @@ class PortfolioAdmin(ListHeaderAdmin):
fieldsets = [ fieldsets = [
# created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}" # created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
(None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}), (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
# TODO - uncomment in #2521 ("Portfolio members", {"fields": ["display_admins", "display_members"]}),
# ("Portfolio members", {
# "classes": ("collapse", "closed"),
# "fields": ["administrators", "members"]}
# ),
("Portfolio domains", {"fields": ["domains", "domain_requests"]}), ("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
("Type of organization", {"fields": ["organization_type", "federal_type"]}), ("Type of organization", {"fields": ["organization_type", "federal_type"]}),
( (
@ -3022,15 +3019,118 @@ class PortfolioAdmin(ListHeaderAdmin):
readonly_fields = [ readonly_fields = [
# This is the created_at field # This is the created_at field
"created_on", "created_on",
# Custom fields such as these must be defined as readonly. # Django admin doesn't allow methods to be directly listed in fieldsets. We can
# display the custom methods display_admins amd display_members in the admin form if
# they are readonly.
"federal_type", "federal_type",
"domains", "domains",
"domain_requests", "domain_requests",
"suborganizations", "suborganizations",
"portfolio_type", "portfolio_type",
"display_admins",
"display_members",
"creator", "creator",
] ]
def get_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio
admin_permissions = UserPortfolioPermission.objects.filter(
portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Get the user objects associated with these permissions
admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions)
return admin_users
def get_non_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude(
roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Get the user objects associated with these permissions
non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions)
return non_admin_users
def display_admins(self, obj):
"""Get joined users who are Admin, unpack and return an HTML block.
'DJA readonly can't handle querysets, so we need to unpack and return html here.
Alternatively, we could return querysets in context but that would limit where this
data would display in a custom change form without extensive template customization.
Will be used in the field_readonly block"""
admins = self.get_admin_users(obj)
if not admins:
return format_html("<p>No admins found.</p>")
admin_details = ""
for portfolio_admin in admins:
change_url = reverse("admin:registrar_user_change", args=[portfolio_admin.pk])
admin_details += "<address class='margin-bottom-2 dja-address-contact-list'>"
admin_details += f'<a href="{change_url}">{escape(portfolio_admin)}</a><br>'
admin_details += f"{escape(portfolio_admin.title)}<br>"
admin_details += f"{escape(portfolio_admin.email)}"
admin_details += "<div class='admin-icon-group admin-icon-group__clipboard-link'>"
admin_details += f"<input aria-hidden='true' class='display-none' value='{escape(portfolio_admin.email)}'>"
admin_details += (
"<button class='usa-button usa-button--unstyled padding-right-1 usa-button--icon padding-left-05"
+ "button--clipboard copy-to-clipboard text-no-underline' type='button'>"
)
admin_details += "<svg class='usa-icon'>"
admin_details += "<use aria-hidden='true' xlink:href='/public/img/sprite.svg#content_copy'></use>"
admin_details += "</svg>"
admin_details += "Copy"
admin_details += "</button>"
admin_details += "</div><br>"
admin_details += f"{escape(portfolio_admin.phone)}"
admin_details += "</address>"
return format_html(admin_details)
display_admins.short_description = "Administrators" # type: ignore
def display_members(self, obj):
"""Get joined users who have roles/perms that are not Admin, unpack and return an HTML block.
DJA readonly can't handle querysets, so we need to unpack and return html here.
Alternatively, we could return querysets in context but that would limit where this
data would display in a custom change form without extensive template customization.
Will be used in the after_help_text block."""
members = self.get_non_admin_users(obj)
if not members:
return ""
member_details = (
"<table><thead><tr><th>Name</th><th>Title</th><th>Email</th>"
+ "<th>Phone</th><th>Roles</th></tr></thead><tbody>"
)
for member in members:
full_name = member.get_formatted_name()
member_details += "<tr>"
member_details += f"<td>{escape(full_name)}</td>"
member_details += f"<td>{escape(member.title)}</td>"
member_details += f"<td>{escape(member.email)}</td>"
member_details += f"<td>{escape(member.phone)}</td>"
member_details += "<td>"
for role in member.portfolio_role_summary(obj):
member_details += f"<span class='usa-tag'>{escape(role)}</span> "
member_details += "</td></tr>"
member_details += "</tbody></table>"
return format_html(member_details)
display_members.short_description = "Members" # type: ignore
def display_members_summary(self, obj):
"""Will be passed as context and used in the field_readonly block."""
members = self.get_non_admin_users(obj)
if not members:
return {}
return self.get_field_links_as_list(members, "user", separator=", ")
def federal_type(self, obj: models.Portfolio): def federal_type(self, obj: models.Portfolio):
"""Returns the federal_type field""" """Returns the federal_type field"""
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-" return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
@ -3090,7 +3190,7 @@ class PortfolioAdmin(ListHeaderAdmin):
] ]
def get_field_links_as_list( def get_field_links_as_list(
self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
): ):
""" """
Generate HTML links for items in a queryset, using a specified attribute for link text. Generate HTML links for items in a queryset, using a specified attribute for link text.
@ -3122,14 +3222,14 @@ class PortfolioAdmin(ListHeaderAdmin):
if link_info_attribute: if link_info_attribute:
link += f" ({self.value_of_attribute(item, link_info_attribute)})" link += f" ({self.value_of_attribute(item, link_info_attribute)})"
if seperator: if separator:
links.append(link) links.append(link)
else: else:
links.append(f"<li>{link}</li>") links.append(f"<li>{link}</li>")
# If no seperator is specified, just return an unordered list. # If no separator is specified, just return an unordered list.
if seperator: if separator:
return format_html(seperator.join(links)) if links else "-" return format_html(separator.join(links)) if links else "-"
else: else:
links = "".join(links) links = "".join(links)
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else "-" return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else "-"
@ -3172,8 +3272,12 @@ class PortfolioAdmin(ListHeaderAdmin):
return readonly_fields return readonly_fields
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 related suborganizations and domain groups""" """Add related suborganizations and domain groups.
extra_context = {"skip_additional_contact_info": True} Add the summary for the portfolio members field (list of members that link to change_forms)."""
obj = self.get_object(request, object_id)
extra_context = extra_context or {}
extra_context["skip_additional_contact_info"] = True
extra_context["display_members_summary"] = self.display_members_summary(obj)
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):

View file

@ -0,0 +1,14 @@
// Use Django's jQuery with Select2 to make the user select on the user transfer view a combobox
(function($) {
$(document).ready(function() {
if ($) {
$("#selected_user").select2({
width: 'resolve',
placeholder: 'Select a user',
allowClear: true
});
} else {
console.error('jQuery is not available');
}
});
})(window.jQuery);

View file

@ -172,6 +172,7 @@ function addOrRemoveSessionBoolean(name, add){
** To perform data operations on this - we need to use jQuery rather than vanilla js. ** To perform data operations on this - we need to use jQuery rather than vanilla js.
*/ */
(function (){ (function (){
if (document.getElementById("id_investigator") && django && django.jQuery) {
let selector = django.jQuery("#id_investigator") let selector = django.jQuery("#id_investigator")
let assignSelfButton = document.querySelector("#investigator__assign_self"); let assignSelfButton = document.querySelector("#investigator__assign_self");
if (!selector || !assignSelfButton) { if (!selector || !assignSelfButton) {
@ -203,9 +204,7 @@ function addOrRemoveSessionBoolean(name, add){
// The parent container has display type flex. // The parent container has display type flex.
assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex"; assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
}); });
}
})(); })();
/** An IIFE for pages in DjangoAdmin that use a clipboard button /** An IIFE for pages in DjangoAdmin that use a clipboard button
@ -215,7 +214,6 @@ function addOrRemoveSessionBoolean(name, add){
function copyToClipboardAndChangeIcon(button) { function copyToClipboardAndChangeIcon(button) {
// Assuming the input is the previous sibling of the button // Assuming the input is the previous sibling of the button
let input = button.previousElementSibling; let input = button.previousElementSibling;
let userId = input.getAttribute("user-id")
// Copy input value to clipboard // Copy input value to clipboard
if (input) { if (input) {
navigator.clipboard.writeText(input.value).then(function() { navigator.clipboard.writeText(input.value).then(function() {

View file

@ -1220,7 +1220,7 @@ 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 suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯';
const row = document.createElement('tr'); const row = document.createElement('tr');
@ -1229,7 +1229,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!noPortfolioFlag) { if (!noPortfolioFlag) {
markupForSuborganizationRow = ` markupForSuborganizationRow = `
<td> <td>
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span> <span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
</td> </td>
` `
} }
@ -1910,7 +1910,7 @@ document.addEventListener('DOMContentLoaded', function() {
let editableFormGroup = button.parentElement.parentElement.parentElement; let editableFormGroup = button.parentElement.parentElement.parentElement;
if (editableFormGroup){ if (editableFormGroup){
let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field") let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field")
let inputField = document.getElementById(`id_${fieldName}`); let inputField = document.getElementById(`id_${fieldName}`);
if (!inputField || !readonlyField) { if (!inputField || !readonlyField) {
return; return;
@ -1936,8 +1936,8 @@ document.addEventListener('DOMContentLoaded', function() {
// Keep the path before '#' and replace the part after '#' with 'invalid' // Keep the path before '#' and replace the part after '#' with 'invalid'
const newHref = parts[0] + '#error'; const newHref = parts[0] + '#error';
svg.setAttribute('xlink:href', newHref); svg.setAttribute('xlink:href', newHref);
fullNameField.classList.add("input-with-edit-button__error") fullNameField.classList.add("toggleable_input__error")
label = fullNameField.querySelector(".input-with-edit-button__readonly-field") label = fullNameField.querySelector(".toggleable_input__readonly-field")
label.innerHTML = "Unknown"; label.innerHTML = "Unknown";
} }
} }
@ -2043,7 +2043,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Due to the nature of how uswds works, this is slightly hacky. // Due to the nature of how uswds works, this is slightly hacky.
// Use a MutationObserver to watch for changes in the dropdown list // Use a MutationObserver to watch for changes in the dropdown list
const dropdownList = document.querySelector(`#${input.id}--list`); const dropdownList = comboBox.querySelector(`#${input.id}--list`);
const observer = new MutationObserver(function(mutations) { const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) { mutations.forEach(function(mutation) {
if (mutation.type === "childList") { if (mutation.type === "childList") {
@ -2111,7 +2111,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!initialValue){ if (!initialValue){
blankOption.classList.add("usa-combo-box__list-option--selected") blankOption.classList.add("usa-combo-box__list-option--selected")
} }
blankOption.textContent = "---------"; blankOption.textContent = "";
dropdownList.insertBefore(blankOption, dropdownList.firstChild); dropdownList.insertBefore(blankOption, dropdownList.firstChild);
blankOption.addEventListener("click", (e) => { blankOption.addEventListener("click", (e) => {

View file

@ -126,7 +126,7 @@ html[data-theme="light"] {
body.dashboard, body.dashboard,
body.change-list, body.change-list,
body.change-form, body.change-form,
.analytics { .custom-admin-template, dt {
color: var(--body-fg); color: var(--body-fg);
} }
.usa-table td { .usa-table td {
@ -155,7 +155,7 @@ html[data-theme="dark"] {
body.dashboard, body.dashboard,
body.change-list, body.change-list,
body.change-form, body.change-form,
.analytics { .custom-admin-template, dt {
color: var(--body-fg); color: var(--body-fg);
} }
.usa-table td { .usa-table td {
@ -370,14 +370,60 @@ input.admin-confirm-button {
list-style-type: none; list-style-type: none;
line-height: normal; line-height: normal;
} }
.button { }
// This block resolves some of the issues we're seeing on buttons due to css
// conflicts between DJ and USWDS
a.button,
.usa-button--dja {
display: inline-block; display: inline-block;
padding: 10px 8px; padding: 10px 15px;
line-height: normal; font-size: 14px;
line-height: 16.1px;
font-kerning: auto;
font-family: inherit;
font-weight: normal;
}
.button svg,
.button span,
.usa-button--dja svg,
.usa-button--dja span {
vertical-align: middle;
}
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
background: var(--button-bg);
}
.usa-button--dja span {
font-size: 14px;
}
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary):hover {
background: var(--button-hover-bg);
} }
a.button:active, a.button:focus { a.button:active, a.button:focus {
text-decoration: none; text-decoration: none;
} }
.usa-modal {
font-family: inherit;
}
input[type=submit].button--dja-toolbar {
border: 1px solid var(--border-color);
font-size: 0.8125rem;
padding: 4px 8px;
margin: 0;
vertical-align: middle;
background: var(--body-bg);
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
cursor: pointer;
color: var(--body-fg);
}
input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
border-color: var(--body-quiet-color);
}
// Targets the DJA buttom with a nested icon
button .usa-icon,
.button .usa-icon,
.button--clipboard .usa-icon {
vertical-align: middle;
} }
.module--custom { .module--custom {
@ -471,13 +517,6 @@ address.dja-address-contact-list {
color: var(--link-fg); color: var(--link-fg);
} }
// Targets the DJA buttom with a nested icon
button .usa-icon,
.button .usa-icon,
.button--clipboard .usa-icon {
vertical-align: middle;
}
.errors span.select2-selection { .errors span.select2-selection {
border: 1px solid var(--error-fg) !important; border: 1px solid var(--error-fg) !important;
} }
@ -738,7 +777,7 @@ div.dja__model-description{
li { li {
list-style-type: disc; list-style-type: disc;
font-family: Source Sans Pro Web,Helvetica Neue,Helvetica,Roboto,Arial,sans-serif; font-family: family('sans');
} }
a, a:link, a:visited { a, a:link, a:visited {
@ -878,3 +917,16 @@ ul.add-list-reset {
padding: 0 !important; padding: 0 !important;
margin: 0 !important; margin: 0 !important;
} }
// Fix the combobox when deployed outside admin (eg user transfer)
.submit-row .select2,
.submit-row .select2 span {
margin-top: 0;
}
.transfer-user-selector .select2-selection__placeholder {
color: #3d4551!important;
}
.dl-dja dt {
font-size: 14px;
}

View file

@ -33,16 +33,19 @@ body {
} }
#wrapper.dashboard--portfolio { #wrapper.dashboard--portfolio {
background-color: color('gray-1');
padding-top: units(4)!important; padding-top: units(4)!important;
} }
#wrapper.dashboard--grey-1 {
background-color: color('gray-1');
}
.section--outlined {
.section-outlined {
background-color: color('white'); background-color: color('white');
border: 1px solid color('base-lighter'); border: 1px solid color('base-lighter');
border-radius: 4px; border-radius: 4px;
padding: 0 units(2) units(3); padding: 0 units(4) units(3) units(2);
margin-top: units(3); margin-top: units(3);
&.margin-top-0 { &.margin-top-0 {
@ -72,9 +75,13 @@ body {
} }
} }
.section--outlined__header--no-portfolio { .section-outlined--border-base-light {
.section--outlined__search, border: 1px solid color('base-light');
.section--outlined__utility-button { }
.section-outlined__header--no-portfolio {
.section-outlined__search,
.section-outlined__utility-button {
margin-top: units(2); margin-top: units(2);
} }
@ -82,11 +89,11 @@ body {
display: flex; display: flex;
column-gap: units(3); column-gap: units(3);
.section--outlined__search, .section-outlined__search,
.section--outlined__utility-button { .section-outlined__utility-button {
margin-top: 0; margin-top: 0;
} }
.section--outlined__search { .section-outlined__search {
flex-grow: 4; flex-grow: 4;
// Align right // Align right
max-width: 383px; max-width: 383px;
@ -192,3 +199,7 @@ abbr[title] {
max-width: 50ch; max-width: 50ch;
} }
} }
.margin-right-neg-4px {
margin-right: -4px;
}

View file

@ -124,10 +124,6 @@ a.withdraw:active {
background-color: color('error-darker'); background-color: color('error-darker');
} }
.usa-button--unstyled .usa-icon {
vertical-align: bottom;
}
a.usa-button--unstyled:visited { a.usa-button--unstyled:visited {
color: color('primary'); color: color('primary');
} }
@ -162,14 +158,14 @@ a.usa-button--unstyled:visited {
} }
} }
.input-with-edit-button { .toggleable_input {
svg.usa-icon { svg.usa-icon {
width: 1.5em !important; width: 1.5em !important;
height: 1.5em !important; height: 1.5em !important;
color: #{$dhs-green}; color: #{$dhs-green};
position: absolute; position: absolute;
} }
&.input-with-edit-button__error { &.toggleable_input__error {
svg.usa-icon { svg.usa-icon {
color: #{$dhs-red}; color: #{$dhs-red};
} }
@ -205,12 +201,32 @@ a.usa-button--unstyled:visited {
} }
} }
.dotgov-table a,
.usa-link--icon,
.usa-button--with-icon {
display: flex;
align-items: flex-start;
color: color('primary');
column-gap: units(.5);
align-items: center;
}
.dotgov-table a,
.usa-link--icon {
&:visited {
color: color('primary');
}
}
a .usa-icon,
.usa-button--with-icon .usa-icon {
height: 1.3em;
width: 1.3em;
}
.usa-icon.usa-icon--big { .usa-icon.usa-icon--big {
margin: 0; margin: 0;
height: 1.5em; height: 1.5em;
width: 1.5em; width: 1.5em;
} }
.margin-right-neg-4px {
margin-right: -4px;
}

View file

@ -1,18 +0,0 @@
@use "uswds-core" as *;
.dotgov-table a,
.usa-link--icon {
display: flex;
align-items: flex-start;
color: color('primary');
&:visited {
color: color('primary');
}
.usa-icon {
// align icon with x height
margin-top: units(0.5);
margin-right: units(0.5);
}
}

View file

@ -1,5 +1,10 @@
@use "uswds-core" as *; @use "uswds-core" as *;
td,
th {
vertical-align: top;
}
.dotgov-table--stacked { .dotgov-table--stacked {
td, th { td, th {
padding: units(1) units(2) units(2px) 0; padding: units(1) units(2) units(2px) 0;
@ -12,7 +17,7 @@
tr { tr {
border-bottom: none; border-bottom: none;
border-top: 2px solid color('base-light'); border-top: 2px solid color('base-lighter');
margin-top: units(2); margin-top: units(2);
&:first-child { &:first-child {
@ -39,10 +44,6 @@
.dotgov-table { .dotgov-table {
width: 100%; width: 100%;
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
tbody th { tbody th {
word-break: break-word; word-break: break-word;
} }
@ -56,7 +57,7 @@
} }
td, th { td, th {
border-bottom: 1px solid color('base-light'); border-bottom: 1px solid color('base-lighter');
} }
thead th { thead th {
@ -72,11 +73,17 @@
td, th, td, th,
.usa-tabel th{ .usa-tabel th{
padding: units(2) units(2) units(2) 0; padding: units(2) units(4) units(2) 0;
} }
thead tr:first-child th:first-child { thead tr:first-child th:first-child {
border-top: none; border-top: none;
} }
} }
@include at-media(tablet-lg) {
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
}
} }

View file

@ -10,7 +10,6 @@
--- Custom Styles ---------------------------------*/ --- Custom Styles ---------------------------------*/
@forward "base"; @forward "base";
@forward "typography"; @forward "typography";
@forward "links";
@forward "lists"; @forward "lists";
@forward "accordions"; @forward "accordions";
@forward "buttons"; @forward "buttons";

View file

@ -357,13 +357,18 @@ CSP_FORM_ACTION = allowed_sources
# and inline with a nonce, as well as allowing connections back to their domain. # and inline with a nonce, as well as allowing connections back to their domain.
# Note: If needed, we can embed chart.js instead of using the CDN # Note: If needed, we can embed chart.js instead of using the CDN
CSP_DEFAULT_SRC = ("'self'",) CSP_DEFAULT_SRC = ("'self'",)
CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"] CSP_STYLE_SRC = [
"'self'",
"https://www.ssa.gov/accessibility/andi/andi.css",
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css",
]
CSP_SCRIPT_SRC_ELEM = [ CSP_SCRIPT_SRC_ELEM = [
"'self'", "'self'",
"https://www.googletagmanager.com/", "https://www.googletagmanager.com/",
"https://cdn.jsdelivr.net/npm/chart.js", "https://cdn.jsdelivr.net/npm/chart.js",
"https://www.ssa.gov", "https://www.ssa.gov",
"https://ajax.googleapis.com", "https://ajax.googleapis.com",
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js",
] ]
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"] CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"] CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]

View file

@ -24,6 +24,7 @@ from registrar.views.report_views import (
from registrar.views.domain_request import Step 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.transfer_user import TransferUserView
from registrar.views.utility.api_views import ( from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json, get_senior_official_from_federal_agency_json,
get_federal_and_portfolio_types_from_federal_agency_json, get_federal_and_portfolio_types_from_federal_agency_json,
@ -137,6 +138,7 @@ urlpatterns = [
AnalyticsView.as_view(), AnalyticsView.as_view(),
name="analytics", name="analytics",
), ),
path("admin/registrar/user/<int:user_id>/transfer/", TransferUserView.as_view(), name="transfer_user"),
path( path(
"admin/api/get-senior-official-from-federal-agency-json/", "admin/api/get-senior-official-from-federal-agency-json/",
get_senior_official_from_federal_agency_json, get_senior_official_from_federal_agency_json,

View file

@ -60,6 +60,17 @@ def add_has_profile_feature_flag_to_context(request):
def portfolio_permissions(request): def portfolio_permissions(request):
"""Make portfolio permissions for the request user available in global context""" """Make portfolio permissions for the request user available in global context"""
context = {
"has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": False,
"has_view_members_portfolio_permission": False,
"has_edit_members_portfolio_permission": False,
"has_view_suborganization": False,
"has_edit_suborganization": False,
"portfolio": None,
"has_organization_feature_flag": False,
}
try: try:
portfolio = request.session.get("portfolio") portfolio = request.session.get("portfolio")
if portfolio: if portfolio:
@ -69,29 +80,15 @@ def portfolio_permissions(request):
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission( "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(
portfolio portfolio
), ),
"has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio),
"has_edit_members_portfolio_permission": request.user.has_edit_members_portfolio_permission(portfolio),
"has_view_suborganization": request.user.has_view_suborganization(portfolio), "has_view_suborganization": request.user.has_view_suborganization(portfolio),
"has_edit_suborganization": request.user.has_edit_suborganization(portfolio), "has_edit_suborganization": request.user.has_edit_suborganization(portfolio),
"portfolio": portfolio, "portfolio": portfolio,
"has_organization_feature_flag": True, "has_organization_feature_flag": True,
} }
return { return context
"has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": False,
"has_view_suborganization": False,
"has_edit_suborganization": False,
"portfolio": None,
"has_organization_feature_flag": False,
}
except AttributeError: except AttributeError:
# Handles cases where request.user might not exist # Handles cases where request.user might not exist
return { return context
"has_base_portfolio_permission": False,
"has_domains_portfolio_permission": False,
"has_domain_requests_portfolio_permission": False,
"has_view_suborganization": False,
"has_edit_suborganization": False,
"portfolio": None,
"has_organization_feature_flag": False,
}

View file

@ -417,7 +417,7 @@ class SeniorOfficialContactForm(ContactForm):
# This action should be blocked by the UI, as the text fields are readonly. # This action should be blocked by the UI, as the text fields are readonly.
# If they get past this point, we forbid it this way. # If they get past this point, we forbid it this way.
# This could be malicious, so lets reserve information for the backend only. # This could be malicious, so lets reserve information for the backend only.
raise ValueError("Senior Official cannot be modified for federal or tribal domains.") raise ValueError("Senior official cannot be modified for federal or tribal domains.")
elif db_so.has_more_than_one_join("information_senior_official"): elif db_so.has_more_than_one_join("information_senior_official"):
# Handle the case where the domain information object is available and the SO Contact # Handle the case where the domain information object is available and the SO Contact
# has more than one joined object. # has more than one joined object.

View file

@ -21,7 +21,7 @@ class Command(BaseCommand):
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=""" prompt_message="""
This script will delete all rows from the following tables: This script will delete all rows from the following tables:
* Contact * Contact
* Domain * Domain

View file

@ -130,7 +130,7 @@ class Command(BaseCommand):
"""Asks if the user wants to proceed with this action""" """Asks if the user wants to proceed with this action"""
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
==Extension Amount== ==Extension Amount==
Period: {extension_amount} year(s) Period: {extension_amount} year(s)

View file

@ -64,7 +64,7 @@ class Command(BaseCommand):
# Will sys.exit() when prompt is "n" # Will sys.exit() when prompt is "n"
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
==Master data file== ==Master data file==
domain_additional_filename: {org_args.domain_additional_filename} domain_additional_filename: {org_args.domain_additional_filename}
@ -84,7 +84,7 @@ class Command(BaseCommand):
# Will sys.exit() when prompt is "n" # Will sys.exit() when prompt is "n"
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
==Master data file== ==Master data file==
domain_additional_filename: {org_args.domain_additional_filename} domain_additional_filename: {org_args.domain_additional_filename}

View file

@ -27,7 +27,7 @@ class Command(BaseCommand):
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
==Proposed Changes== ==Proposed Changes==
CSV: {federal_cio_csv_path} CSV: {federal_cio_csv_path}

View file

@ -651,7 +651,7 @@ class Command(BaseCommand):
title = "Do you wish to load additional data for TransitionDomains?" title = "Do you wish to load additional data for TransitionDomains?"
proceed = TerminalHelper.prompt_for_execution( proceed = TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
!!! ENSURE THAT ALL FILENAMES ARE CORRECT BEFORE PROCEEDING !!! ENSURE THAT ALL FILENAMES ARE CORRECT BEFORE PROCEEDING
==Master data file== ==Master data file==
domain_additional_filename: {domain_additional_filename} domain_additional_filename: {domain_additional_filename}

View file

@ -91,7 +91,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N" # Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
==Proposed Changes== ==Proposed Changes==
Number of DomainInformation objects to change: {len(human_readable_domain_names)} Number of DomainInformation objects to change: {len(human_readable_domain_names)}
The following DomainInformation objects will be modified: {human_readable_domain_names} The following DomainInformation objects will be modified: {human_readable_domain_names}
@ -148,7 +148,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N" # Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
==File location== ==File location==
current-full.csv filepath: {file_path} current-full.csv filepath: {file_path}

View file

@ -31,7 +31,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N" # Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
==Proposed Changes== ==Proposed Changes==
Number of Domain objects to change: {len(domains)} Number of Domain objects to change: {len(domains)}
""", """,

View file

@ -54,7 +54,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N" # Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
==Proposed Changes== ==Proposed Changes==
Number of DomainRequest objects to change: {len(domain_requests)} Number of DomainRequest objects to change: {len(domain_requests)}
@ -72,7 +72,7 @@ class Command(BaseCommand):
# Code execution will stop here if the user prompts "N" # Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=f"""
==Proposed Changes== ==Proposed Changes==
Number of DomainInformation objects to change: {len(domain_infos)} Number of DomainInformation objects to change: {len(domain_infos)}

View file

@ -0,0 +1,38 @@
import logging
from django.core.management import BaseCommand
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
from registrar.models import Domain, TransitionDomain
logger = logging.getLogger(__name__)
class Command(BaseCommand, PopulateScriptTemplate):
help = "Loops through each domain object and populates the last_status_update and first_submitted_date"
def handle(self, **kwargs):
"""Loops through each valid Domain object and updates it's first_ready value if it is out of sync"""
filter_conditions = {"state__in": [Domain.State.READY, Domain.State.ON_HOLD, Domain.State.DELETED]}
self.mass_update_records(Domain, filter_conditions, ["first_ready"], verbose=True)
def update_record(self, record: Domain):
"""Defines how we update the first_ready field"""
# update the first_ready value based on the creation date.
record.first_ready = record.created_at.date()
logger.info(
f"{TerminalColors.OKCYAN}Updating {record} => first_ready: " f"{record.first_ready}{TerminalColors.ENDC}"
)
# check if a transition domain object for this domain name exists,
# or if so whether its first_ready value matches its created_at date
def custom_filter(self, records):
to_include_pks = []
for record in records:
if (
TransitionDomain.objects.filter(domain_name=record.name).exists()
and record.first_ready != record.created_at.date()
): # noqa
to_include_pks.append(record.pk)
return records.filter(pk__in=to_include_pks)

View file

@ -2,9 +2,12 @@ import logging
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Model
from django.db.models.manager import BaseManager
from typing import List from typing import List
from registrar.utility.enums import LogCode from registrar.utility.enums import LogCode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -76,27 +79,60 @@ class PopulateScriptTemplate(ABC):
@abstractmethod @abstractmethod
def update_record(self, record): def update_record(self, record):
"""Defines how we update each field. Must be defined before using mass_update_records.""" """Defines how we update each field.
raises:
NotImplementedError: If not defined before calling mass_update_records.
"""
raise NotImplementedError raise NotImplementedError
def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True): def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True, verbose=False):
"""Loops through each valid "object_class" object - specified by filter_conditions - and """Loops through each valid "object_class" object - specified by filter_conditions - and
updates fields defined by fields_to_update using update_record. updates fields defined by fields_to_update using update_record.
You must define update_record before you can use this function. Parameters:
object_class: The Django model class that you want to perform the bulk update on.
This should be the actual class, not a string of the class name.
filter_conditions: dictionary of valid Django Queryset filter conditions
(e.g. {'verification_type__isnull'=True}).
fields_to_update: List of strings specifying which fields to update.
(e.g. ["first_ready_date", "last_submitted_date"])
debug: Whether to log script run summary in debug mode.
Default: True.
verbose: Whether to print a detailed run summary *before* run confirmation.
Default: False.
Raises:
NotImplementedError: If you do not define update_record before using this function.
TypeError: If custom_filter is not Callable.
""" """
records = object_class.objects.filter(**filter_conditions) if filter_conditions else object_class.objects.all() records = object_class.objects.filter(**filter_conditions) if filter_conditions else object_class.objects.all()
# apply custom filter
records = self.custom_filter(records)
readable_class_name = self.get_class_name(object_class) readable_class_name = self.get_class_name(object_class)
# for use in the execution prompt.
proposed_changes = f"""==Proposed Changes==
Number of {readable_class_name} objects to change: {len(records)}
These fields will be updated on each record: {fields_to_update}
"""
if verbose:
proposed_changes = f"""{proposed_changes}
These records will be updated: {list(records.all())}
"""
# Code execution will stop here if the user prompts "N" # Code execution will stop here if the user prompts "N"
TerminalHelper.prompt_for_execution( TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True, system_exit_on_terminate=True,
info_to_inspect=f""" prompt_message=proposed_changes,
==Proposed Changes==
Number of {readable_class_name} objects to change: {len(records)}
These fields will be updated on each record: {fields_to_update}
""",
prompt_title=self.prompt_title, prompt_title=self.prompt_title,
) )
logger.info("Updating...") logger.info("Updating...")
@ -141,10 +177,17 @@ class PopulateScriptTemplate(ABC):
return f"{TerminalColors.FAIL}" f"Failed to update {record}" f"{TerminalColors.ENDC}" return f"{TerminalColors.FAIL}" f"Failed to update {record}" f"{TerminalColors.ENDC}"
def should_skip_record(self, record) -> bool: # noqa def should_skip_record(self, record) -> bool: # noqa
"""Defines the condition in which we should skip updating a record. Override as needed.""" """Defines the condition in which we should skip updating a record. Override as needed.
The difference between this and custom_filter is that records matching these conditions
*will* be included in the run but will be skipped (and logged as such)."""
# By default - don't skip # By default - don't skip
return False return False
def custom_filter(self, records: BaseManager[Model]) -> BaseManager[Model]:
"""Override to define filters that can't be represented by django queryset field lookups.
Applied to individual records *after* filter_conditions. True means"""
return records
class TerminalHelper: class TerminalHelper:
@staticmethod @staticmethod
@ -220,6 +263,9 @@ class TerminalHelper:
an answer is required of the user). an answer is required of the user).
The "answer" return value is True for "yes" or False for "no". The "answer" return value is True for "yes" or False for "no".
Raises:
ValueError: When "default" is not "yes", "no", or None.
""" """
valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False} valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
if default is None: if default is None:
@ -244,6 +290,7 @@ class TerminalHelper:
@staticmethod @staticmethod
def query_yes_no_exit(question: str, default="yes"): def query_yes_no_exit(question: str, default="yes"):
"""Ask a yes/no question via raw_input() and return their answer. """Ask a yes/no question via raw_input() and return their answer.
Allows for answer "e" to exit.
"question" is a string that is presented to the user. "question" is a string that is presented to the user.
"default" is the presumed answer if the user just hits <Enter>. "default" is the presumed answer if the user just hits <Enter>.
@ -251,6 +298,9 @@ class TerminalHelper:
an answer is required of the user). an answer is required of the user).
The "answer" return value is True for "yes" or False for "no". The "answer" return value is True for "yes" or False for "no".
Raises:
ValueError: When "default" is not "yes", "no", or None.
""" """
valid = { valid = {
"yes": True, "yes": True,
@ -317,9 +367,8 @@ class TerminalHelper:
case _: case _:
logger.info(print_statement) logger.info(print_statement)
# TODO - "info_to_inspect" should be refactored to "prompt_message"
@staticmethod @staticmethod
def prompt_for_execution(system_exit_on_terminate: bool, info_to_inspect: str, prompt_title: str) -> bool: def prompt_for_execution(system_exit_on_terminate: bool, prompt_message: str, prompt_title: str) -> bool:
"""Create to reduce code complexity. """Create to reduce code complexity.
Prompts the user to inspect the given string Prompts the user to inspect the given string
and asks if they wish to proceed. and asks if they wish to proceed.
@ -340,7 +389,7 @@ class TerminalHelper:
===================================================== =====================================================
*** IMPORTANT: VERIFY THE FOLLOWING LOOKS CORRECT *** *** IMPORTANT: VERIFY THE FOLLOWING LOOKS CORRECT ***
{info_to_inspect} {prompt_message}
{TerminalColors.FAIL} {TerminalColors.FAIL}
Proceed? (Y = proceed, N = {action_description_for_selecting_no}) Proceed? (Y = proceed, N = {action_description_for_selecting_no})
{TerminalColors.ENDC}""" {TerminalColors.ENDC}"""

View file

@ -0,0 +1,66 @@
# Generated by Django 4.2.10 on 2024-09-04 21:29
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0122_create_groups_v16"),
]
operations = [
migrations.AlterField(
model_name="portfolioinvitation",
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"),
("view_members", "View members"),
("edit_members", "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"),
("view_suborganization", "View suborganization"),
("edit_suborganization", "Edit suborganization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="userportfoliopermission",
name="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"),
("view_members", "View members"),
("edit_members", "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"),
("view_suborganization", "View suborganization"),
("edit_suborganization", "Edit suborganization"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
]

View file

@ -16,8 +16,8 @@ class UserPortfolioPermission(TimeStampedModel):
PORTFOLIO_ROLE_PERMISSIONS = { PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER, UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBER, UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
@ -28,7 +28,7 @@ class UserPortfolioPermission(TimeStampedModel):
], ],
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [ UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER, UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
# Domain: field specific permissions # Domain: field specific permissions

View file

@ -17,8 +17,8 @@ class UserPortfolioPermissionChoices(models.TextChoices):
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
VIEW_MEMBER = "view_member", "View members" VIEW_MEMBERS = "view_members", "View members"
EDIT_MEMBER = "edit_member", "Create and edit members" EDIT_MEMBERS = "edit_members", "Create and edit members"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests" VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"

View file

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div id="content-main" class="analytics"> <div id="content-main" class="custom-admin-template">
<div class="grid-row grid-gap-2"> <div class="grid-row grid-gap-2">
<div class="tablet:grid-col-6 margin-top-2"> <div class="tablet:grid-col-6 margin-top-2">
@ -29,28 +29,28 @@
<div class="padding-top-2 padding-x-2"> <div class="padding-top-2 padding-x-2">
<ul class="usa-button-group wrapped-button-group"> <ul class="usa-button-group wrapped-button-group">
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<a href="{% url 'export_data_type' %}" class="button text-no-wrap" role="button"> <a href="{% url 'export_data_type' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">All domain metadata</span> </svg><span class="margin-left-05">All domain metadata</span>
</a> </a>
</li> </li>
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<a href="{% url 'export_data_full' %}" class="button text-no-wrap" role="button"> <a href="{% url 'export_data_full' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Current full</span> </svg><span class="margin-left-05">Current full</span>
</a> </a>
</li> </li>
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<a href="{% url 'export_data_federal' %}" class="button text-no-wrap" role="button"> <a href="{% url 'export_data_federal' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Current federal</span> </svg><span class="margin-left-05">Current federal</span>
</a> </a>
</li> </li>
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<a href="{% url 'export_data_domain_requests_full' %}" class="button text-no-wrap" role="button"> <a href="{% url 'export_data_domain_requests_full' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">All domain requests metadata</span> </svg><span class="margin-left-05">All domain requests metadata</span>
@ -84,35 +84,35 @@
</div> </div>
<ul class="usa-button-group"> <ul class="usa-button-group">
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<button class="button exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button"> <button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Domain growth</span> </svg><span class="margin-left-05">Domain growth</span>
</button> </button>
</li> </li>
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<button class="button exportLink" data-export-url="{% url 'export_requests_growth' %}" type="button"> <button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_requests_growth' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Request growth</span> </svg><span class="margin-left-05">Request growth</span>
</button> </button>
</li> </li>
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<button class="button exportLink" data-export-url="{% url 'export_managed_domains' %}" type="button"> <button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_managed_domains' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Managed domains</span> </svg><span class="margin-left-05">Managed domains</span>
</button> </button>
</li> </li>
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<button class="button exportLink" data-export-url="{% url 'export_unmanaged_domains' %}" type="button"> <button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_unmanaged_domains' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg><span class="margin-left-05">Unmanaged domains</span> </svg><span class="margin-left-05">Unmanaged domains</span>
</button> </button>
</li> </li>
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<button class="button exportLink usa-button--secondary" data-export-url="{% url 'analytics' %}" type="button"> <button class="usa-button usa-button--dja exportLink usa-button--secondary" data-export-url="{% url 'analytics' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#assessment"></use> <use xlink:href="{%static 'img/sprite.svg'%}#assessment"></use>
</svg><span class="margin-left-05">Update charts</span> </svg><span class="margin-left-05">Update charts</span>

View file

@ -17,7 +17,7 @@ Template for an input field with a clipboard
> >
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use> <use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg> </svg>
<span>Copy</span> Copy
</div> </div>
</button> </button>
</div> </div>
@ -25,7 +25,7 @@ Template for an input field with a clipboard
<div class="admin-icon-group admin-icon-group__clipboard-link"> <div class="admin-icon-group admin-icon-group__clipboard-link">
<input aria-hidden="true" class="display-none" value="{{ field.email }}" /> <input aria-hidden="true" class="display-none" value="{{ field.email }}" />
<button <button
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline" class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05"
type="button" type="button"
> >
<svg <svg
@ -33,7 +33,7 @@ Template for an input field with a clipboard
> >
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use> <use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg> </svg>
<span class="padding-left-05">Copy</span> Copy
</button> </button>
</div> </div>
{% endif %} {% endif %}

View file

@ -0,0 +1,260 @@
{% extends 'admin/base_site.html' %}
{% load i18n static %}
{% block content_title %}<h1>Transfer user</h1>{% endblock %}
{% block extrastyle %}
{{ block.super }}
{% endblock %}
{% block extrahead %}
{{ block.super }}
<!-- Making the user select a combobox: -->
<!-- Load Django Admin's base JavaScript. This is NEEDED because select2 relies on it. -->
<script src="{% static 'admin/js/vendor/jquery/jquery.min.js' %}"></script>
<!-- Include Select2 JavaScript. Since this view technically falls outside of admin, this is needed. -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script type="application/javascript" src="{% static 'js/get-gov-admin-extra.js' %}" defer></script>
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' 'registrar' %}">{% trans 'Registrar' %}</a>
&rsaquo; <a href="{% url 'admin:registrar_user_changelist' %}">{% trans 'Users' %}</a>
&rsaquo; <a href="{% url 'admin:registrar_user_change' current_user.pk %}">{{ current_user.first_name }} {{ current_user.last_name }}</a>
&rsaquo; {% trans 'Transfer User' %}
</div>
{% endblock %}
{% block content %}
<div id="content-main" class="custom-admin-template">
<div class="module padding-4 display-flex flex-row flex-justify submit-row">
<div class="desktop:flex-align-center">
<form class="transfer-user-selector" method="GET" action="{% url 'transfer_user' current_user.pk %}">
<label for="selected_user" class="text-middle">Select user to transfer data from:</label>
<select name="selected_user" id="selected_user" class="admin-combobox margin-top-0" onchange="this.form.submit()">
<option value="">Select a user</option>
{% for user in other_users %}
<option value="{{ user.pk }}" {% if selected_user and user.pk == selected_user.pk %}selected{% endif %}>
{{ user.first_name }} {{ user.last_name }}
</option>
{% endfor %}
</select>
<input type="submit" value="Select and preview" class="button--dja-toolbar">
</form>
</div>
<div class="desktop:flex-align-center">
{% if selected_user %}
<a class="usa-button usa-button--dja" href="#transfer-and-delete" aria-controls="transfer-and-delete" data-open-modal>
Transfer and delete user
</a>
{% endif %}
</div>
</div>
<div class="grid-row grid-gap-2">
<div class="tablet:grid-col-6 margin-top-2">
<div class="module height-full">
<h2>User to transfer data from</h2>
<div class="padding-top-2 padding-x-2">
{% if selected_user %}
<dl class="dl-dja">
<dt>Username:</dt>
<dd>{{ selected_user.username }}</dd>
<dt>Created at:</dt>
<dd>{{ selected_user.created_at }}</dd>
<dt>Last login:</dt>
<dd>{{ selected_user.last_login }}</dd>
<dt>First name:</dt>
<dd>{{ selected_user.first_name }}</dd>
<dt>Middle name:</dt>
<dd>{{ selected_user.middle_name }}</dd>
<dt>Last name:</dt>
<dd>{{ selected_user.last_name }}</dd>
<dt>Title:</dt>
<dd>{{ selected_user.title }}</dd>
<dt>Email:</dt>
<dd>{{ selected_user.email }}</dd>
<dt>Phone:</dt>
<dd>{{ selected_user.phone }}</dd>
<h3 class="font-heading-md">Data that will get transferred:</h3>
<dt>Domains:</dt>
<dd>
{% if selected_user_domains %}
<ul>
{% for domain in selected_user_domains %}
<li>{{ domain }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</dd>
<dt>Domain requests:</dt>
<dd>
{% if selected_user_domain_requests %}
<ul>
{% for request in selected_user_domain_requests %}
<li>{{ request }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</dd>
<dt>Portfolios:</dt>
<dd>
{% if selected_user_portfolios %}
<ul>
{% for portfolio in selected_user_portfolios %}
<li>{{ portfolio.portfolio }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</dd>
</dl>
{% else %}
<p>No user selected yet.</p>
{% endif %}
</div>
</div>
</div>
<div class="tablet:grid-col-6 margin-top-2">
<div class="module height-full">
<h2>User to receive data</h2>
<div class="padding-top-2 padding-x-2">
<dl class="dl-dja">
<dt>Username:</dt>
<dd>{{ current_user.username }}</dd>
<dt>Created at:</dt>
<dd>{{ current_user.created_at }}</dd>
<dt>Last login:</dt>
<dd>{{ current_user.last_login }}</dd>
<dt>First name:</dt>
<dd>{{ current_user.first_name }}</dd>
<dt>Middle name:</dt>
<dd>{{ current_user.middle_name }}</dd>
<dt>Last name:</dt>
<dd>{{ current_user.last_name }}</dd>
<dt>Title:</dt>
<dd>{{ current_user.title }}</dd>
<dt>Email:</dt>
<dd>{{ current_user.email }}</dd>
<dt>Phone:</dt>
<dd>{{ current_user.phone }}</dd>
<h3 class="font-heading-md" aria-label="Data that will added to:">&nbsp;</h3>
<dt>Domains:</dt>
<dd>
{% if current_user_domains %}
<ul>
{% for domain in current_user_domains %}
<li>{{ domain }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</dd>
<dt>Domain requests:</dt>
<dd>
{% if current_user_domain_requests %}
<ul>
{% for request in current_user_domain_requests %}
<li>{{ request }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</dd>
<dt>Portfolios:</dt>
<dd>
{% if current_user_portfolios %}
<ul>
{% for portfolio in current_user_portfolios %}
<li>{{ portfolio.portfolio }}</li>
{% endfor %}
</ul>
{% else %}
None
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<div
class="usa-modal"
id="transfer-and-delete"
aria-labelledby="This action will delete {{ selected_user }}"
aria-describedby="This action will delete {{ selected_user }}"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="transfer-and-delete-heading">
Are you sure you want to transfer data and delete this user?
</h2>
<div class="usa-prose">
{% if selected_user != logged_in_user %}
<p>Username: <b>{{ selected_user.username }}</b><br>
Name: <b>{{ selected_user.first_name }} {{ selected_user.last_name }}</b><br>
Email: <b>{{ selected_user.email }}</b></p>
<p>This action cannot be undone.</p>
{% else %}
<p>Don't do it!</p>
{% endif %}
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
{% if selected_user != logged_in_user %}
<li class="usa-button-group__item">
<form method="POST" action="{% url 'transfer_user' current_user.pk %}">
{% csrf_token %}
<input type="hidden" name="selected_user" value="{{ selected_user.pk }}">
<input type="submit" class="usa-button usa-button--dja" value="Yes, transfer and delete user">
</form>
</li>
{% endif %}
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
name="_cancel_domain_request_ineligible"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{% endblock %}

View file

@ -137,6 +137,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
</div> </div>
{% elif field.field.name == "display_admins" %}
<div class="readonly">{{ field.contents|safe }}</div>
{% elif field.field.name == "display_members" %}
<div class="readonly">
{% if display_members_summary %}
{{ display_members_summary }}
{% else %}
<p>No additional members found.</p>
{% endif %}
</div>
{% else %} {% else %}
<div class="readonly">{{ field.contents }}</div> <div class="readonly">{{ field.contents }}</div>
{% endif %} {% endif %}
@ -330,6 +340,13 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</details> </details>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% elif field.field.name == "display_members" and field.contents %}
<details class="margin-top-1 dja-detail-table" aria-role="button" open>
<summary class="padding-1 padding-left-0 dja-details-summary">Details</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{{ field.contents|safe }}
</div>
</details>
{% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %} {% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
<div class="flex-container margin-top-2"> <div class="flex-container margin-top-2">
<span> <span>

View file

@ -17,8 +17,7 @@
This is a placeholder for now. This is a placeholder for now.
Disclaimer: Disclaimer:
When extending the fieldset view - *make a new one* that extends from detail_table_fieldset. When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
For instance, "portfolio_fieldset.html".
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences. detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
{% endcomment %} {% endcomment %}
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %} {% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}

View file

@ -1,6 +1,21 @@
{% extends 'django/admin/email_clipboard_change_form.html' %} {% extends 'django/admin/email_clipboard_change_form.html' %}
{% load i18n static %} {% load i18n static %}
{% block field_sets %}
<div class="display-flex flex-row flex-justify submit-row">
<div class="desktop:flex-align-self-end">
<a href="{% url 'transfer_user' original.pk %}" class="button">
Transfer data from old account
</a>
</div>
</div>
{% for fieldset in adminform %}
{% include "django/admin/includes/domain_fieldset.html" with state_help_message=state_help_message %}
{% endfor %}
{% endblock %}
{% block after_related_objects %} {% block after_related_objects %}
{% if portfolios %} {% if portfolios %}
<div class="module aligned padding-3"> <div class="module aligned padding-3">

View file

@ -7,7 +7,9 @@ for now we just carry the attribute to both the parent element and the select.
<div class="usa-combo-box" <div class="usa-combo-box"
{% for name, value in widget.attrs.items %} {% for name, value in widget.attrs.items %}
{% if name != 'id' %}
{{ name }}="{{ value }}" {{ name }}="{{ value }}"
{% endif %}
{% endfor %} {% endfor %}
> >
{% include "django/forms/widgets/select.html" %} {% include "django/forms/widgets/select.html" %}

View file

@ -63,10 +63,10 @@
<div class="grid-row margin-top-1"> <div class="grid-row margin-top-1">
<div class="grid-col"> <div class="grid-col">
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record"> <button type="button" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use> <use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span> </svg>Delete
</button> </button>
</div> </div>
</div> </div>
@ -74,10 +74,10 @@
</fieldset> </fieldset>
{% endfor %} {% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-form"> <button type="button" class="usa-button usa-button--unstyled usa-button--with-icon margin-bottom-2" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use> <use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add new record</span> </svg>Add new record
</button> </button>
<button <button

View file

@ -52,20 +52,20 @@
{% endwith %} {% endwith %}
</div> </div>
<div class="tablet:grid-col-2"> <div class="tablet:grid-col-2">
<button type="button" class="usa-button usa-button--unstyled display-block delete-record margin-bottom-075"> <button type="button" class="usa-button usa-button--unstyled usa-button--with-icon delete-record margin-bottom-075">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use> <use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span> </svg>Delete
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-form"> <button type="button" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use> <use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another name server</span> </svg>Add another name server
</button> </button>
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more' {% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'

View file

@ -42,7 +42,7 @@
{% input_with_errors form.state_territory %} {% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" %} {% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors form.zipcode %} {% input_with_errors form.zipcode %}
{% endwith %} {% endwith %}

View file

@ -33,7 +33,7 @@
{% input_with_errors forms.0.state_territory %} {% input_with_errors forms.0.state_territory %}
{% with add_class="usa-input--small" %} {% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors forms.0.zipcode %} {% input_with_errors forms.0.zipcode %}
{% endwith %} {% endwith %}

View file

@ -1,7 +1,7 @@
{% extends "domain_base.html" %} {% extends "domain_base.html" %}
{% load static field_helpers%} {% load static field_helpers%}
{% block title %}Suborganization{% endblock %} {% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %}
{% block domain_content %} {% block domain_content %}
{# this is right after the messages block in the parent template #} {# this is right after the messages block in the parent template #}

View file

@ -21,7 +21,7 @@
</ul> </ul>
{% if domain.permissions %} {% if domain.permissions %}
<section class="section--outlined"> <section class="section-outlined">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<h2 class> Domain managers </h2> <h2 class> Domain managers </h2>
<caption class="sr-only">Domain managers</caption> <caption class="sr-only">Domain managers</caption>
@ -112,7 +112,7 @@
</section> </section>
{% if domain.invitations.exists %} {% if domain.invitations.exists %}
<section class="section--outlined"> <section class="section-outlined">
<h2>Invitations</h2> <h2>Invitations</h2>
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<caption class="sr-only">Domain invitations</caption> <caption class="sr-only">Domain invitations</caption>

View file

@ -3,7 +3,7 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domain_requests_json' as url %} {% url 'get_domain_requests_json' as url %}
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span> <span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
<section class="section--outlined domain-requests" id="domain-requests"> <section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="grid-row"> <div class="grid-row">
{% if not has_domain_requests_portfolio_permission %} {% if not has_domain_requests_portfolio_permission %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">

View file

@ -5,8 +5,8 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domains_json' as url %} {% url 'get_domains_json' as url %}
<span id="get_domains_json_url" class="display-none">{{url}}</span> <span id="get_domains_json_url" class="display-none">{{url}}</span>
<section class="section--outlined domains{% if not portfolio %} margin-top-0{% endif %}" id="domains"> <section class="section-outlined domains margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domains">
<div class="section--outlined__header margin-bottom-3 {% if not portfolio %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}"> <div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not portfolio %} {% if not portfolio %}
<h2 id="domains-header" class="display-inline-block">Domains</h2> <h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span> <span class="display-none" id="no-portfolio-js-flag"></span>
@ -14,7 +14,7 @@
<!-- Embedding the portfolio value in a data attribute --> <!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span> <span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %} {% endif %}
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}"> <div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="margin-top-2"> <section aria-label="Domains search component" class="margin-top-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 %}
@ -43,10 +43,10 @@
</section> </section>
</div> </div>
{% if user_domain_count and user_domain_count > 0 %} {% if user_domain_count and user_domain_count > 0 %}
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}"> <div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="mobile-lg:margin-top-205"> <section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button"> <a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon" role="button">
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV </svg>Export as CSV
</a> </a>
@ -158,7 +158,7 @@
<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>
{% if portfolio and has_view_suborganization %} {% if portfolio and has_view_suborganization %}
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th> <th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
{% endif %} {% endif %}
<th <th
scope="col" scope="col"

View file

@ -12,46 +12,6 @@
<button type="button" class="usa-nav__close"> <button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" /> <img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button> </button>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if has_domains_portfolio_permission %}
{% url 'domains' as url %}
{%else %}
{% url 'no-portfolio-domains' as url %}
{% endif %}
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
Domains
</a>
</li>
<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 'domain-requests' as url %}
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} 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 'organization' 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"> <div class="usa-nav__secondary">
<ul class="usa-nav__secondary-links"> <ul class="usa-nav__secondary-links">
<li class="usa-nav__secondary-item"> <li class="usa-nav__secondary-item">
@ -75,6 +35,48 @@
</li> </li>
</ul> </ul>
</div> </div>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if has_domains_portfolio_permission %}
{% url 'domains' as url %}
{%else %}
{% url 'no-portfolio-domains' as url %}
{% endif %}
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
Domains
</a>
</li>
<!-- <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 'domain-requests' as url %}
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
Domain requests
</a>
</li>
{% endif %}
{% if has_view_members_portfolio_permission %}
<li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Members
</a>
</li>
{% endif %}
<li class="usa-nav__primary-item">
{% url 'organization' 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 {% if request.path == '/organization/' %} usa-current{% endif %}">
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
{{ portfolio.organization_name }}
</span>
</a>
</li>
</ul>
</div> </div>
</nav> </nav>
{% endblock %} {% endblock %}

View file

@ -4,7 +4,7 @@
{% include "includes/form_errors.html" with form=form %} {% include "includes/form_errors.html" with form=form %}
{% endif %} {% endif %}
<h1>Senior Official</h1> <h1>Senior official</h1>
<p> <p>
Your senior official is a person within your organization who can authorize domain requests. Your senior official is a person within your organization who can authorize domain requests.

View file

@ -1,6 +1,6 @@
{% load static field_helpers url_helpers custom_filters %} {% load static field_helpers url_helpers custom_filters %}
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 input-with-edit-button {% if not field.value and field.field.required %}input-with-edit-button__error{% endif %}"> <div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 toggleable_input {% if not field.value and field.field.required %}toggleable_input__error{% endif %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
{% if field.value or not field.field.required %} {% if field.value or not field.field.required %}
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use> <use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
@ -8,7 +8,7 @@
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use> <use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
{%endif %} {%endif %}
</svg> </svg>
<div class="display-inline padding-left-05 margin-left-3 input-with-edit-button__readonly-field {% if not field.field.required %}text-base{% endif %}"> <div class="display-inline padding-left-05 margin-left-3 toggleable_input__readonly-field {% if not field.field.required %}text-base{% endif %}">
{% if field.name != "phone" %} {% if field.name != "phone" %}
{{ field.value }} {{ field.value }}
{% else %} {% else %}

View file

@ -5,9 +5,10 @@
{% block title %} Domains | {% endblock %} {% block title %} Domains | {% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<div id="main-content">
<h1 id="domains-header">Domains</h1> <h1 id="domains-header">Domains</h1>
<section class="section--outlined"> <section class="section-outlined">
<div class="section--outlined__header margin-bottom-3"> <div class="section-outlined__header margin-bottom-3">
<h2 id="domains-header" class="display-inline-block">You arent managing any domains.</h2> <h2 id="domains-header" class="display-inline-block">You arent managing any domains.</h2>
{% if portfolio_administrators %} {% if portfolio_administrators %}
<p>If you believe you should have access to a domain, reach out to your organizations administrators.</p> <p>If you believe you should have access to a domain, reach out to your organizations administrators.</p>
@ -27,4 +28,5 @@
{% endif %} {% endif %}
</div> </div>
</section> </section>
</div>
{% endblock %} {% endblock %}

View file

@ -1,10 +1,10 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block wrapper %} {% block wrapper %}
<div id="wrapper" class="dashboard--portfolio"> <div id="wrapper" class="{% block wrapper_class %}dashboard--portfolio{% endblock %}">
{% block content %} {% block content %}
<main id="main-content" class="grid-container"> <main class="grid-container">
{% if user.is_authenticated %} {% if user.is_authenticated %}
{# the entire logged in page goes here #} {# the entire logged in page goes here #}
@ -26,10 +26,8 @@
{% endif %} {% endif %}
</main> </main>
{% endblock %} {% endblock content%}
<div role="complementary">{% block complementary %}{% endblock %}</div> <div role="complementary">{% block complementary %}{% endblock %}</div>
{% block content_bottom %}{% endblock %} {% block content_bottom %}{% endblock %}
</div> </div>
{% endblock wrapper %} {% endblock wrapper %}

View file

@ -4,7 +4,13 @@
{% block title %} Domains | {% endblock %} {% block title %} Domains | {% endblock %}
{% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<div id="main-content">
<h1 id="domains-header">Domains</h1> <h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %} {% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %}
</div>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,7 @@
{% extends 'portfolio_base.html' %} {% extends 'portfolio_base.html' %}
{% load static field_helpers%} {% load static field_helpers%}
{% block title %}Organization mailing address | {{ portfolio.name }} | {% endblock %} {% block title %}Organization mailing address | {{ portfolio.name }}{% endblock %}
{% load static %} {% load static %}
@ -17,7 +17,7 @@
{% include 'portfolio_organization_sidebar.html' %} {% include 'portfolio_organization_sidebar.html' %}
</div> </div>
<div class="tablet:grid-col-9"> <div class="tablet:grid-col-9" id="main-content">
<h1>Organization</h1> <h1>Organization</h1>
@ -41,7 +41,7 @@
{% input_with_errors form.address_line2 %} {% input_with_errors form.address_line2 %}
{% input_with_errors form.city %} {% input_with_errors form.city %}
{% input_with_errors form.state_territory %} {% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" %} {% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors form.zipcode %} {% input_with_errors form.zipcode %}
{% endwith %} {% endwith %}
<button type="submit" class="usa-button"> <button type="submit" class="usa-button">

View file

@ -4,7 +4,12 @@
{% block title %} Domain requests | {% endblock %} {% block title %} Domain requests | {% endblock %}
{% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %} {% block portfolio_content %}
<div id="main-content">
<h1 id="domain-requests-header">Domain requests</h1> <h1 id="domain-requests-header">Domain requests</h1>
{% comment %} {% comment %}
@ -20,4 +25,5 @@
</p> </p>
{% include "includes/domain_requests_table.html" with portfolio=portfolio %} {% include "includes/domain_requests_table.html" with portfolio=portfolio %}
</div>
{% endblock %} {% endblock %}

View file

@ -1,7 +1,7 @@
{% extends 'portfolio_base.html' %} {% extends 'portfolio_base.html' %}
{% load static field_helpers%} {% load static field_helpers%}
{% block title %}Senior Official | {{ portfolio.name }} | {% endblock %} {% block title %}Senior official | {{ portfolio.name }}{% endblock %}
{% load static %} {% load static %}
@ -17,7 +17,7 @@
{% include 'portfolio_organization_sidebar.html' %} {% include 'portfolio_organization_sidebar.html' %}
</div> </div>
<div class="tablet:grid-col-9"> <div class="tablet:grid-col-9" id="main-content">
{% include "includes/senior_official.html" with can_edit=False %} {% include "includes/senior_official.html" with can_edit=False %}
</div> </div>
</div> </div>

View file

@ -48,6 +48,8 @@ from registrar.models import (
from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.verified_by_staff import VerifiedByStaff from registrar.models.verified_by_staff import VerifiedByStaff
from .common import ( from .common import (
MockDbForSharedTests, MockDbForSharedTests,
@ -63,7 +65,8 @@ from .common import (
) )
from django.contrib.sessions.backends.db import SessionStore from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from unittest.mock import patch, Mock from unittest.mock import ANY, patch, Mock
from django_webtest import WebTest # type: ignore
import logging import logging
@ -2084,6 +2087,7 @@ class TestPortfolioAdmin(TestCase):
DomainRequest.objects.all().delete() DomainRequest.objects.all().delete()
Domain.objects.all().delete() Domain.objects.all().delete()
Portfolio.objects.all().delete() Portfolio.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator @less_console_noise_decorator
def test_created_on_display(self): def test_created_on_display(self):
@ -2135,3 +2139,310 @@ class TestPortfolioAdmin(TestCase):
domain_requests = self.admin.domain_requests(self.portfolio) domain_requests = self.admin.domain_requests(self.portfolio)
self.assertIn("2 domain requests", domain_requests) self.assertIn("2 domain requests", domain_requests)
@less_console_noise_decorator
def test_portfolio_members_display(self):
"""Tests the custom portfolio members field, admin and member sections"""
admin_user_1 = User.objects.create(
username="testuser1",
first_name="Gerald",
last_name="Meoward",
title="Captain",
email="meaoward@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_1, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
admin_user_2 = User.objects.create(
username="testuser2",
first_name="Arnold",
last_name="Poopy",
title="Major",
email="poopy@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_2, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
admin_user_3 = User.objects.create(
username="testuser3",
first_name="Mad",
last_name="Max",
title="Road warrior",
email="madmax@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_3, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
admin_user_4 = User.objects.create(
username="testuser4",
first_name="Agent",
last_name="Smith",
title="Program",
email="thematrix@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_4,
portfolio=self.portfolio,
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
display_admins = self.admin.display_admins(self.portfolio)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_1.pk}/change/">Gerald Meoward meaoward@gov.gov</a>',
display_admins,
)
self.assertIn("Captain", display_admins)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_2.pk}/change/">Arnold Poopy poopy@gov.gov</a>', display_admins
)
self.assertIn("Major", display_admins)
display_members_summary = self.admin.display_members_summary(self.portfolio)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_3.pk}/change/">Mad Max madmax@gov.gov</a>',
display_members_summary,
)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_4.pk}/change/">Agent Smith thematrix@gov.gov</a>',
display_members_summary,
)
display_members = self.admin.display_members(self.portfolio)
self.assertIn("Mad Max", display_members)
self.assertIn("<span class='usa-tag'>Member</span>", display_members)
self.assertIn("Road warrior", display_members)
self.assertIn("Agent Smith", display_members)
self.assertIn("<span class='usa-tag'>Domain requestor</span>", display_members)
self.assertIn("Program", display_members)
class TestTransferUser(WebTest):
"""User transfer custom admin page"""
# csrf checks do not work well with WebTest.
# We disable them here.
csrf_checks = False
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.site = AdminSite()
cls.superuser = create_superuser()
cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site)
cls.factory = RequestFactory()
def setUp(self):
self.app.set_user(self.superuser)
self.user1, _ = User.objects.get_or_create(
username="madmax", first_name="Max", last_name="Rokatanski", title="Road warrior"
)
self.user2, _ = User.objects.get_or_create(
username="furiosa", first_name="Furiosa", last_name="Jabassa", title="Imperator"
)
def tearDown(self):
Suborganization.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
Domain.objects.all().delete()
Portfolio.objects.all().delete()
UserDomainRole.objects.all().delete()
@less_console_noise_decorator
def test_transfer_user_shows_current_and_selected_user_information(self):
"""Assert we pull the current user info and display it on the transfer page"""
completed_domain_request(user=self.user1, name="wasteland.gov")
domain_request = completed_domain_request(
user=self.user1, name="citadel.gov", status=DomainRequest.DomainRequestStatus.SUBMITTED
)
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED
domain_request.save()
portfolio1 = Portfolio.objects.create(organization_name="Hotel California", creator=self.user2)
UserPortfolioPermission.objects.create(
user=self.user1, portfolio=portfolio1, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
portfolio2 = Portfolio.objects.create(organization_name="Tokyo Hotel", creator=self.user2)
UserPortfolioPermission.objects.create(
user=self.user2, portfolio=portfolio2, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
self.assertContains(user_transfer_page, "madmax")
self.assertContains(user_transfer_page, "Max")
self.assertContains(user_transfer_page, "Rokatanski")
self.assertContains(user_transfer_page, "Road warrior")
self.assertContains(user_transfer_page, "wasteland.gov")
self.assertContains(user_transfer_page, "citadel.gov")
self.assertContains(user_transfer_page, "Hotel California")
select_form = user_transfer_page.forms[0]
select_form["selected_user"] = str(self.user2.id)
preview_result = select_form.submit()
self.assertContains(preview_result, "furiosa")
self.assertContains(preview_result, "Furiosa")
self.assertContains(preview_result, "Jabassa")
self.assertContains(preview_result, "Imperator")
self.assertContains(preview_result, "Tokyo Hotel")
@less_console_noise_decorator
def test_transfer_user_transfers_user_portfolio_roles(self):
"""Assert that a portfolio user role gets transferred"""
portfolio = Portfolio.objects.create(organization_name="Hotel California", creator=self.user2)
user_portfolio_permission = UserPortfolioPermission.objects.create(
user=self.user2, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
submit_form = user_transfer_page.forms[1]
submit_form["selected_user"] = self.user2.pk
submit_form.submit()
user_portfolio_permission.refresh_from_db()
self.assertEquals(user_portfolio_permission.user, self.user1)
@less_console_noise_decorator
def test_transfer_user_transfers_domain_request_creator_and_investigator(self):
"""Assert that domain request fields get transferred"""
domain_request = completed_domain_request(user=self.user2, name="wasteland.gov", investigator=self.user2)
self.assertEquals(domain_request.creator, self.user2)
self.assertEquals(domain_request.investigator, self.user2)
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
submit_form = user_transfer_page.forms[1]
submit_form["selected_user"] = self.user2.pk
submit_form.submit()
domain_request.refresh_from_db()
self.assertEquals(domain_request.creator, self.user1)
self.assertEquals(domain_request.investigator, self.user1)
@less_console_noise_decorator
def test_transfer_user_transfers_domain_information_creator(self):
"""Assert that domain fields get transferred"""
domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user2)
self.assertEquals(domain_information.creator, self.user2)
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
submit_form = user_transfer_page.forms[1]
submit_form["selected_user"] = self.user2.pk
submit_form.submit()
domain_information.refresh_from_db()
self.assertEquals(domain_information.creator, self.user1)
@less_console_noise_decorator
def test_transfer_user_transfers_domain_role(self):
"""Assert that user domain role get transferred"""
domain_1, _ = Domain.objects.get_or_create(name="chrome.gov", state=Domain.State.READY)
domain_2, _ = Domain.objects.get_or_create(name="v8.gov", state=Domain.State.READY)
user_domain_role1, _ = UserDomainRole.objects.get_or_create(
user=self.user2, domain=domain_1, role=UserDomainRole.Roles.MANAGER
)
user_domain_role2, _ = UserDomainRole.objects.get_or_create(
user=self.user2, domain=domain_2, role=UserDomainRole.Roles.MANAGER
)
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
submit_form = user_transfer_page.forms[1]
submit_form["selected_user"] = self.user2.pk
submit_form.submit()
user_domain_role1.refresh_from_db()
user_domain_role2.refresh_from_db()
self.assertEquals(user_domain_role1.user, self.user1)
self.assertEquals(user_domain_role2.user, self.user1)
@less_console_noise_decorator
def test_transfer_user_transfers_verified_by_staff_requestor(self):
"""Assert that verified by staff creator gets transferred"""
vip, _ = VerifiedByStaff.objects.get_or_create(requestor=self.user2, email="immortan.joe@citadel.com")
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
submit_form = user_transfer_page.forms[1]
submit_form["selected_user"] = self.user2.pk
submit_form.submit()
vip.refresh_from_db()
self.assertEquals(vip.requestor, self.user1)
@less_console_noise_decorator
def test_transfer_user_deletes_old_user(self):
"""Assert that the slected user gets deleted"""
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
submit_form = user_transfer_page.forms[1]
submit_form["selected_user"] = self.user2.pk
submit_form.submit()
# Refresh user2 from the database and check if it still exists
with self.assertRaises(User.DoesNotExist):
self.user2.refresh_from_db()
@less_console_noise_decorator
def test_transfer_user_throws_transfer_and_delete_success_messages(self):
"""Test that success messages for data transfer and user deletion are displayed."""
# Ensure the setup for VerifiedByStaff
VerifiedByStaff.objects.get_or_create(requestor=self.user2, email="immortan.joe@citadel.com")
# Access the transfer user page
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
with patch("django.contrib.messages.success") as mock_success_message:
# Fill the form with the selected user and submit
submit_form = user_transfer_page.forms[1]
submit_form["selected_user"] = self.user2.pk
after_submit = submit_form.submit().follow()
self.assertContains(after_submit, "<h1>Change user</h1>")
mock_success_message.assert_any_call(
ANY,
(
"Data transferred successfully for the following objects: ['Changed requestor "
+ 'from "Furiosa Jabassa " to "Max Rokatanski " on immortan.joe@citadel.com\']'
),
)
mock_success_message.assert_any_call(ANY, f"Deleted {self.user2} {self.user2.username}")
@less_console_noise_decorator
def test_transfer_user_throws_error_message(self):
"""Test that an error message is thrown if the transfer fails."""
with patch(
"registrar.views.TransferUserView.transfer_user_fields_and_log", side_effect=Exception("Simulated Error")
):
with patch("django.contrib.messages.error") as mock_error:
# Access the transfer user page
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
# Fill the form with the selected user and submit
submit_form = user_transfer_page.forms[1]
submit_form["selected_user"] = self.user2.pk
submit_form.submit().follow()
# Assert that the error message was called with the correct argument
mock_error.assert_called_once_with(ANY, "An error occurred during the transfer: Simulated Error")
@less_console_noise_decorator
def test_transfer_user_modal(self):
"""Assert modal on page"""
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
self.assertContains(user_transfer_page, "This action cannot be undone.")

View file

@ -1311,6 +1311,7 @@ class TestUser(TestCase):
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.user, _ = User.objects.get_or_create(email=self.email) self.user, _ = User.objects.get_or_create(email=self.email)
self.factory = RequestFactory() self.factory = RequestFactory()
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.user)
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
@ -1325,6 +1326,65 @@ class TestUser(TestCase):
User.objects.all().delete() User.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
@patch.object(User, "has_edit_suborganization", return_value=True)
def test_portfolio_role_summary_admin(self, mock_edit_suborganization):
# Test if the user is recognized as an Admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
@patch.multiple(
User,
has_view_all_domains_permission=lambda self, portfolio: True,
has_domain_requests_portfolio_permission=lambda self, portfolio: True,
has_edit_requests=lambda self, portfolio: True,
)
def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self):
# Test if the user has both 'View-only admin' and 'Domain requestor' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin", "Domain requestor"])
@patch.multiple(
User,
has_view_all_domains_permission=lambda self, portfolio: True,
has_domain_requests_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_view_only_admin(self):
# Test if the user is recognized as a View-only admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin"])
@patch.multiple(
User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_edit_requests=lambda self, portfolio: True,
has_domains_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_requestor_domain_manager(self):
# Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"])
@patch.multiple(
User, has_base_portfolio_permission=lambda self, portfolio: True, has_edit_requests=lambda self, portfolio: True
)
def test_portfolio_role_summary_member_domain_requestor(self):
# Test if the user has 'Member' and 'Domain requestor' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor"])
@patch.multiple(
User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_domains_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_manager(self):
# Test if the user has 'Member' and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
@patch.multiple(User, has_base_portfolio_permission=lambda self, portfolio: True)
def test_portfolio_role_summary_member(self):
# Test if the user is recognized as a Member
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
def test_portfolio_role_summary_empty(self):
# Test if the user has no roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
@less_console_noise_decorator @less_console_noise_decorator
def test_check_transition_domains_without_domains_on_login(self): def test_check_transition_domains_without_domains_on_login(self):
"""A user's on_each_login callback does not check transition domains. """A user's on_each_login callback does not check transition domains.
@ -1474,6 +1534,7 @@ class TestUser(TestCase):
self.assertFalse(self.user.has_contact_info()) self.assertFalse(self.user.has_contact_info())
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_requests", active=True)
def test_has_portfolio_permission(self): def test_has_portfolio_permission(self):
""" """
0. Returns False when user does not have a permission 0. Returns False when user does not have a permission
@ -1495,7 +1556,10 @@ class TestUser(TestCase):
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=portfolio, portfolio=portfolio,
user=self.user, user=self.user,
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS], additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
],
) )
user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio) user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio)

View file

@ -25,6 +25,7 @@ SAMPLE_KWARGS = {
"domain": "whitehouse.gov", "domain": "whitehouse.gov",
"user_pk": "1", "user_pk": "1",
"portfolio_id": "1", "portfolio_id": "1",
"user_id": "1",
} }
# Our test suite will ignore some namespaces. # Our test suite will ignore some namespaces.

View file

@ -1153,7 +1153,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
def test_domain_senior_official(self): def test_domain_senior_official(self):
"""Can load domain's senior official page.""" """Can load domain's senior official page."""
page = self.client.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id})) page = self.client.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
self.assertContains(page, "Senior official", count=3) self.assertContains(page, "Senior official", count=4)
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_senior_official_content(self): def test_domain_senior_official_content(self):

View file

@ -230,6 +230,7 @@ class TestPortfolio(WebTest):
self.assertContains(response, 'for="id_city"') self.assertContains(response, 'for="id_city"')
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_requests", active=True)
def test_accessible_pages_when_user_does_not_have_permission(self): def test_accessible_pages_when_user_does_not_have_permission(self):
"""Tests which pages are accessible when user does not have portfolio permissions""" """Tests which pages are accessible when user does not have portfolio permissions"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
@ -280,6 +281,7 @@ class TestPortfolio(WebTest):
self.assertEquals(domain_request_page.status_code, 403) self.assertEquals(domain_request_page.status_code, 403)
@less_console_noise_decorator @less_console_noise_decorator
@override_flag("organization_requests", active=True)
def test_accessible_pages_when_user_does_not_have_role(self): def test_accessible_pages_when_user_does_not_have_role(self):
"""Test that admin / memmber roles are associated with the right access""" """Test that admin / memmber roles are associated with the right access"""
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
@ -532,3 +534,99 @@ class TestPortfolio(WebTest):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Domain name") self.assertContains(response, "Domain name")
permission.delete() permission.delete()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=False)
def test_organization_requests_waffle_flag_off_hides_nav_link_and_restricts_permission(self):
"""Setting the organization_requests waffle off hides the nav link and restricts access to the requests page"""
self.app.set_user(self.user.username)
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
home = self.app.get(reverse("home")).follow()
self.assertContains(home, "Hotel California")
self.assertNotContains(home, "Domain requests")
domain_requests = self.app.get(reverse("domain-requests"), expect_errors=True)
self.assertEqual(domain_requests.status_code, 403)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_organization_requests_waffle_flag_on_shows_nav_link_and_allows_permission(self):
"""Setting the organization_requests waffle on shows the nav link and allows access to the requests page"""
self.app.set_user(self.user.username)
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
home = self.app.get(reverse("home")).follow()
self.assertContains(home, "Hotel California")
self.assertContains(home, "Domain requests")
domain_requests = self.app.get(reverse("domain-requests"))
self.assertEqual(domain_requests.status_code, 200)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=False)
def test_organization_members_waffle_flag_off_hides_nav_link(self):
"""Setting the organization_members waffle off hides the nav link"""
self.app.set_user(self.user.username)
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
home = self.app.get(reverse("home")).follow()
self.assertContains(home, "Hotel California")
self.assertNotContains(home, "Members")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_organization_members_waffle_flag_on_shows_nav_link(self):
"""Setting the organization_members waffle on shows the nav link"""
self.app.set_user(self.user.username)
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
home = self.app.get(reverse("home")).follow()
self.assertContains(home, "Hotel California")
self.assertContains(home, "Members")

View file

@ -19,3 +19,4 @@ from .user_profile import UserProfileView, FinishProfileSetupView
from .health import * from .health import *
from .index import * from .index import *
from .portfolios import * from .portfolios import *
from .transfer_user import TransferUserView

View file

@ -133,5 +133,5 @@ def serialize_domain(domain, user):
"action_url": reverse("domain", kwargs={"pk": domain.id}), "action_url": reverse("domain", kwargs={"pk": domain.id}),
"action_label": ("View" if view_only else "Manage"), "action_label": ("View" if view_only else "Manage"),
"svg_icon": ("visibility" if view_only else "settings"), "svg_icon": ("visibility" if view_only else "settings"),
"suborganization": suborganization_name, "domain_info__sub_organization": suborganization_name,
} }

View file

@ -0,0 +1,172 @@
import logging
from django.shortcuts import render, get_object_or_404, redirect
from django.views import View
from registrar.models.domain import Domain
from registrar.models.domain_information import DomainInformation
from registrar.models.domain_request import DomainRequest
from registrar.models.portfolio import Portfolio
from registrar.models.user import User
from django.contrib.admin import site
from django.contrib import messages
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.verified_by_staff import VerifiedByStaff
from typing import Any, List
logger = logging.getLogger(__name__)
class TransferUserView(View):
"""Transfer user methods that set up the transfer_user template and handle the forms on it."""
JOINS = [
(DomainRequest, "creator"),
(DomainInformation, "creator"),
(Portfolio, "creator"),
(DomainRequest, "investigator"),
(UserDomainRole, "user"),
(VerifiedByStaff, "requestor"),
(UserPortfolioPermission, "user"),
]
# Future-proofing in case joined fields get added on the user model side
# This was tested in the first portfolio model iteration and works
USER_FIELDS: List[Any] = []
def get(self, request, user_id):
"""current_user referes to the 'source' user where the button that redirects to this view was clicked.
other_users exclude current_user and populate a dropdown, selected_user is the selection in the dropdown.
This also querries the relevant domains and domain requests, and the admin context needed for the sidenav."""
current_user = get_object_or_404(User, pk=user_id)
other_users = User.objects.exclude(pk=user_id).order_by(
"first_name", "last_name"
) # Exclude the current user from the dropdown
# Get the default admin site context, needed for the sidenav
admin_context = site.each_context(request)
context = {
"current_user": current_user,
"other_users": other_users,
"logged_in_user": request.user,
**admin_context, # Include the admin context
"current_user_domains": self.get_domains(current_user),
"current_user_domain_requests": self.get_domain_requests(current_user),
"current_user_portfolios": self.get_portfolios(current_user),
}
selected_user_id = request.GET.get("selected_user")
if selected_user_id:
selected_user = get_object_or_404(User, pk=selected_user_id)
context["selected_user"] = selected_user
context["selected_user_domains"] = self.get_domains(selected_user)
context["selected_user_domain_requests"] = self.get_domain_requests(selected_user)
context["selected_user_portfolios"] = self.get_portfolios(selected_user)
return render(request, "admin/transfer_user.html", context)
def post(self, request, user_id):
"""This handles the transfer from selected_user to current_user then deletes selected_user.
NOTE: We have a ticket to refactor this into a more solid lookup for related fields in #2645"""
current_user = get_object_or_404(User, pk=user_id)
selected_user_id = request.POST.get("selected_user")
selected_user = get_object_or_404(User, pk=selected_user_id)
try:
change_logs = []
# Transfer specific fields
self.transfer_user_fields_and_log(selected_user, current_user, change_logs)
# Perform the updates and log the changes
for model_class, field_name in self.JOINS:
self.update_joins_and_log(model_class, field_name, selected_user, current_user, change_logs)
# Success message if any related objects were updated
if change_logs:
success_message = f"Data transferred successfully for the following objects: {change_logs}"
messages.success(request, success_message)
selected_user.delete()
messages.success(request, f"Deleted {selected_user} {selected_user.username}")
except Exception as e:
messages.error(request, f"An error occurred during the transfer: {e}")
return redirect("admin:registrar_user_change", object_id=user_id)
@classmethod
def update_joins_and_log(cls, model_class, field_name, selected_user, current_user, change_logs):
"""
Helper function to update the user join fields for a given model and log the changes.
"""
filter_kwargs = {field_name: selected_user}
updated_objects = model_class.objects.filter(**filter_kwargs)
for obj in updated_objects:
# Check for duplicate UserDomainRole before updating
if model_class == UserDomainRole:
if model_class.objects.filter(user=current_user, domain=obj.domain).exists():
continue # Skip the update to avoid a duplicate
# Update the field on the object and save it
setattr(obj, field_name, current_user)
obj.save()
# Log the change
cls.log_change(obj, field_name, selected_user, current_user, change_logs)
@classmethod
def transfer_user_fields_and_log(cls, selected_user, current_user, change_logs):
"""
Transfers portfolio fields from the selected_user to the current_user.
Logs the changes for each transferred field.
"""
for field in cls.USER_FIELDS:
field_value = getattr(selected_user, field, None)
if field_value:
setattr(current_user, field, field_value)
cls.log_change(current_user, field, field_value, field_value, change_logs)
current_user.save()
@classmethod
def log_change(cls, obj, field_name, field_value, new_value, change_logs):
"""Logs the change for a specific field on an object"""
log_entry = f'Changed {field_name} from "{field_value}" to "{new_value}" on {obj}'
logger.info(log_entry)
# Collect the related object for the success message
change_logs.append(log_entry)
@classmethod
def get_domains(cls, user):
"""A simplified version of domains_json"""
user_domain_roles = UserDomainRole.objects.filter(user=user)
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
domains = Domain.objects.filter(id__in=domain_ids)
return domains
@classmethod
def get_domain_requests(cls, user):
"""A simplified version of domain_requests_json"""
domain_requests = DomainRequest.objects.filter(creator=user)
return domain_requests
@classmethod
def get_portfolios(cls, user):
"""Get portfolios"""
portfolios = UserPortfolioPermission.objects.filter(user=user)
return portfolios

View file

@ -7,5 +7,6 @@ from .permission_views import (
DomainRequestPermissionWithdrawView, DomainRequestPermissionWithdrawView,
DomainInvitationPermissionDeleteView, DomainInvitationPermissionDeleteView,
DomainRequestWizardPermissionView, DomainRequestWizardPermissionView,
PortfolioMembersPermission,
) )
from .api_views import get_senior_official_from_federal_agency_json from .api_views import get_senior_official_from_federal_agency_json

View file

@ -454,3 +454,20 @@ class PortfolioDomainRequestsPermission(PortfolioBasePermission):
return False return False
return super().has_permission() return super().has_permission()
class PortfolioMembersPermission(PortfolioBasePermission):
"""Permission mixin that allows access to portfolio members pages if user
has access, otherwise 403"""
def has_permission(self):
"""Check if this user has access to members 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"]"""
portfolio = self.request.session.get("portfolio")
if not self.request.user.has_view_members(portfolio):
return False
return super().has_permission()

View file

@ -18,6 +18,7 @@ from .mixins import (
UserDeleteDomainRolePermission, UserDeleteDomainRolePermission,
UserProfilePermission, UserProfilePermission,
PortfolioBasePermission, PortfolioBasePermission,
PortfolioMembersPermission,
) )
import logging import logging
@ -229,3 +230,11 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P
This abstract view cannot be instantiated. Actual views must specify This abstract view cannot be instantiated. Actual views must specify
`template_name`. `template_name`.
""" """
class PortfolioMembersPermissionView(PortfolioMembersPermission, 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

@ -72,6 +72,7 @@
10038 OUTOFSCOPE http://app:8080/domains/ 10038 OUTOFSCOPE http://app:8080/domains/
10038 OUTOFSCOPE http://app:8080/organization/ 10038 OUTOFSCOPE http://app:8080/organization/
10038 OUTOFSCOPE http://app:8080/suborganization/ 10038 OUTOFSCOPE http://app:8080/suborganization/
10038 OUTOFSCOPE http://app:8080/transfer/
# 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