diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 642e9dc30..6332956f8 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -3,10 +3,6 @@ name: Testing
on:
push:
- paths-ignore:
- - 'docs/**'
- - '**.md'
- - '.gitignore'
branches:
- main
pull_request:
diff --git a/docs/developer/cloning-databases.md b/docs/developer/cloning-databases.md
new file mode 100644
index 000000000..3c8a3c3fa
--- /dev/null
+++ b/docs/developer/cloning-databases.md
@@ -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.
diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md
index 499c0840f..cdef3dba7 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -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```
\ No newline at end of file
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index e89147b11..8ecf36f52 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -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
diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js
index cfb83badc..d3422b722 100644
--- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js
+++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js
@@ -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();
diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js
index 338d5d98c..0abfee9b6 100644
--- a/src/registrar/assets/src/js/getgov/table-base.js
+++ b/src/registrar/assets/src/js/getgov/table-base.js
@@ -93,7 +93,6 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
` : ''}
${modal_button_text}
- ${screen_reader_text}
`;
@@ -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}"
>