Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/3363-uat-1-bug-fixes

This commit is contained in:
Rebecca Hsieh 2025-01-30 10:39:45 -08:00
commit a2ad967470
No known key found for this signature in database
61 changed files with 1112 additions and 302 deletions

View file

@ -3,10 +3,6 @@ name: Testing
on:
push:
paths-ignore:
- 'docs/**'
- '**.md'
- '.gitignore'
branches:
- main
pull_request:

View file

@ -0,0 +1,17 @@
# Cloning Databases
The clone-db workflow clones a Source database to a Destination database using cloud.gov's cg-manage-rds tool. This document contains additional information needed to understand how the workflow functions.
## Additional Roles Required
The clone-db workflow functions by temporarily sharing the Destination database with the space of the Source database. This is because cloning databases across spaces is hard. Sharing is done via the `cf share-service` command, but requires that the authenticated user (in this case this will be a user from the Source space) have the `space-developer` role in *both* the Source and Destination spaces. This must be set by someone with permission to edit space roles *before* the workflow runs. The user in question can be found using the `cf space-users [ORG] [SPACE]` command where the SPACE is the Source space, and will appear as a UAA user with a UUID as the name. There is only one such user per space by default (this is a [service account](https://cloud.gov/docs/services/cloud-gov-service-account/) set up by cloud.gov for our Github workflows). This user needs to be provided with the `space-developer` role in the Destination space, which can be accomplished using `cf set-space-role [USER] [ORG] [DESTINATION SPACE] SpaceDeveloper`.
## Turning Off DB Cloning Fast (For Emergencies or other Scenarios)
Note: In less urgent situations it may be better to make a PR removing the scheduled workflow trigger.
Step 1:
Get the name of the correct service using `cf spaces-users cisa-dotgov stable`. There should only be one user with a name that is a UUID, that is the one you want.
step 2:
Remove the space developer role by doing the following command:
`cf unset-space-role [USER] cisa-dotgov staging SpaceDeveloper`
This will cause the job to fail without requiring pushing anything to main.

View file

@ -907,13 +907,14 @@ Example (only requests): `./manage.py create_federal_portfolio --branch "executi
```docker-compose exec app ./manage.py create_federal_portfolio --agency_name "{federal_agency_name}" --both```
##### Parameters
| | Parameter | Description |
|:-:|:-------------------------- |:-------------------------------------------------------------------------------------------|
| 1 | **agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". |
| 2 | **branch** | Creates a portfolio for each federal agency in a branch: executive, legislative, judicial |
| 3 | **both** | If True, runs parse_requests and parse_domains. |
| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
| | Parameter | Description |
|:-:|:---------------------------- |:-------------------------------------------------------------------------------------------|
| 1 | **agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". |
| 2 | **branch** | Creates a portfolio for each federal agency in a branch: executive, legislative, judicial |
| 3 | **both** | If True, runs parse_requests and parse_domains. |
| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
| 6 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. |
- Parameters #1-#2: Either `--agency_name` or `--branch` must be specified. Not both.
- Parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them,
@ -953,3 +954,40 @@ To create a specific portfolio:
#### Step 1: Running the script
```docker-compose exec app ./manage.py patch_suborganizations```
## Remove Non-whitelisted Portfolios
This script removes Portfolio entries from the database that are not part of a predefined list of allowed portfolios (`ALLOWED_PORTFOLIOS`).
It performs the following actions:
1. Prompts the user for confirmation before proceeding with deletions.
2. Updates related objects such as `DomainInformation`, `Domain`, and `DomainRequest` to set their `portfolio` field to `None` to prevent integrity errors.
3. Deletes associated objects such as `PortfolioInvitation`, `UserPortfolioPermission`, and `Suborganization`.
4. Logs a detailed summary of all cascading deletions and orphaned objects.
### 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-nl`
#### Step 3: Create a shell instance
```/tmp/lifecycle/shell```
#### Step 4: Running the script
To remove portfolios:
```./manage.py remove_unused_portfolios```
If you wish to enable debug mode for additional logging:
```./manage.py remove_unused_portfolios --debug```
### Running locally
#### Step 1: Running the script
```docker-compose exec app ./manage.py remove_unused_portfolios```
To enable debug mode locally:
```docker-compose exec app ./manage.py remove_unused_portfolios --debug```

View file

@ -1222,9 +1222,9 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class SeniorOfficialAdmin(ListHeaderAdmin):
"""Custom Senior Official Admin class."""
search_fields = ["first_name", "last_name", "email"]
search_fields = ["first_name", "last_name", "email", "federal_agency__agency"]
search_help_text = "Search by first name, last name or email."
list_display = ["first_name", "last_name", "email", "federal_agency"]
list_display = ["federal_agency", "first_name", "last_name", "email"]
# this ordering effects the ordering of results
# in autocomplete_fields for Senior Official
@ -1678,22 +1678,25 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
parameter_name = "converted_generic_orgs"
def lookups(self, request, model_admin):
converted_generic_orgs = set()
# Annotate the queryset to avoid Python-side iteration
queryset = (
DomainInformation.objects.annotate(
converted_generic_org=Case(
When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"),
When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"),
default=Value(""),
output_field=CharField(),
)
)
.values_list("converted_generic_org", flat=True)
.distinct()
)
# Populate the set with tuples of (value, display value)
for domain_info in DomainInformation.objects.all():
converted_generic_org = domain_info.converted_generic_org_type # Actual value
converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value
# Filter out empty results and return sorted list of unique values
return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org])
if converted_generic_org:
converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display
# Sort the set by display value
return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value
# Filter queryset
def queryset(self, request, queryset):
if self.value(): # Check if a generic org is selected in the filter
if self.value():
return queryset.filter(
Q(portfolio__organization_type=self.value())
| Q(portfolio__isnull=True, generic_org_type=self.value())
@ -2031,22 +2034,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
parameter_name = "converted_generic_orgs"
def lookups(self, request, model_admin):
converted_generic_orgs = set()
# Annotate the queryset to avoid Python-side iteration
queryset = (
DomainRequest.objects.annotate(
converted_generic_org=Case(
When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"),
When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"),
default=Value(""),
output_field=CharField(),
)
)
.values_list("converted_generic_org", flat=True)
.distinct()
)
# Populate the set with tuples of (value, display value)
for domain_request in DomainRequest.objects.all():
converted_generic_org = domain_request.converted_generic_org_type # Actual value
converted_generic_org_display = domain_request.converted_generic_org_type_display # Display value
# Filter out empty results and return sorted list of unique values
return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org])
if converted_generic_org:
converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display
# Sort the set by display value
return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value
# Filter queryset
def queryset(self, request, queryset):
if self.value(): # Check if a generic org is selected in the filter
if self.value():
return queryset.filter(
Q(portfolio__organization_type=self.value())
| Q(portfolio__isnull=True, generic_org_type=self.value())
@ -2062,24 +2068,39 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
parameter_name = "converted_federal_types"
def lookups(self, request, model_admin):
converted_federal_types = set()
# Populate the set with tuples of (value, display value)
for domain_request in DomainRequest.objects.all():
converted_federal_type = domain_request.converted_federal_type # Actual value
converted_federal_type_display = domain_request.converted_federal_type_display # Display value
if converted_federal_type:
converted_federal_types.add(
(converted_federal_type, converted_federal_type_display) # Value, Display
# Annotate the queryset for efficient filtering
queryset = (
DomainRequest.objects.annotate(
converted_federal_type=Case(
When(
portfolio__isnull=False,
portfolio__federal_agency__federal_type__isnull=False,
then="portfolio__federal_agency__federal_type",
),
When(
portfolio__isnull=True,
federal_agency__federal_type__isnull=False,
then="federal_agency__federal_type",
),
default=Value(""),
output_field=CharField(),
)
)
.values_list("converted_federal_type", flat=True)
.distinct()
)
# Sort the set by display value
return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value
# Filter out empty values and return sorted unique entries
return sorted(
[
(federal_type, BranchChoices.get_branch_label(federal_type))
for federal_type in queryset
if federal_type
]
)
# Filter queryset
def queryset(self, request, queryset):
if self.value(): # Check if a federal type is selected in the filter
if self.value():
return queryset.filter(
Q(portfolio__federal_agency__federal_type=self.value())
| Q(portfolio__isnull=True, federal_type=self.value())
@ -3226,59 +3247,86 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
parameter_name = "converted_generic_orgs"
def lookups(self, request, model_admin):
converted_generic_orgs = set()
# Annotate the queryset to avoid Python-side iteration
queryset = (
Domain.objects.annotate(
converted_generic_org=Case(
When(
domain_info__isnull=False,
domain_info__portfolio__organization_type__isnull=False,
then="domain_info__portfolio__organization_type",
),
When(
domain_info__isnull=False,
domain_info__portfolio__isnull=True,
domain_info__generic_org_type__isnull=False,
then="domain_info__generic_org_type",
),
default=Value(""),
output_field=CharField(),
)
)
.values_list("converted_generic_org", flat=True)
.distinct()
)
# Populate the set with tuples of (value, display value)
for domain_info in DomainInformation.objects.all():
converted_generic_org = domain_info.converted_generic_org_type # Actual value
converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value
# Filter out empty results and return sorted list of unique values
return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org])
if converted_generic_org:
converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display
# Sort the set by display value
return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value
# Filter queryset
def queryset(self, request, queryset):
if self.value(): # Check if a generic org is selected in the filter
if self.value():
return queryset.filter(
Q(domain_info__portfolio__organization_type=self.value())
| Q(domain_info__portfolio__isnull=True, domain_info__generic_org_type=self.value())
)
return queryset
class FederalTypeFilter(admin.SimpleListFilter):
"""Custom Federal Type filter that accomodates portfolio feature.
If we have a portfolio, use the portfolio's federal type. If not, use the
federal type in the Domain Information object."""
organization in the Domain Request object."""
title = "federal type"
parameter_name = "converted_federal_types"
def lookups(self, request, model_admin):
converted_federal_types = set()
# Populate the set with tuples of (value, display value)
for domain_info in DomainInformation.objects.all():
converted_federal_type = domain_info.converted_federal_type # Actual value
converted_federal_type_display = domain_info.converted_federal_type_display # Display value
if converted_federal_type:
converted_federal_types.add(
(converted_federal_type, converted_federal_type_display) # Value, Display
# Annotate the queryset for efficient filtering
queryset = (
Domain.objects.annotate(
converted_federal_type=Case(
When(
domain_info__isnull=False,
domain_info__portfolio__isnull=False,
then=F("domain_info__portfolio__federal_agency__federal_type"),
),
When(
domain_info__isnull=False,
domain_info__portfolio__isnull=True,
domain_info__federal_type__isnull=False,
then="domain_info__federal_agency__federal_type",
),
default=Value(""),
output_field=CharField(),
)
)
.values_list("converted_federal_type", flat=True)
.distinct()
)
# Sort the set by display value
return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value
# Filter out empty values and return sorted unique entries
return sorted(
[
(federal_type, BranchChoices.get_branch_label(federal_type))
for federal_type in queryset
if federal_type
]
)
# Filter queryset
def queryset(self, request, queryset):
if self.value(): # Check if a federal type is selected in the filter
if self.value():
return queryset.filter(
Q(domain_info__portfolio__federal_agency__federal_type=self.value())
| Q(domain_info__portfolio__isnull=True, domain_info__federal_agency__federal_type=self.value())
Q(domain_info__portfolio__federal_type=self.value())
| Q(domain_info__portfolio__isnull=True, domain_info__federal_type=self.value())
)
return queryset

View file

@ -18,11 +18,11 @@ export function initPortfolioNewMemberPageToggle() {
const unique_id = `${member_type}-${member_id}`;
let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member";
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`);
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`);
// This easter egg is only for fixtures that dont have names as we are displaying their emails
// All prod users will have emails linked to their account
MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction);
MembersTable.addMemberDeleteModal(num_domains, member_email || member_name || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction);
uswdsInitializeModals();

View file

@ -93,7 +93,6 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
<use xlink:href="/public/img/sprite.svg#delete"></use>
</svg>` : ''}
${modal_button_text}
<span class="usa-sr-only">${screen_reader_text}</span>
</a>
`;
@ -107,6 +106,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
aria-expanded="false"
aria-controls="more-actions-${unique_id}"
aria-label="${screen_reader_text}"
>
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
@ -284,15 +284,18 @@ export class BaseTable {
showElement(dataWrapper);
hideElement(noSearchResultsWrapper);
hideElement(noDataWrapper);
this.tableAnnouncementRegion.innerHTML = '';
} else {
hideElement(dataWrapper);
showElement(noSearchResultsWrapper);
hideElement(noDataWrapper);
this.tableAnnouncementRegion.innerHTML = this.noSearchResultsWrapper.innerHTML;
}
} else {
hideElement(dataWrapper);
hideElement(noSearchResultsWrapper);
showElement(noDataWrapper);
this.tableAnnouncementRegion.innerHTML = this.noDataWrapper.innerHTML;
}
};
@ -448,6 +451,7 @@ export class BaseTable {
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
if (!baseUrlValue) return;
this.tableAnnouncementRegion.innerHTML = '<p>Loading table.</p>';
let url = `${baseUrlValue}?${searchParams.toString()}`
fetch(url)
.then(response => response.json())
@ -469,7 +473,6 @@ export class BaseTable {
let dataObjects = this.getDataObjects(data);
let customTableOptions = this.customizeTable(data);
dataObjects.forEach(dataObject => {
this.addRow(dataObject, tbody, customTableOptions);
});

View file

@ -103,8 +103,9 @@ export class EditMemberDomainsTable extends BaseTable {
disabled = true;
}
// uses margin-right-neg-5 as a hack to increase the text-wrapping width on this table
row.innerHTML = `
<td data-label="Selection" data-sort-value="0" class="padding-right-105">
<th scope="row" role="rowheader" data-label="Selection" data-sort-value="0" class="padding-right-105">
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
@ -112,6 +113,7 @@ export class EditMemberDomainsTable extends BaseTable {
type="checkbox"
name="${domain.name}"
value="${domain.id}"
aria-label="${domain.name}"
${checked ? 'checked' : ''}
${disabled ? 'disabled' : ''}
/>
@ -119,10 +121,10 @@ export class EditMemberDomainsTable extends BaseTable {
<span class="sr-only">${domain.id}</span>
</label>
</div>
</td>
</th>
<td data-label="Domain name">
${domain.name}
${disabled ? '<span class="display-block margin-top-05 text-gray-50">Domains must have one domain manager. To unassign this member, the domain needs another domain manager.</span>' : ''}
${disabled ? '<span class="display-block margin-top-05 text-gray-50 margin-right-neg-5">Domains must have one domain manager. To unassign this member, the domain needs another domain manager.</span>' : ''}
</td>
`;
tbody.appendChild(row);
@ -235,7 +237,8 @@ export class EditMemberDomainsTable extends BaseTable {
// Create unassigned domains list
const unassignedDomainsList = document.createElement('ul');
unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled');
this.removedDomains.forEach(removedDomain => {
let removedDomainsCopy = [...this.removedDomains].sort((a, b) => a.name.localeCompare(b.name));
removedDomainsCopy.forEach(removedDomain => {
const removedDomainListItem = document.createElement('li');
removedDomainListItem.textContent = removedDomain.name; // Use textContent for security
unassignedDomainsList.appendChild(removedDomainListItem);
@ -244,7 +247,8 @@ export class EditMemberDomainsTable extends BaseTable {
// Create assigned domains list
const assignedDomainsList = document.createElement('ul');
assignedDomainsList.classList.add('usa-list', 'usa-list--unstyled');
this.addedDomains.forEach(addedDomain => {
let addedDomainsCopy = [...this.addedDomains].sort((a, b) => a.name.localeCompare(b.name));
addedDomainsCopy.forEach(addedDomain => {
const addedDomainListItem = document.createElement('li');
addedDomainListItem.textContent = addedDomain.name; // Use textContent for security
assignedDomainsList.appendChild(addedDomainListItem);
@ -259,7 +263,7 @@ export class EditMemberDomainsTable extends BaseTable {
// Append unassigned domains section
if (this.removedDomains.length) {
const unassignedHeader = document.createElement('h3');
unassignedHeader.classList.add('margin-bottom-1');
unassignedHeader.classList.add('margin-bottom-05', 'h4');
unassignedHeader.textContent = 'Unassigned domains';
domainAssignmentSummary.appendChild(unassignedHeader);
domainAssignmentSummary.appendChild(unassignedDomainsList);
@ -268,7 +272,8 @@ export class EditMemberDomainsTable extends BaseTable {
// Append assigned domains section
if (this.addedDomains.length) {
const assignedHeader = document.createElement('h3');
assignedHeader.classList.add('margin-bottom-1');
// Make this h3 look like a h4
assignedHeader.classList.add('margin-bottom-05', 'h4');
assignedHeader.textContent = 'Assigned domains';
domainAssignmentSummary.appendChild(assignedHeader);
domainAssignmentSummary.appendChild(assignedDomainsList);
@ -276,7 +281,8 @@ export class EditMemberDomainsTable extends BaseTable {
// Append total assigned domains section
const totalHeader = document.createElement('h3');
totalHeader.classList.add('margin-bottom-1');
// Make this h3 look like a h4
totalHeader.classList.add('margin-bottom-05', 'h4');
totalHeader.textContent = 'Total assigned domains';
domainAssignmentSummary.appendChild(totalHeader);
const totalCount = document.createElement('p');
@ -289,6 +295,7 @@ export class EditMemberDomainsTable extends BaseTable {
this.updateReadonlyDisplay();
hideElement(this.editModeContainer);
showElement(this.readonlyModeContainer);
window.scrollTo(0, 0);
}
showEditMode() {

View file

@ -19,9 +19,9 @@ export class MemberDomainsTable extends BaseTable {
const domain = dataObject;
const row = document.createElement('tr');
row.innerHTML = `
<td scope="row" data-label="Domain name">
<th scope="row" role="rowheader" data-label="Domain name">
${domain.name}
</td>
</th>
`;
tbody.appendChild(row);
}

View file

@ -78,13 +78,12 @@ export class MembersTable extends BaseTable {
const num_domains = member.domain_urls.length;
const last_active = this.handleLastActive(member.last_active);
let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member";
const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): '';
const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `Expand for more options for ${member.name}`): '';
const row = document.createElement('tr');
let admin_tagHTML = ``;
if (member.is_admin)
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary">Admin</span>`
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary-dark text-semibold">Admin</span>`
// generate html blocks for domains and permissions for the member
let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url);
@ -99,7 +98,8 @@ export class MembersTable extends BaseTable {
type="button"
class="usa-button--show-more-button usa-button usa-button--unstyled display-block margin-top-1"
data-for=${unique_id}
aria-label="Expand for additional information"
aria-label="Expand for additional information for ${member.member_display}"
aria-label-placeholder="${member.member_display}"
>
<span>Expand</span>
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24">
@ -137,7 +137,7 @@ export class MembersTable extends BaseTable {
}
// This easter egg is only for fixtures that dont have names as we are displaying their emails
// All prod users will have emails linked to their account
if (customTableOptions.hasAdditionalActions) MembersTable.addMemberDeleteModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row);
if (customTableOptions.hasAdditionalActions) MembersTable.addMemberDeleteModal(num_domains, member.email || member.name || "Samwise Gamgee", member_delete_url, unique_id, row);
}
/**
@ -166,13 +166,27 @@ export class MembersTable extends BaseTable {
spanElement.textContent = 'Close';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
buttonParentRow.classList.add('hide-td-borders');
toggleButton.setAttribute('aria-label', 'Close additional information');
let ariaLabelText = "Close additional information";
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
if (ariaLabelPlaceholder) {
ariaLabelText = `Close additional information for ${ariaLabelPlaceholder}`;
}
toggleButton.setAttribute('aria-label', ariaLabelText);
// Set tabindex for focusable elements in expanded content
} else {
hideElement(contentDiv);
spanElement.textContent = 'Expand';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
buttonParentRow.classList.remove('hide-td-borders');
toggleButton.setAttribute('aria-label', 'Expand for additional information');
let ariaLabelText = "Expand for additional information";
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
if (ariaLabelPlaceholder) {
ariaLabelText = `Expand for additional information for ${ariaLabelPlaceholder}`;
}
toggleButton.setAttribute('aria-label', ariaLabelText);
}
}
@ -245,21 +259,19 @@ export class MembersTable extends BaseTable {
// Only generate HTML if the member has one or more assigned domains
if (num_domains > 0) {
domainsHTML += "<div class='desktop:grid-col-5 margin-bottom-2 desktop:margin-bottom-0'>";
domainsHTML += "<h4 class='margin-y-0'>Domains assigned</h4>";
domainsHTML += `<p class='margin-y-0'>This member is assigned to ${num_domains} domains:</p>`;
domainsHTML += "<h4 class='font-body-xs margin-y-0'>Domains assigned</h4>";
domainsHTML += `<p class='font-body-xs text-base-dark margin-y-0'>This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:</p>`;
domainsHTML += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
// Display up to 6 domains with their URLs
for (let i = 0; i < num_domains && i < 6; i++) {
domainsHTML += `<li><a href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
domainsHTML += `<li><a class="font-body-xs" href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
}
domainsHTML += "</ul>";
// If there are more than 6 domains, display a "View assigned domains" link
if (num_domains >= 6) {
domainsHTML += `<p><a href="${action_url}/domains">View assigned domains</a></p>`;
}
domainsHTML += `<p class="font-body-xs"><a href="${action_url}/domains">View assigned domains</a></p>`;
domainsHTML += "</div>";
}
@ -378,34 +390,37 @@ export class MembersTable extends BaseTable {
generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) {
let permissionsHTML = '';
// Define shared classes across elements for easier refactoring
let sharedParagraphClasses = "font-body-xs text-base-dark margin-top-1 p--blockquote";
// Check domain-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domains:</strong> Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>";
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domains:</strong> Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>`;
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domains:</strong> Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>";
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domains:</strong> Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>`;
}
// Check request-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domain requests:</strong> Can view all organization domain requests. Can create domain requests and modify their own requests.</p>";
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domain requests:</strong> Can view all organization domain requests. Can create domain requests and modify their own requests.</p>`;
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domain requests (view-only):</strong> Can view all organization domain requests. Can't create or modify any domain requests.</p>";
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domain requests (view-only):</strong> Can view all organization domain requests. Can't create or modify any domain requests.</p>`;
}
// Check member-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members:</strong> Can manage members including inviting new members, removing current members, and assigning domains to members.</p>";
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Members:</strong> Can manage members including inviting new members, removing current members, and assigning domains to members.</p>`;
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>";
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>`;
}
// If no specific permissions are assigned, display a message indicating no additional permissions
if (!permissionsHTML) {
permissionsHTML += "<p class='margin-top-1 p--blockquote'><b>No additional permissions:</b> There are no additional permissions for this member.</p>";
permissionsHTML += `<p class='${sharedParagraphClasses}'><b>No additional permissions:</b> There are no additional permissions for this member.</p>`;
}
// Add a permissions header and wrap the entire output in a container
permissionsHTML = "<div class='desktop:grid-col-7'><h4 class='margin-y-0'>Additional permissions for this member</h4>" + permissionsHTML + "</div>";
permissionsHTML = `<div class='desktop:grid-col-7'><h4 class='font-body-xs margin-y-0'>Additional permissions for this member</h4>${permissionsHTML}</div>`;
return permissionsHTML;
}
@ -423,7 +438,7 @@ export class MembersTable extends BaseTable {
let modalDescription = ``;
if (num_domains >= 0){
modalHeading = `Are you sure you want to delete ${member_email}?`;
modalHeading = `Are you sure you want to remove ${member_email} from the organization?`;
modalDescription = `They will no longer be able to access this organization.
This action cannot be undone.`;
if (num_domains >= 1)

View file

@ -246,6 +246,10 @@ a.text-secondary:hover {
color: $theme-color-error;
}
.usa-button.usa-button--secondary {
background-color: $theme-color-error;
}
.usa-button--show-more-button {
font-size: size('ui', 'xs');
text-decoration: none;

View file

@ -0,0 +1,5 @@
@use "uswds-core" as *;
.usa-modal__main {
padding: 0 2rem 2rem;
}

View file

@ -41,6 +41,13 @@ th {
}
}
// The member table has an extra "expand" row, which looks like a single row.
// But the DOM disagrees - so we basically need to hide the border on both rows.
#members__table-wrapper .dotgov-table tr:nth-last-child(2) td,
#members__table-wrapper .dotgov-table tr:nth-last-child(2) th {
border-bottom: none;
}
.dotgov-table {
width: 100%;
@ -56,10 +63,9 @@ th {
border: none;
}
tr:not(.hide-td-borders) {
td, th {
border-bottom: 1px solid color('base-lighter');
}
tr:not(.hide-td-borders):not(:last-child) td,
tr:not(.hide-td-borders):not(:last-child) th {
border-bottom: 1px solid color('base-lighter');
}
thead th {

View file

@ -0,0 +1,3 @@
.usa-tag {
text-transform: none;
}

View file

@ -68,6 +68,7 @@ in the form $setting: value,
/*---------------------------
## Font weights
----------------------------*/
$theme-font-weight-medium: 400,
$theme-font-weight-semibold: 600,
/*---------------------------

View file

@ -26,6 +26,8 @@
@forward "header";
@forward "register-form";
@forward "containers";
@forward "modals";
@forward "tags";
/*--------------------------------------------------
--- Admin ---------------------------------*/

View file

@ -520,7 +520,7 @@ LOGGING = {
"()": JsonFormatter,
},
},
# define where log messages will be sent;
# define where log messages will be sent
# each logger can have one or more handlers
"handlers": {
"console": {

View file

@ -323,22 +323,50 @@ class DomainRequestFixture:
cls._create_domain_requests(users)
@classmethod
def _create_domain_requests(cls, users):
def _create_domain_requests(cls, users): # noqa: C901
"""Creates DomainRequests given a list of users."""
total_domain_requests_to_make = len(users) # 100000
# Check if the database is already populated with the desired
# number of entries.
# (Prevents re-adding more entries to an already populated database,
# which happens when restarting Docker src)
domain_requests_already_made = DomainRequest.objects.count()
domain_requests_to_create = []
for user in users:
for request_data in cls.DOMAINREQUESTS:
# Prepare DomainRequest objects
try:
domain_request = DomainRequest(
creator=user,
organization_name=request_data["organization_name"],
)
cls._set_non_foreign_key_fields(domain_request, request_data)
cls._set_foreign_key_fields(domain_request, request_data, user)
domain_requests_to_create.append(domain_request)
except Exception as e:
logger.warning(e)
if domain_requests_already_made < total_domain_requests_to_make:
for user in users:
for request_data in cls.DOMAINREQUESTS:
# Prepare DomainRequest objects
try:
domain_request = DomainRequest(
creator=user,
organization_name=request_data["organization_name"],
)
cls._set_non_foreign_key_fields(domain_request, request_data)
cls._set_foreign_key_fields(domain_request, request_data, user)
domain_requests_to_create.append(domain_request)
except Exception as e:
logger.warning(e)
num_additional_requests_to_make = (
total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create)
)
if num_additional_requests_to_make > 0:
for _ in range(num_additional_requests_to_make):
random_user = random.choice(users) # nosec
try:
random_request_type = random.choice(cls.DOMAINREQUESTS) # nosec
# Prepare DomainRequest objects
domain_request = DomainRequest(
creator=random_user,
organization_name=random_request_type["organization_name"],
)
cls._set_non_foreign_key_fields(domain_request, random_request_type)
cls._set_foreign_key_fields(domain_request, random_request_type, random_user)
domain_requests_to_create.append(domain_request)
except Exception as e:
logger.warning(f"Error creating random domain request: {e}")
# Bulk create domain requests
cls._bulk_create_requests(domain_requests_to_create)

View file

@ -196,6 +196,7 @@ class UserFixture:
"username": "b6a15987-5c88-4e26-8de2-ca71a0bdb2cd",
"first_name": "Alysia-Analyst",
"last_name": "Alysia-Analyst",
"email": "abroddrick+1@truss.works",
},
{
"username": "91a9b97c-bd0a-458d-9823-babfde7ebf44",
@ -361,22 +362,30 @@ class UserFixture:
@staticmethod
def _prepare_new_users(users, existing_usernames, existing_user_ids, are_superusers):
return [
User(
id=user_data.get("id"),
first_name=user_data.get("first_name"),
last_name=user_data.get("last_name"),
username=user_data.get("username"),
email=user_data.get("email", ""),
title=user_data.get("title", "Peon"),
phone=user_data.get("phone", "2022222222"),
is_active=user_data.get("is_active", True),
is_staff=True,
is_superuser=are_superusers,
)
for user_data in users
if user_data.get("username") not in existing_usernames and user_data.get("id") not in existing_user_ids
]
new_users = []
for i, user_data in enumerate(users):
username = user_data.get("username")
id = user_data.get("id")
first_name = user_data.get("first_name", "Bob")
last_name = user_data.get("last_name", "Builder")
default_email = f"placeholder.{first_name.lower()}.{last_name.lower()}+{i}@igorville.gov"
email = user_data.get("email", default_email)
if username not in existing_usernames and id not in existing_user_ids:
user = User(
id=id,
first_name=first_name,
last_name=last_name,
username=username,
email=email,
title=user_data.get("title", "Peon"),
phone=user_data.get("phone", "2022222222"),
is_active=user_data.get("is_active", True),
is_staff=True,
is_superuser=are_superusers,
)
new_users.append(user)
return new_users
@staticmethod
def _create_new_users(new_users):

View file

@ -64,6 +64,11 @@ class Command(BaseCommand):
action=argparse.BooleanOptionalAction,
help="Adds portfolio to both requests and domains",
)
parser.add_argument(
"--skip_existing_portfolios",
action=argparse.BooleanOptionalAction,
help="Only add suborganizations to newly created portfolios, skip existing ones.",
)
def handle(self, **options):
agency_name = options.get("agency_name")
@ -71,6 +76,7 @@ class Command(BaseCommand):
parse_requests = options.get("parse_requests")
parse_domains = options.get("parse_domains")
both = options.get("both")
skip_existing_portfolios = options.get("skip_existing_portfolios")
if not both:
if not parse_requests and not parse_domains:
@ -97,7 +103,9 @@ class Command(BaseCommand):
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
try:
# C901 'Command.handle' is too complex (12)
portfolio = self.handle_populate_portfolio(federal_agency, parse_domains, parse_requests, both)
portfolio = self.handle_populate_portfolio(
federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios
)
portfolios.append(portfolio)
except Exception as exec:
self.failed_portfolios.add(federal_agency)
@ -109,26 +117,33 @@ class Command(BaseCommand):
updated_suborg_count = self.post_process_all_suborganization_fields(agencies)
message = f"Added city and state_territory information to {updated_suborg_count} suborgs."
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, message)
TerminalHelper.log_script_run_summary(
self.updated_portfolios,
self.failed_portfolios,
self.skipped_portfolios,
debug=False,
skipped_header="----- SOME PORTFOLIOS WERENT CREATED -----",
log_header="============= FINISHED HANDLE PORTFOLIO STEP ===============",
skipped_header="----- SOME PORTFOLIOS WERENT CREATED (BUT OTHER RECORDS ARE STILL PROCESSED) -----",
display_as_str=True,
)
# POST PROCESSING STEP: Remove the federal agency if it matches the portfolio name.
# We only do this for started domain requests.
if parse_requests or both:
prompt_message = (
"This action will update domain requests even if they aren't on a portfolio."
"\nNOTE: This will modify domain requests, even if no portfolios were created."
"\nIn the event no portfolios *are* created, then this step will target "
"the existing portfolios with your given params."
"\nThis step is entirely optional, and is just for extra data cleanup."
)
TerminalHelper.prompt_for_execution(
system_exit_on_terminate=True,
prompt_message="This action will update domain requests even if they aren't on a portfolio.",
prompt_message=prompt_message,
prompt_title=(
"POST PROCESS STEP: Do you want to clear federal agency on (related) started domain requests?"
),
verify_message=None,
verify_message="*** THIS STEP IS OPTIONAL ***",
)
self.post_process_started_domain_requests(agencies, portfolios)
@ -151,6 +166,11 @@ class Command(BaseCommand):
status=DomainRequest.DomainRequestStatus.STARTED,
organization_name__isnull=False,
)
if domain_requests_to_update.count() == 0:
TerminalHelper.colorful_logger(logger.info, TerminalColors.MAGENTA, "No domain requests to update.")
return
portfolio_set = {normalize_string(portfolio.organization_name) for portfolio in portfolios if portfolio}
# Update the request, assuming the given agency name matches the portfolio name
@ -173,10 +193,19 @@ class Command(BaseCommand):
DomainRequest.objects.bulk_update(updated_requests, ["federal_agency"])
TerminalHelper.colorful_logger(logger.info, TerminalColors.OKBLUE, "Action completed successfully.")
def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both):
def handle_populate_portfolio(self, federal_agency, parse_domains, parse_requests, both, skip_existing_portfolios):
"""Attempts to create a portfolio. If successful, this function will
also create new suborganizations"""
portfolio, _ = self.create_portfolio(federal_agency)
portfolio, created = self.create_portfolio(federal_agency)
if skip_existing_portfolios and not created:
TerminalHelper.colorful_logger(
logger.warning,
TerminalColors.YELLOW,
"Skipping modifications to suborgs, domain requests, and "
"domains due to the --skip_existing_portfolios flag. Portfolio already exists.",
)
return portfolio
self.create_suborganizations(portfolio, federal_agency)
if parse_domains or both:
self.handle_portfolio_domains(portfolio, federal_agency)
@ -283,15 +312,13 @@ class Command(BaseCommand):
DomainRequest.DomainRequestStatus.INELIGIBLE,
DomainRequest.DomainRequestStatus.REJECTED,
]
domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency, portfolio__isnull=True).exclude(
status__in=invalid_states
)
domain_requests = DomainRequest.objects.filter(federal_agency=federal_agency).exclude(status__in=invalid_states)
if not domain_requests.exists():
message = f"""
Portfolio '{portfolio}' not added to domain requests: no valid records found.
This means that a filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
Excluded statuses: STARTED, INELIGIBLE, REJECTED.
Filter info: DomainRequest.objects.filter(federal_agency=federal_agency, portfolio__isnull=True).exclude(
Filter info: DomainRequest.objects.filter(federal_agency=federal_agency).exclude(
status__in=invalid_states
)
"""
@ -335,12 +362,12 @@ class Command(BaseCommand):
Returns a queryset of DomainInformation objects, or None if nothing changed.
"""
domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True)
domain_infos = DomainInformation.objects.filter(federal_agency=federal_agency)
if not domain_infos.exists():
message = f"""
Portfolio '{portfolio}' not added to domains: no valid records found.
The filter on DomainInformation for the federal_agency '{federal_agency}' returned no results.
Filter info: DomainInformation.objects.filter(federal_agency=federal_agency, portfolio__isnull=True)
Filter info: DomainInformation.objects.filter(federal_agency=federal_agency)
"""
TerminalHelper.colorful_logger(logger.info, TerminalColors.YELLOW, message)
return None

View file

@ -0,0 +1,238 @@
import argparse
import logging
from django.core.management.base import BaseCommand
from django.db import IntegrityError
from django.db import transaction
from registrar.management.commands.utility.terminal_helper import (
TerminalColors,
TerminalHelper,
)
from registrar.models import (
Portfolio,
DomainGroup,
DomainInformation,
DomainRequest,
PortfolioInvitation,
Suborganization,
UserPortfolioPermission,
)
logger = logging.getLogger(__name__)
ALLOWED_PORTFOLIOS = [
"Department of Veterans Affairs",
"Department of the Treasury",
"National Archives and Records Administration",
"Department of Defense",
"Office of Personnel Management",
"National Aeronautics and Space Administration",
"City and County of San Francisco",
"State of Arizona, Executive Branch",
"Department of the Interior",
"Department of State",
"Department of Justice",
"Capitol Police",
"Administrative Office of the Courts",
"Supreme Court of the United States",
]
class Command(BaseCommand):
help = "Remove all Portfolio entries with names not in the allowed list."
def add_arguments(self, parser):
"""
OPTIONAL ARGUMENTS:
--debug
A boolean (default to true), which activates additional print statements
"""
parser.add_argument("--debug", action=argparse.BooleanOptionalAction)
def prompt_delete_entries(self, portfolios_to_delete, debug_on):
"""Brings up a prompt in the terminal asking
if the user wishes to delete data in the
Portfolio table. If the user confirms,
deletes the data in the Portfolio table"""
entries_to_remove_by_name = list(portfolios_to_delete.values_list("organization_name", flat=True))
formatted_entries = "\n\t\t".join(entries_to_remove_by_name)
confirm_delete = TerminalHelper.query_yes_no(
f"""
{TerminalColors.FAIL}
WARNING: You are about to delete the following portfolios:
{formatted_entries}
Are you sure you want to continue?{TerminalColors.ENDC}"""
)
if confirm_delete:
logger.info(
f"""{TerminalColors.YELLOW}
----------Deleting entries----------
(please wait)
{TerminalColors.ENDC}"""
)
self.delete_entries(portfolios_to_delete, debug_on)
else:
logger.info(
f"""{TerminalColors.OKCYAN}
----------No entries deleted----------
(exiting script)
{TerminalColors.ENDC}"""
)
def delete_entries(self, portfolios_to_delete, debug_on): # noqa: C901
# Log the number of entries being removed
count = portfolios_to_delete.count()
if count == 0:
logger.info(
f"""{TerminalColors.OKCYAN}
No entries to remove.
{TerminalColors.ENDC}
"""
)
return
# If debug mode is on, print out entries being removed
if debug_on:
entries_to_remove_by_name = list(portfolios_to_delete.values_list("organization_name", flat=True))
formatted_entries = ", ".join(entries_to_remove_by_name)
logger.info(
f"""{TerminalColors.YELLOW}
Entries to be removed: {formatted_entries}
{TerminalColors.ENDC}
"""
)
# Check for portfolios with non-empty related objects
# (These will throw integrity errors if they are not updated)
portfolios_with_assignments = []
for portfolio in portfolios_to_delete:
has_assignments = any(
[
DomainGroup.objects.filter(portfolio=portfolio).exists(),
DomainInformation.objects.filter(portfolio=portfolio).exists(),
DomainRequest.objects.filter(portfolio=portfolio).exists(),
PortfolioInvitation.objects.filter(portfolio=portfolio).exists(),
Suborganization.objects.filter(portfolio=portfolio).exists(),
UserPortfolioPermission.objects.filter(portfolio=portfolio).exists(),
]
)
if has_assignments:
portfolios_with_assignments.append(portfolio)
if portfolios_with_assignments:
formatted_entries = "\n\t\t".join(
f"{portfolio.organization_name}" for portfolio in portfolios_with_assignments
)
confirm_cascade_delete = TerminalHelper.query_yes_no(
f"""
{TerminalColors.FAIL}
WARNING: these entries have related objects.
{formatted_entries}
Deleting them will update any associated domains / domain requests to have no portfolio
and will cascade delete any associated portfolio invitations, portfolio permissions, domain groups,
and suborganizations. Any suborganizations that get deleted will also orphan (not delete) their
associated domains / domain requests.
Are you sure you want to continue?{TerminalColors.ENDC}"""
)
if not confirm_cascade_delete:
logger.info(
f"""{TerminalColors.OKCYAN}
Operation canceled by the user.
{TerminalColors.ENDC}
"""
)
return
with transaction.atomic():
# Try to delete the portfolios
try:
summary = []
for portfolio in portfolios_to_delete:
portfolio_summary = [f"---- CASCADE SUMMARY for {portfolio.organization_name} -----"]
if portfolio in portfolios_with_assignments:
domain_groups = DomainGroup.objects.filter(portfolio=portfolio)
domain_informations = DomainInformation.objects.filter(portfolio=portfolio)
domain_requests = DomainRequest.objects.filter(portfolio=portfolio)
portfolio_invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
suborganizations = Suborganization.objects.filter(portfolio=portfolio)
user_permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
if domain_groups.exists():
formatted_groups = "\n".join([str(group) for group in domain_groups])
portfolio_summary.append(f"{len(domain_groups)} Deleted DomainGroups:\n{formatted_groups}")
domain_groups.delete()
if domain_informations.exists():
formatted_domain_infos = "\n".join([str(info) for info in domain_informations])
portfolio_summary.append(
f"{len(domain_informations)} Orphaned DomainInformations:\n{formatted_domain_infos}"
)
domain_informations.update(portfolio=None)
if domain_requests.exists():
formatted_domain_reqs = "\n".join([str(req) for req in domain_requests])
portfolio_summary.append(
f"{len(domain_requests)} Orphaned DomainRequests:\n{formatted_domain_reqs}"
)
domain_requests.update(portfolio=None)
if portfolio_invitations.exists():
formatted_portfolio_invitations = "\n".join([str(inv) for inv in portfolio_invitations])
portfolio_summary.append(
f"{len(portfolio_invitations)} Deleted PortfolioInvitations:\n{formatted_portfolio_invitations}" # noqa
)
portfolio_invitations.delete()
if user_permissions.exists():
formatted_user_list = "\n".join(
[perm.user.get_formatted_name() for perm in user_permissions]
)
portfolio_summary.append(
f"Deleted UserPortfolioPermissions for the following users:\n{formatted_user_list}"
)
user_permissions.delete()
if suborganizations.exists():
portfolio_summary.append("Cascade Deleted Suborganizations:")
for suborg in suborganizations:
DomainInformation.objects.filter(sub_organization=suborg).update(sub_organization=None)
DomainRequest.objects.filter(sub_organization=suborg).update(sub_organization=None)
portfolio_summary.append(f"{suborg.name}")
suborg.delete()
portfolio.delete()
summary.append("\n\n".join(portfolio_summary))
summary_string = "\n\n".join(summary)
# Output a success message with detailed summary
logger.info(
f"""{TerminalColors.OKCYAN}
Successfully removed {count} portfolios.
The following portfolio deletions had cascading effects;
{summary_string}
{TerminalColors.ENDC}
"""
)
except IntegrityError as e:
logger.info(
f"""{TerminalColors.FAIL}
Could not delete some portfolios due to integrity constraints:
{e}
{TerminalColors.ENDC}
"""
)
def handle(self, *args, **options):
# Get all Portfolio entries not in the allowed portfolios list
portfolios_to_delete = Portfolio.objects.exclude(organization_name__in=ALLOWED_PORTFOLIOS)
self.prompt_delete_entries(portfolios_to_delete, options.get("debug"))

View file

@ -9,6 +9,7 @@ from django.utils import timezone
from registrar.models.domain import Domain
from registrar.models.federal_agency import FederalAgency
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.utility.constants import BranchChoices
from auditlog.models import LogEntry
@ -903,6 +904,7 @@ class DomainRequest(TimeStampedModel):
email_template,
email_template_subject,
bcc_address="",
cc_addresses: list[str] = [],
context=None,
send_email=True,
wrap_email=False,
@ -955,12 +957,20 @@ class DomainRequest(TimeStampedModel):
if custom_email_content:
context["custom_email_content"] = custom_email_content
if self.requesting_entity_is_portfolio() or self.requesting_entity_is_suborganization():
portfolio_view_requests_users = self.portfolio.portfolio_users_with_permissions( # type: ignore
permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS], include_admin=True
)
cc_addresses = list(portfolio_view_requests_users.values_list("email", flat=True))
send_templated_email(
email_template,
email_template_subject,
recipient.email,
context=context,
bcc_address=bcc_address,
cc_addresses=cc_addresses,
wrap_email=wrap_email,
)
logger.info(f"The {new_status} email sent to: {recipient.email}")

View file

@ -4,6 +4,7 @@ from registrar.models.domain_request import DomainRequest
from registrar.models.federal_agency import FederalAgency
from registrar.models.user import User
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from django.db.models import Q
from .utility.time_stamped_model import TimeStampedModel
@ -122,6 +123,16 @@ class Portfolio(TimeStampedModel):
if self.state_territory != self.StateTerritoryChoices.PUERTO_RICO and self.urbanization:
self.urbanization = None
# If the org type is federal, and org federal agency is not blank, and is a federal agency
# overwrite the organization name with the federal agency's agency
if (
self.organization_type == self.OrganizationChoices.FEDERAL
and self.federal_agency
and self.federal_agency != FederalAgency.get_non_federal_agency()
and self.federal_agency.agency
):
self.organization_name = self.federal_agency.agency
super().save(*args, **kwargs)
@property
@ -144,6 +155,25 @@ class Portfolio(TimeStampedModel):
).values_list("user__id", flat=True)
return User.objects.filter(id__in=admin_ids)
def portfolio_users_with_permissions(self, permissions=[], include_admin=False):
"""Gets all users with specified additional permissions for this particular portfolio.
Returns a queryset of User."""
portfolio_users = self.portfolio_users
if permissions:
if include_admin:
portfolio_users = portfolio_users.filter(
Q(additional_permissions__overlap=permissions)
| Q(
roles__overlap=[
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
]
),
)
else:
portfolio_users = portfolio_users.filter(additional_permissions__overlap=permissions)
user_ids = portfolio_users.values_list("user__id", flat=True)
return User.objects.filter(id__in=user_ids)
# == Getters for domains == #
def get_domains(self, order_by=None):
"""Returns all DomainInformations associated with this portfolio"""

View file

@ -55,7 +55,9 @@ class SeniorOfficial(TimeStampedModel):
return " ".join(names) if names else "Unknown"
def __str__(self):
if self.first_name or self.last_name:
if self.federal_agency and (self.first_name or self.last_name):
return self.get_formatted_name() + " of " + self.federal_agency.__str__()
elif self.first_name or self.last_name:
return self.get_formatted_name()
elif self.pk:
return str(self.pk)

View file

@ -30,7 +30,7 @@
<td>{{ member.user.phone }}</td>
<td>
{% for role in member.user|portfolio_role_summary:original %}
<span class="usa-tag">{{ role }}</span>
<span class="usa-tag bg-primary-dark text-semibold">{{ role }}</span>
{% endfor %}
</td>
<td class="padding-left-1 text-size-small">

View file

@ -4,6 +4,9 @@
{% block title %}Add a domain manager | {% endblock %}
{% block domain_content %}
{% include "includes/form_errors.html" with form=form %}
{% block breadcrumb %}
{% if portfolio %}
<!-- Navigation breadcrumbs -->
@ -38,8 +41,6 @@
{% endif %}
{% endblock breadcrumb %}
{% include "includes/form_errors.html" with form=form %}
<h1>Add a domain manager</h1>
{% if has_organization_feature_flag %}
<p>

View file

@ -46,7 +46,7 @@
{# messages block is under the back breadcrumb link #}
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-3">
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2">
<div class="usa-alert__body">
{{ message }}
</div>

View file

@ -5,6 +5,10 @@
{% block domain_content %}
{% for form in formset %}
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
{% block breadcrumb %}
{% if portfolio %}
<!-- Navigation breadcrumbs -->
@ -38,10 +42,6 @@
</div>
{% endif %}
{% for form in formset %}
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
<h1 id="domain-dsdata">DS data</h1>
<p>In order to enable DNSSEC, you must first configure it with your DNS hosting service.</p>

View file

@ -4,6 +4,12 @@
{% block title %}DNS name servers | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}
{% for form in formset %}
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
{% block breadcrumb %}
{% if portfolio %}
<!-- Navigation breadcrumbs -->
@ -26,11 +32,6 @@
{% endif %}
{% endblock breadcrumb %}
{# this is right after the messages block in the parent template #}
{% for form in formset %}
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
<h1>DNS name servers</h1>
<p>Before your domain can be used well need information about your domain name servers. Name server records indicate which DNS server is authoritative for your domain.</p>

View file

@ -4,6 +4,9 @@
{% block title %}Security email | {{ domain.name }} | {% endblock %}
{% block domain_content %}
{% include "includes/form_errors.html" with form=form %}
{% block breadcrumb %}
{% if portfolio %}
<!-- Navigation breadcrumbs -->
@ -23,8 +26,6 @@
{% endif %}
{% endblock breadcrumb %}
{% include "includes/form_errors.html" with form=form %}
<h1>Security email</h1>
<p>We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain. Security emails are made public and included in the <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'about/data/' %}">.gov domain data</a> we provide.</p>

View file

@ -5,6 +5,8 @@
{% block domain_content %}
{% include "includes/form_errors.html" with form=form %}
{% block breadcrumb %}
{% if portfolio %}
<!-- Navigation breadcrumbs -->
@ -24,10 +26,6 @@
{% endif %}
{% endblock breadcrumb %}
{# this is right after the messages block in the parent template #}
{% include "includes/form_errors.html" with form=form %}
<h1>Suborganization</h1>
<p>

View file

@ -65,11 +65,10 @@
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ item.permission.user.email }}" data-label="Email">
{{ item.permission.user.email }}
{% if item.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
{% if item.has_admin_flag %}<span class="usa-tag margin-left-1 primary-dark text-semibold">Admin</span>{% endif %}
</th>
{% if not portfolio %}<td data-label="Role">{{ item.permission.role|title }}</td>{% endif %}
<td>
{% if can_delete_users %}
<a
id="button-toggle-user-alert-{{ forloop.counter }}"
href="#toggle-user-alert-{{ forloop.counter }}"
@ -77,6 +76,7 @@
aria-controls="toggle-user-alert-{{ forloop.counter }}"
data-open-modal
aria-disabled="false"
aria-label="Remove {{ item.permission.user.email }}""
>
Remove
</a>
@ -112,18 +112,6 @@
{% csrf_token %}
</form>
{% endif %}
{% else %}
<input
type="submit"
class="usa-button--unstyled disabled-button usa-tooltip usa-tooltip--registrar"
value="Remove"
data-position="bottom"
title="Domains must have at least one domain manager"
data-tooltip="true"
aria-disabled="true"
role="button"
>
{% endif %}
</td>
</tr>
{% endfor %}
@ -160,7 +148,7 @@
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ invitation.domain_invitation.user.email }}" data-label="Email">
{{ invitation.domain_invitation.email }}
{% if invitation.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
{% if invitation.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary-dark text-semibold">Admin</span>{% endif %}
</th>
<td data-sort-value="{{ invitation.domain_invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.domain_invitation.created_at|date }} </td>
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}

View file

@ -4,6 +4,7 @@ Hi, {{ recipient.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Action needed

View file

@ -4,6 +4,7 @@ Hi, {{ recipient.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Action needed

View file

@ -4,6 +4,7 @@ Hi, {{ recipient.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Action needed

View file

@ -4,6 +4,7 @@ Hi, {{ recipient.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Action needed

View file

@ -2,27 +2,23 @@
Hi,{% if domain_manager and domain_manager.first_name %} {{ domain_manager.first_name }}.{% endif %}
A domain manager was invited to {{ domain.name }}.
DOMAIN: {{ domain.name }}
INVITED BY: {{ requestor_email }}
INVITED ON: {{date}}
MANAGER INVITED: {{ invited_email_address }}
----------------------------------------------------------------
NEXT STEPS
The person who received the invitation will become a domain manager once they log in to the
.gov registrar. They'll need to access the registrar using a Login.gov account that's
associated with the invited email address.
If you need to cancel this invitation or remove the domain manager (because they've already
logged in), you can do that by going to this domain in the .gov registrar <https://manage.get.gov/>.
If you need to cancel this invitation or remove the domain manager, you can do that by going to
this domain in the .gov registrar <https://manage.get.gov/>.
WHY DID YOU RECEIVE THIS EMAIL?
Youre listed as a domain manager for {{ domain.name }}, so youll receive a notification whenever
someone is invited to manage that domain.

View file

@ -4,6 +4,7 @@ Hi, {{ recipient.first_name }}.
Your .gov domain request has been withdrawn and will not be reviewed by our team.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Withdrawn

View file

@ -4,6 +4,7 @@ Hi, {{ recipient.first_name }}.
Congratulations! Your .gov domain request has been approved.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Approved

View file

@ -4,6 +4,7 @@ Hi, {{ recipient.first_name }}.
Your .gov domain request has been rejected.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Rejected

View file

@ -4,6 +4,7 @@ Hi, {{ recipient.first_name }}.
We received your .gov domain request.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Submitted
@ -11,13 +12,15 @@ STATUS: Submitted
NEXT STEPS
Well review your request. This review period can take 30 business days. Due to the volume of requests, the wait time is longer than usual. We appreciate your patience.
{% if is_org_user %}
During our review well verify that your requested domain meets our naming requirements.
{% else %}
During our review, well verify that:
- Your organization is eligible for a .gov domain
- You work at the organization and/or can make requests on its behalf
- Your requested domain meets our naming requirements
Well email you if we have questions. Well also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <https://manage.get.gov>
{% endif %}
Well email you if we have questions. Well also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <https://manage.get.gov>.
NEED TO MAKE CHANGES?

View file

@ -1,7 +1,7 @@
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2">
<div class="usa-alert__body">
<div class="usa-alert__body {% if no_max_width %} maxw-none {% endif %}">
{{ message }}
</div>
</div>

View file

@ -23,7 +23,7 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_member_domains_json' as url %}
<span id="get_member_domains_json_url" class="display-none">{{url}}</span>
<section class="section-outlined member-domains margin-top-0 section-outlined--border-base-light" id="edit-member-domains">
<section class="section-outlined member-domains margin-top-0 padding-bottom-0 section-outlined--border-base-light" id="edit-member-domains">
<h2>
Edit domains assigned to
@ -37,7 +37,7 @@
<div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
<section aria-label="Member domains search component" class="margin-top-2">
<section aria-label="Member domains search component">
<form class="usa-search usa-search--show-label" method="POST" role="search">
{% csrf_token %}
<label class="usa-label display-block margin-bottom-05" for="edit-member-domains__search-field">
@ -81,7 +81,7 @@
<!-- ---------- MAIN TABLE ---------- -->
<div class="display-none margin-top-0" id="edit-member-domains__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked margin-bottom-4">
<caption class="sr-only">member domains</caption>
<thead>
<tr>
@ -99,10 +99,10 @@
aria-live="polite"
></div>
</div>
<div class="display-none" id="edit-member-domains__no-data">
<div class="display-none margin-bottom-4" id="edit-member-domains__no-data">
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p>
</div>
<div class="display-none" id="edit-member-domains__no-search-results">
<div class="display-none margin-bottom-4" id="edit-member-domains__no-search-results">
<p>No results found</p>
</div>
</section>

View file

@ -23,7 +23,7 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_member_domains_json' as url %}
<span id="get_member_domains_json_url" class="display-none">{{url}}</span>
<section class="section-outlined member-domains margin-top-0 section-outlined--border-base-light" id="member-domains">
<section class="section-outlined member-domains margin-top-0 padding-bottom-0 section-outlined--border-base-light" id="member-domains">
<h2>
Domains assigned to
@ -37,7 +37,7 @@
<div class="section-outlined__header margin-bottom-3 grid-row" id="edit-member-domains__search">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
<section aria-label="Member domains search component" class="margin-top-2">
<section aria-label="Member domains search component">
<form class="usa-search usa-search--show-label" method="POST" role="search">
{% csrf_token %}
<label class="usa-label display-block margin-bottom-05" for="member-domains__search-field">
@ -77,7 +77,7 @@
<!-- ---------- MAIN TABLE ---------- -->
<div class="display-none margin-top-0" id="member-domains__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked margin-bottom-4">
<caption class="sr-only">member domains</caption>
<thead>
<tr>
@ -94,10 +94,10 @@
aria-live="polite"
></div>
</div>
<div class="display-none" id="member-domains__no-data">
<div class="display-none margin-bottom-4" id="member-domains__no-data">
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p>
</div>
<div class="display-none" id="member-domains__no-search-results">
<div class="display-none margin-bottom-4" id="member-domains__no-search-results">
<p>No results found</p>
</div>
</section>

View file

@ -54,7 +54,7 @@
<thead>
<tr>
<th data-sortable="member" role="columnheader" id="header-member">Member</th>
<th data-sortable="last_active" role="columnheader" id="header-last-active">Last Active</th>
<th data-sortable="last_active" role="columnheader" id="header-last-active">Last active</th>
<th
role="columnheader"
id="header-action"

View file

@ -16,7 +16,7 @@ Organization member
{% endblock messages%}
{% url 'members' as url %}
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
<nav class="usa-breadcrumb padding-top-0" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
@ -41,7 +41,7 @@ Organization member
{% if has_edit_members_portfolio_permission %}
{% if member %}
<div id="wrapper-delete-action"
data-member-name="{{ member.email }}"
data-member-name="{{ member.get_formatted_name }}"
data-member-type="member"
data-member-id="{{ member.id }}"
data-num-domains="{{ portfolio_permission.get_managed_domains_count }}"

View file

@ -21,7 +21,7 @@
{% url 'invitedmember' pk=portfolio_invitation.id as url2 %}
{% url 'invitedmember-domains-edit' pk=portfolio_invitation.id as url3 %}
{% endif %}
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
<nav class="usa-breadcrumb padding-top-0" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
@ -37,7 +37,7 @@
<div class="grid-row grid-gap">
<div class="mobile:grid-col-12 tablet:grid-col-7">
<h1 class="margin-bottom-3">Domain assignments</h1>
<h1>Domain assignments</h1>
</div>
{% if has_edit_members_portfolio_permission %}
<div class="mobile:grid-col-12 tablet:grid-col-5">
@ -51,7 +51,7 @@
{% endif %}
</div>
<p>
<p class="margin-top-0 margin-bottom-4 maxw-none">
A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains.
</p>

View file

@ -21,7 +21,7 @@
{% url 'invitedmember' pk=portfolio_invitation.id as url2 %}
{% url 'invitedmember-domains' pk=portfolio_invitation.id as url3 %}
{% endif %}
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
<nav class="usa-breadcrumb padding-top-0" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
@ -39,12 +39,10 @@
</nav>
<section id="domain-assignments-edit-view">
<h1 class="margin-bottom-3">Edit domain assignments</h1>
<h1>Edit domain assignments</h1>
<p class="margin-bottom-0">
<p class="margin-top-0 margin-bottom-4 maxw-none">
A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains.
</p>
<p>
When you save this form the member will get an email to notify them of any changes.
</p>
@ -76,7 +74,7 @@
<section id="domain-assignments-readonly-view" class="display-none">
<h1 class="margin-bottom-3">Review domain assignments</h1>
<h2>Would you like to continue with the following domain assignment changes for
<h2 class="margin-top-0">Would you like to continue with the following domain assignment changes for
{% if member %}
{{ member.email }}
{% else %}
@ -84,17 +82,19 @@
{% endif %}
</h2>
<p>When you save this form the member will get an email to notify them of any changes.</p>
<p class="margin-bottom-4">
When you save this form the member will get an email to notify them of any changes.
</p>
<div id="domain-assignments-summary" class="margin-bottom-2">
<div id="domain-assignments-summary" class="margin-bottom-5">
<!-- AJAX will populate this summary -->
<h3 class="margin-bottom-1">Unassigned domains</h3>
<h3 class="margin-bottom-1 h4">Unassigned domains</h3>
<ul class="usa-list usa-list--unstyled">
<li>item1</li>
<li>item2</li>
</ul>
<h3 class="margin-bottom-0">Assigned domains</h3>
<h3 class="margin-bottom-0 h4">Assigned domains</h3>
<ul class="usa-list usa-list--unstyled">
<li>item1</li>
<li>item2</li>

View file

@ -9,7 +9,6 @@
{% endblock %}
{% block portfolio_content %}
{% include "includes/form_errors.html" with form=form %}
<div id="main-content" class=" {% if not is_widescreen_centered %}desktop:grid-offset-2{% endif %}">
<!-- Form messages -->

View file

@ -12,12 +12,12 @@
<!-- Form messages -->
{% block messages %}
{% include "includes/form_messages.html" %}
{% include "includes/form_messages.html" with no_max_width=True %}
{% endblock messages%}
<div id="main-content">
<div id="toggleable-alert" class="usa-alert usa-alert--slim margin-bottom-2 display-none">
<div class="usa-alert__body usa-alert__body--widescreen">
<div class="usa-alert__body">
<p class="usa-alert__text ">
<!-- alert message will be conditionally populated by javascript -->
</p>

View file

@ -779,7 +779,7 @@ class TestDomainAdminWithClient(TestCase):
response = self.client.get("/admin/registrar/domain/")
# There are 4 template references to Federal (4) plus four references in the table
# for our actual domain_request
self.assertContains(response, "Federal", count=57)
self.assertContains(response, "Federal", count=56)
# This may be a bit more robust
self.assertContains(response, '<td class="field-converted_generic_org_type">Federal</td>', count=1)
# Now let's make sure the long description does not exist

View file

@ -662,7 +662,7 @@ class TestDomainRequestAdmin(MockEppLib):
response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal")
# There are 2 template references to Federal (4) and two in the results data
# of the request
self.assertContains(response, "Federal", count=55)
self.assertContains(response, "Federal", count=54)
# This may be a bit more robust
self.assertContains(response, '<td class="field-converted_generic_org_type">Federal</td>', count=1)
# Now let's make sure the long description does not exist

View file

@ -3,7 +3,10 @@ import boto3_mocking # type: ignore
from datetime import date, datetime, time
from django.core.management import call_command
from django.test import TestCase, override_settings
from registrar.models.domain_group import DomainGroup
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.utility.constants import BranchChoices
from django.utils import timezone
from django.utils.module_loading import import_string
@ -32,7 +35,13 @@ import tablib
from unittest.mock import patch, call, MagicMock, mock_open
from epplibwrapper import commands, common
from .common import MockEppLib, less_console_noise, completed_domain_request, MockSESClient, MockDbForIndividualTests
from .common import (
MockEppLib,
less_console_noise,
completed_domain_request,
MockSESClient,
MockDbForIndividualTests,
)
from api.tests.common import less_console_noise_decorator
@ -1822,7 +1831,7 @@ class TestCreateFederalPortfolio(TestCase):
self.run_create_federal_portfolio(agency_name="Non-existent Agency", parse_requests=True)
def test_does_not_update_existing_portfolio(self):
"""Tests that an existing portfolio is not updated"""
"""Tests that an existing portfolio is not updated when"""
# Create an existing portfolio
existing_portfolio = Portfolio.objects.create(
federal_agency=self.federal_agency,
@ -1845,6 +1854,71 @@ class TestCreateFederalPortfolio(TestCase):
self.assertEqual(existing_portfolio.notes, "Old notes")
self.assertEqual(existing_portfolio.creator, self.user)
def test_skip_existing_portfolios(self):
"""Tests the skip_existing_portfolios to ensure that it doesn't add
suborgs, domain requests, and domain info."""
# Create an existing portfolio with a suborganization
existing_portfolio = Portfolio.objects.create(
federal_agency=self.federal_agency,
organization_name="Test Federal Agency",
organization_type=DomainRequest.OrganizationChoices.CITY,
creator=self.user,
notes="Old notes",
)
existing_suborg = Suborganization.objects.create(
portfolio=existing_portfolio, name="Existing Suborg", city="Old City", state_territory="CA"
)
# Create a domain request that would normally be associated
domain_request = completed_domain_request(
name="wackytaco.gov",
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.federal_agency,
user=self.user,
organization_name="would_create_suborg",
)
domain_request.approve()
domain = Domain.objects.get(name="wackytaco.gov").domain_info
# Run the command with skip_existing_portfolios=True
self.run_create_federal_portfolio(
agency_name="Test Federal Agency", parse_requests=True, skip_existing_portfolios=True
)
# Refresh objects from database
existing_portfolio.refresh_from_db()
existing_suborg.refresh_from_db()
domain_request.refresh_from_db()
domain.refresh_from_db()
# Verify nothing was changed on the portfolio itself
# SANITY CHECK: if the portfolio updates, it will change to FEDERAL.
# if this case fails, it means we are overriding data (and not simply just other weirdness)
self.assertNotEqual(existing_portfolio.organization_type, DomainRequest.OrganizationChoices.FEDERAL)
# Notes and creator should be untouched
self.assertEqual(existing_portfolio.organization_type, DomainRequest.OrganizationChoices.CITY)
self.assertEqual(existing_portfolio.organization_name, self.federal_agency.agency)
self.assertEqual(existing_portfolio.notes, "Old notes")
self.assertEqual(existing_portfolio.creator, self.user)
# Verify suborganization wasn't modified
self.assertEqual(existing_suborg.city, "Old City")
self.assertEqual(existing_suborg.state_territory, "CA")
# Verify that the domain request wasn't modified
self.assertIsNone(domain_request.portfolio)
self.assertIsNone(domain_request.sub_organization)
# Verify that the domain wasn't modified
self.assertIsNone(domain.portfolio)
self.assertIsNone(domain.sub_organization)
# Verify that a new suborg wasn't created
self.assertFalse(Suborganization.objects.filter(name="would_create_suborg").exists())
@less_console_noise_decorator
def test_post_process_suborganization_fields(self):
"""Test suborganization field updates from domain and request data.
@ -2167,3 +2241,116 @@ class TestPatchSuborganizations(MockDbForIndividualTests):
self.assertEqual(self.domain_information_1.sub_organization, keep_org)
self.assertEqual(self.domain_request_2.sub_organization, unrelated_org)
self.assertEqual(self.domain_information_2.sub_organization, unrelated_org)
class TestRemovePortfolios(TestCase):
"""Test the remove_unused_portfolios command"""
def setUp(self):
self.user = User.objects.create(username="testuser")
self.logger_patcher = patch("registrar.management.commands.export_tables.logger")
self.logger_mock = self.logger_patcher.start()
# Create mock database objects
self.portfolio_ok = Portfolio.objects.create(
organization_name="Department of Veterans Affairs", creator=self.user
)
self.unused_portfolio_with_related_objects = Portfolio.objects.create(
organization_name="Test with orphaned objects", creator=self.user
)
self.unused_portfolio_with_suborgs = Portfolio.objects.create(
organization_name="Test with suborg", creator=self.user
)
# Create related objects for unused_portfolio_with_related_objects
self.domain_information = DomainInformation.objects.create(
portfolio=self.unused_portfolio_with_related_objects, creator=self.user
)
self.domain_request = DomainRequest.objects.create(
portfolio=self.unused_portfolio_with_related_objects, creator=self.user
)
self.inv = PortfolioInvitation.objects.create(portfolio=self.unused_portfolio_with_related_objects)
self.group = DomainGroup.objects.create(
portfolio=self.unused_portfolio_with_related_objects, name="Test Domain Group"
)
self.perm = UserPortfolioPermission.objects.create(
portfolio=self.unused_portfolio_with_related_objects, user=self.user
)
# Create a suborganization and suborg related objects for unused_portfolio_with_suborgs
self.suborganization = Suborganization.objects.create(
portfolio=self.unused_portfolio_with_suborgs, name="Test Suborg"
)
self.suborg_domain_information = DomainInformation.objects.create(
sub_organization=self.suborganization, creator=self.user
)
def tearDown(self):
self.logger_patcher.stop()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
Suborganization.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
@patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no")
def test_delete_unlisted_portfolios(self, mock_query_yes_no):
"""Test that portfolios not on the allowed list are deleted."""
mock_query_yes_no.return_value = True
# Ensure all portfolios exist before running the command
self.assertEqual(Portfolio.objects.count(), 3)
# Run the command
call_command("remove_unused_portfolios", debug=False)
# Check that the unlisted portfolio was removed
self.assertEqual(Portfolio.objects.count(), 1)
self.assertFalse(Portfolio.objects.filter(organization_name="Test with orphaned objects").exists())
self.assertFalse(Portfolio.objects.filter(organization_name="Test with suborg").exists())
self.assertTrue(Portfolio.objects.filter(organization_name="Department of Veterans Affairs").exists())
@patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no")
def test_delete_entries_with_related_objects(self, mock_query_yes_no):
"""Test deletion with related objects being handled properly."""
mock_query_yes_no.return_value = True
# Ensure related objects exist before running the command
self.assertEqual(DomainInformation.objects.count(), 2)
self.assertEqual(DomainRequest.objects.count(), 1)
# Run the command
call_command("remove_unused_portfolios", debug=False)
# Check that related objects were updated
self.assertEqual(
DomainInformation.objects.filter(portfolio=self.unused_portfolio_with_related_objects).count(), 0
)
self.assertEqual(DomainRequest.objects.filter(portfolio=self.unused_portfolio_with_related_objects).count(), 0)
self.assertEqual(DomainInformation.objects.filter(portfolio=None).count(), 2)
self.assertEqual(DomainRequest.objects.filter(portfolio=None).count(), 1)
# Check that the portfolio was deleted
self.assertFalse(Portfolio.objects.filter(organization_name="Test with orphaned objects").exists())
@patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no")
def test_delete_entries_with_suborganizations(self, mock_query_yes_no):
"""Test that suborganizations and their related objects are deleted along with the portfolio."""
mock_query_yes_no.return_value = True
# Ensure suborganization and related objects exist before running the command
self.assertEqual(Suborganization.objects.count(), 1)
self.assertEqual(DomainInformation.objects.filter(sub_organization=self.suborganization).count(), 1)
# Run the command
call_command("remove_unused_portfolios", debug=False)
# Check that the suborganization was deleted
self.assertEqual(Suborganization.objects.filter(portfolio=self.unused_portfolio_with_suborgs).count(), 0)
# Check that deletion of suborganization had cascading effects (orphaned DomainInformation)
self.assertEqual(DomainInformation.objects.filter(sub_organization=self.suborganization).count(), 0)
# Check that the portfolio was deleted
self.assertFalse(Portfolio.objects.filter(organization_name="Test with suborg").exists())

View file

@ -2073,13 +2073,18 @@ class TestPortfolio(TestCase):
self.user, _ = User.objects.get_or_create(
username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World"
)
self.non_federal_agency, _ = FederalAgency.objects.get_or_create(agency="Non-Federal Agency")
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="Federal Agency")
super().setUp()
def tearDown(self):
super().tearDown()
Portfolio.objects.all().delete()
self.federal_agency.delete()
# not deleting non_federal_agency so as not to interfere potentially with other tests
User.objects.all().delete()
@less_console_noise_decorator
def test_urbanization_field_resets_when_not_puetro_rico(self):
"""The urbanization field should only be populated when the state is puetro rico.
Otherwise, this field should be empty."""
@ -2100,6 +2105,7 @@ class TestPortfolio(TestCase):
self.assertEqual(portfolio.urbanization, None)
self.assertEqual(portfolio.state_territory, DomainRequest.StateTerritoryChoices.ALABAMA)
@less_console_noise_decorator
def test_can_add_urbanization_field(self):
"""Ensures that you can populate the urbanization field when conditions are right"""
# Create a portfolio that cannot have this field
@ -2121,6 +2127,32 @@ class TestPortfolio(TestCase):
self.assertEqual(portfolio.urbanization, "test123")
self.assertEqual(portfolio.state_territory, DomainRequest.StateTerritoryChoices.PUERTO_RICO)
@less_console_noise_decorator
def test_organization_name_updates_for_federal_agency(self):
# Create a Portfolio instance with a federal agency
portfolio = Portfolio(
creator=self.user,
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.federal_agency,
)
portfolio.save()
# Assert that organization_name is updated to the federal agency's name
self.assertEqual(portfolio.organization_name, "Federal Agency")
@less_console_noise_decorator
def test_organization_name_does_not_update_for_non_federal_agency(self):
# Create a Portfolio instance with a non-federal agency
portfolio = Portfolio(
creator=self.user,
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
federal_agency=self.non_federal_agency,
)
portfolio.save()
# Assert that organization_name remains None
self.assertIsNone(portfolio.organization_name)
class TestAllowedEmail(TestCase):
"""Tests our allowed email whitelist"""

View file

@ -16,7 +16,9 @@ from registrar.models import (
AllowedEmail,
Portfolio,
Suborganization,
UserPortfolioPermission,
)
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
import boto3_mocking
from registrar.utility.constants import BranchChoices
@ -46,6 +48,14 @@ class TestDomainRequest(TestCase):
self.dummy_user_2, _ = User.objects.get_or_create(
username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World"
)
self.dummy_user_3, _ = User.objects.get_or_create(
username="portfolioadmin@igorville.com",
email="portfolioadmin@igorville.com",
first_name="Portfolio",
last_name="Admin",
)
self.started_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
@ -273,7 +283,14 @@ class TestDomainRequest(TestCase):
self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED)
def check_email_sent(
self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
self,
domain_request,
msg,
action,
expected_count,
expected_content=None,
expected_email="mayor@igorville.com",
expected_cc=[],
):
"""Check if an email was sent after performing an action."""
email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)
@ -292,6 +309,11 @@ class TestDomainRequest(TestCase):
]
self.assertEqual(len(sent_emails), expected_count)
if expected_cc:
sent_cc_adddresses = sent_emails[0]["kwargs"]["Destination"]["CcAddresses"]
for cc_address in expected_cc:
self.assertIn(cc_address, sent_cc_adddresses)
if expected_content:
email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn(expected_content, email_content)
@ -1074,6 +1096,36 @@ class TestDomainRequest(TestCase):
self.assertEqual(domain_request2.generic_org_type, domain_request2.converted_generic_org_type)
self.assertEqual(domain_request2.federal_agency, domain_request2.converted_federal_agency)
@less_console_noise_decorator
def test_portfolio_domain_requests_cc_requests_viewers(self):
"""test that portfolio domain request emails cc portfolio members who have read requests access"""
fed_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first()
portfolio = Portfolio.objects.create(
organization_name="Test Portfolio",
creator=self.dummy_user_2,
federal_agency=fed_agency,
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
)
user_portfolio_permission = UserPortfolioPermission.objects.create( # noqa: F841
user=self.dummy_user_3, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Adds cc'ed email in this test's allow list
AllowedEmail.objects.create(email="portfolioadmin@igorville.com")
msg = "Create a domain request and submit it and see if email cc's portfolio admin and members who can view \
requests."
domain_request = completed_domain_request(
name="test.gov", user=self.dummy_user_2, portfolio=portfolio, organization_name="Test Portfolio"
)
self.check_email_sent(
domain_request,
msg,
"submit",
1,
expected_email="intern@igorville.com",
expected_cc=["portfolioadmin@igorville.com"],
)
class TestDomainRequestSuborganization(TestCase):
"""Tests for the suborganization fields on domain requests"""

View file

@ -255,10 +255,10 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
"Organization name,City,State,SO,SO email,"
"Security contact email,Domain managers,Invited domain managers\n"
"adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,"
"Portfolio 1 Federal Agency,,,, ,,(blank),"
"Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,,, ,,(blank),"
"meoward@rocks.com,squeaker@rocks.com\n"
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,"
"Portfolio 1 Federal Agency,,,, ,,(blank),"
"Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,,, ,,(blank),"
'"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n'
"cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,"
"World War I Centennial Commission,,,, ,,(blank),"
@ -280,6 +280,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator
@ -316,9 +317,11 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
expected_content = (
"Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,"
"City,State,SO,SO email,Security contact email,Domain managers,Invited domain managers\n"
"adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank),"
"adomain2.gov,Dns needed,(blank),(blank),Federal - Executive,Portfolio 1 Federal Agency,"
"Portfolio 1 Federal Agency,,, ,,(blank),"
'"info@example.com, meoward@rocks.com",squeaker@rocks.com\n'
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,,,, ,,(blank),"
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,Portfolio 1 Federal Agency,"
"Portfolio 1 Federal Agency,,, ,,(blank),"
'"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n'
)
@ -326,6 +329,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator
@ -587,7 +591,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
expected_content = (
"Domain name,Domain type,Agency,Organization name,City,"
"State,Status,Expiration date, Deleted\n"
"cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Ready,(blank)\n"
"cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Portfolio1FederalAgency,Ready,(blank)\n"
"adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n"
"cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n"
"zdomain12.gov,Interstate,Ready,(blank)\n"
@ -601,6 +605,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
)
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator
@ -780,9 +785,11 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
"city5.gov,Approved,Federal,No,Executive,,Testorg,N/A,,NY,2,requested_suborg,SanFran,CA,,,,,1,0,"
"city1.gov,Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,There is more,"
"Testy Tester testy2@town.com,,city.com,\n"
"city2.gov,In review,Federal,Yes,Executive,Portfolio 1 Federal Agency,,N/A,,,2,SubOrg 1,,,,,,,0,"
"1,city1.gov,,,,,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"city3.gov,Submitted,Federal,Yes,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,,,,,0,1,"
"city2.gov,In review,Federal,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,"
"N/A,,,2,SubOrg 1,,,,,,,0,1,city1.gov,,,,,Purpose of the site,There is more,"
"Testy Tester testy2@town.com,,city.com,\n"
"city3.gov,Submitted,Federal,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,"
"N/A,,,2,,,,,,,,0,1,"
'"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | '
'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, '
'Testy Tester testy2@town.com",'
@ -792,9 +799,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
"Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
"Testy Tester testy2@town.com,"
"cisaRep@igorville.gov,city.com,\n"
"city6.gov,Submitted,Federal,Yes,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,,,,,0,1,city1.gov,"
",,,,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com,"
"cisaRep@igorville.gov,city.com,\n"
"city6.gov,Submitted,Federal,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,N/A,"
",,2,,,,,,,,0,1,city1.gov,,,,,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
"Testy Tester testy2@town.com,cisaRep@igorville.gov,city.com,\n"
)
# Normalize line endings and remove commas,

View file

@ -741,6 +741,7 @@ class TestDomainManagers(TestDomainOverview):
"""Ensure that the user has its original permissions"""
PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
UserDomainRole.objects.all().delete()
User.objects.exclude(id=self.user.id).delete()
super().tearDown()
@ -1278,8 +1279,8 @@ class TestDomainManagers(TestDomainOverview):
response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True)
# Assert that an error message is displayed to the user
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
# Assert that the Cancel link is not displayed
self.assertNotContains(response, "Cancel")
# Assert that the Cancel link (form) is not displayed
self.assertNotContains(response, f"/invitation/{invitation.id}/cancel")
# Assert that the DomainInvitation is not deleted
self.assertTrue(DomainInvitation.objects.filter(id=invitation.id).exists())
DomainInvitation.objects.filter(email=email_address).delete()
@ -1337,6 +1338,57 @@ class TestDomainManagers(TestDomainOverview):
home_page = self.app.get(reverse("home"))
self.assertContains(home_page, self.domain.name)
@less_console_noise_decorator
def test_domain_user_role_delete(self):
"""Posting to the delete view deletes a user domain role."""
# add two managers to the domain so that one can be successfully deleted
email_address = "mayor@igorville.gov"
new_user = User.objects.create(email=email_address, username="mayor")
email_address_2 = "secondmayor@igorville.gov"
new_user_2 = User.objects.create(email=email_address_2, username="secondmayor")
user_domain_role = UserDomainRole.objects.create(
user=new_user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
)
UserDomainRole.objects.create(user=new_user_2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": new_user.id}), follow=True
)
# Assert that a success message is displayed to the user
self.assertContains(response, f"Removed {email_address} as a manager for this domain.")
# Assert that the second user is displayed
self.assertContains(response, f"{email_address_2}")
# Assert that the UserDomainRole is deleted
self.assertFalse(UserDomainRole.objects.filter(id=user_domain_role.id).exists())
@less_console_noise_decorator
def test_domain_user_role_delete_only_manager(self):
"""Posting to the delete view attempts to delete a user domain role when there is only one manager."""
# self.user is the only domain manager, so attempt to delete it
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}), follow=True
)
# Assert that an error message is displayed to the user
self.assertContains(response, "Domains must have at least one domain manager.")
# Assert that the user is still displayed
self.assertContains(response, f"{self.user.email}")
# Assert that the UserDomainRole still exists
self.assertTrue(UserDomainRole.objects.filter(user=self.user, domain=self.domain).exists())
@less_console_noise_decorator
def test_domain_user_role_delete_self_delete(self):
"""Posting to the delete view attempts to delete a user domain role when there is only one manager."""
# add one manager, so there are two and the logged in user, self.user, can be deleted
email_address = "mayor@igorville.gov"
new_user = User.objects.create(email=email_address, username="mayor")
UserDomainRole.objects.create(user=new_user, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}), follow=True
)
# Assert that a success message is displayed to the user
self.assertContains(response, f"You are no longer managing the domain {self.domain}.")
# Assert that the UserDomainRole no longer exists
self.assertFalse(UserDomainRole.objects.filter(user=self.user, domain=self.domain).exists())
class TestDomainNameservers(TestDomainOverview, MockEppLib):
@less_console_noise_decorator

View file

@ -1468,7 +1468,9 @@ class TestPortfolio(WebTest):
# Create a member under same portfolio
member_email = "a_member@example.com"
member, _ = User.objects.get_or_create(username="a_member", email=member_email)
member, _ = User.objects.get_or_create(
username="a_member", email=member_email, first_name="First", last_name="Last"
)
upp, _ = UserPortfolioPermission.objects.get_or_create(
user=member,
@ -1485,7 +1487,8 @@ class TestPortfolio(WebTest):
self.assertEqual(response.status_code, 200)
# Check for email AND member type (which here is just member)
self.assertContains(response, f'data-member-name="{member_email}"')
self.assertContains(response, f'data-member-email="{member_email}"')
self.assertContains(response, 'data-member-name="First Last"')
self.assertContains(response, 'data-member-type="member"')
@less_console_noise_decorator
@ -1676,8 +1679,9 @@ class TestPortfolio(WebTest):
self.assertEqual(response.status_code, 400) # Bad request due to active requests
support_url = "https://get.gov/contact/"
expected_error_message = (
f"This member has an active domain request and can't be removed from the organization. "
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
"This member can't be removed from the organization because they have an active domain request. "
f"Please <a class='usa-link' href='{support_url}' target='_blank'>contact us</a> "
"to remove this member."
)
self.assertContains(response, expected_error_message, status_code=400)
@ -1799,8 +1803,9 @@ class TestPortfolio(WebTest):
support_url = "https://get.gov/contact/"
expected_error_message = (
f"This member has an active domain request and can't be removed from the organization. "
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
"This member can't be removed from the organization because they have an active domain request. "
f"Please <a class='usa-link' href='{support_url}' target='_blank'>contact us</a> "
"to remove this member."
)
args, kwargs = mock_error.call_args

View file

@ -36,7 +36,7 @@ def send_templated_email( # noqa
to_address and bcc_address currently only support single addresses.
cc_address is a list and can contain many addresses. Emails not in the
cc_addresses is a list and can contain many addresses. Emails not in the
whitelist (if applicable) will be filtered out before sending.
template_name and subject_template_name are relative to the same template

View file

@ -1102,9 +1102,6 @@ class DomainUsersView(DomainBaseView):
"""The initial value for the form (which is a formset here)."""
context = super().get_context_data(**kwargs)
# Add conditionals to the context (such as "can_delete_users")
context = self._add_booleans_to_context(context)
# Get portfolio from session (if set)
portfolio = self.request.session.get("portfolio")
@ -1190,20 +1187,6 @@ class DomainUsersView(DomainBaseView):
return context
def _add_booleans_to_context(self, context):
# Determine if the current user can delete managers
domain_pk = None
can_delete_users = False
if self.kwargs is not None and "pk" in self.kwargs:
domain_pk = self.kwargs["pk"]
# Prevent the end user from deleting themselves as a manager if they are the
# only manager that exists on a domain.
can_delete_users = UserDomainRole.objects.filter(domain__id=domain_pk).count() > 1
context["can_delete_users"] = can_delete_users
return context
class DomainAddUserView(DomainFormBaseView):
"""Inside of a domain's user management, a form for adding users.
@ -1338,7 +1321,7 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
"""Refreshes the page after a delete is successful"""
return reverse("domain-users", kwargs={"pk": self.object.domain.id})
def get_success_message(self, delete_self=False):
def get_success_message(self):
"""Returns confirmation content for the deletion event"""
# Grab the text representation of the user we want to delete
@ -1348,7 +1331,7 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
# If the user is deleting themselves, return a specific message.
# If not, return something more generic.
if delete_self:
if self.delete_self:
message = f"You are no longer managing the domain {self.object.domain}."
else:
message = f"Removed {email_or_name} as a manager for this domain."
@ -1361,20 +1344,35 @@ class DomainDeleteUserView(UserDomainRolePermissionDeleteView):
# Delete the object
super().form_valid(form)
# Is the user deleting themselves? If so, display a different message
delete_self = self.request.user == self.object.user
# Add a success message
messages.success(self.request, self.get_success_message(delete_self))
messages.success(self.request, self.get_success_message())
return redirect(self.get_success_url())
def post(self, request, *args, **kwargs):
"""Custom post implementation to redirect to home in the event that the user deletes themselves"""
"""Custom post implementation to ensure last userdomainrole is not removed and to
redirect to home in the event that the user deletes themselves"""
self.object = self.get_object() # Retrieve the UserDomainRole to delete
# Is the user deleting themselves?
self.delete_self = self.request.user == self.object.user
# Check if this is the only UserDomainRole for the domain
if not len(UserDomainRole.objects.filter(domain=self.object.domain)) > 1:
if self.delete_self:
messages.error(
request,
"Domains must have at least one domain manager. "
"To remove yourself, the domain needs another domain manager.",
)
else:
messages.error(request, "Domains must have at least one domain manager.")
return redirect(self.get_success_url())
# normal delete processing in the event that the above condition not reached
response = super().post(request, *args, **kwargs)
# If the user is deleting themselves, redirect to home
delete_self = self.request.user == self.object.user
if delete_self:
if self.delete_self:
return redirect(reverse("home"))
return response

View file

@ -118,8 +118,8 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
if active_requests_count > 0:
# If they have any in progress requests
error_message = mark_safe( # nosec
f"This member has an active domain request and can't be removed from the organization. "
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
"This member can't be removed from the organization because they have an active domain request. "
f"Please <a class='usa-link' href='{support_url}' target='_blank'>contact us</a> to remove this member."
)
elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio):
# If they are the last manager of a domain

View file

@ -344,12 +344,6 @@ class UserDeleteDomainRolePermission(PermissionsLoginMixin):
if not (has_delete_permission or user_is_analyst_or_superuser):
return False
# Check if more than one manager exists on the domain.
# If only one exists, prevent this from happening
has_multiple_managers = len(UserDomainRole.objects.filter(domain=domain_pk)) > 1
if not has_multiple_managers:
return False
return True