diff --git a/.github/workflows/deploy-manual.yaml b/.github/workflows/deploy-manual.yaml
index ba85342b0..a85cc7565 100644
--- a/.github/workflows/deploy-manual.yaml
+++ b/.github/workflows/deploy-manual.yaml
@@ -14,6 +14,7 @@ on:
options:
- ab
- backup
+ - el
- cb
- dk
- es
diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml
index fe0a19089..e9eb06627 100644
--- a/.github/workflows/deploy-sandbox.yaml
+++ b/.github/workflows/deploy-sandbox.yaml
@@ -30,6 +30,7 @@ jobs:
|| startsWith(github.head_ref, 'ag/')
|| startsWith(github.head_ref, 'ms/')
|| startsWith(github.head_ref, 'ad/')
+ || startsWith(github.head_ref, 'el/')
outputs:
environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest"
diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml
index 70ff8ee95..1853b3c4f 100644
--- a/.github/workflows/migrate.yaml
+++ b/.github/workflows/migrate.yaml
@@ -16,6 +16,7 @@ on:
- stable
- staging
- development
+ - el
- ad
- ms
- ag
diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml
index b6fa0fec5..111555b3c 100644
--- a/.github/workflows/reset-db.yaml
+++ b/.github/workflows/reset-db.yaml
@@ -16,6 +16,7 @@ on:
options:
- staging
- development
+ - el
- ad
- ms
- ag
diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md
index 5e1aa688a..a234d882b 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -754,7 +754,7 @@ Example: `cf ssh getgov-za`
| 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. |
## Populate federal agency initials and FCEB
-This script adds to the "is_fceb" and "initials" fields on the FederalAgency model. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070).
+This script adds to the "is_fceb" and "acronym" fields on the FederalAgency model. This script expects a CSV of federal CIOs to pull from, which can be sourced from [here](https://docs.google.com/spreadsheets/d/14oXHFpKyUXS5_mDWARPusghGdHCrP67jCleOknaSx38/edit?gid=479328070#gid=479328070).
### Running on sandboxes
diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md
index b7fd50d52..224fe9a5f 100644
--- a/docs/operations/import_export.md
+++ b/docs/operations/import_export.md
@@ -9,17 +9,16 @@ Simple scripts are provided as detailed below.
### Export
To export from the source environment, run the following command from src directory:
-manage.py export_tables
Connect to the source sandbox and run the command:
-cf ssh {source-app}
-/tmp/lifecycle/shell
-./manage.py export_tables
+`cf ssh {source-app}`
+`/tmp/lifecycle/shell`
+`./manage.py export_tables`
example exporting from getgov-stable:
-cf ssh getgov-stable
-/tmp/lifecycle/shell
-./manage.py export_tables
+`cf ssh getgov-stable`
+`/tmp/lifecycle/shell`
+`./manage.py export_tables`
This exports a file, exported_tables.zip, to the tmp directory
@@ -42,14 +41,16 @@ After exporting the file from the target environment, scp the exported_tables.zi
file from the target environment to local. Run the below commands from local.
Get passcode by running:
-cf ssh-code
+`cf ssh-code`
scp file from source app to local file:
-scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {source-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip {local_file_path}
+`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {source-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip {local_file_path}`
when prompted, supply the passcode retrieved in the 'cf ssh-code' command
example copying from stable to local cwd:
-scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip .
+`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip .`
+
+`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip .`
### Import
@@ -63,14 +64,14 @@ that there are no database conflicts on import.
In order to delete all rows from the appropriate tables, run the following
command:
-cf ssh {target-app}
-/tmp/lifecycle/shell
-./manage.py clean_tables
+`cf ssh {target-app}`
+`/tmp/lifecycle/shell`
+`./manage.py clean_tables`
example cleaning getgov-backup:
-cf ssh getgov-backup
-/tmp/lifecycle/backup
-./manage.py clean_tables
+`cf ssh getgov-backup`
+`/tmp/lifecycle/shell`
+`./manage.py clean_tables`
For reference, this deletes all rows from the following tables:
@@ -96,28 +97,30 @@ with --skipEppSave option set to False. If you set to False, it will attempt to
records to the registry on load. If this is unset, or set to True, it will load the database and not
attempt to update the registry on load.
+Please note that there is currently a bug (missing batch importing, see #2862) so this may not work
+smoothly right now currently.
+
To scp the exported_tables.zip file from local to the sandbox, run the following:
Get passcode by running:
-cf ssh-code
+`cf ssh-code`
scp file from local to target app:
-scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {target-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 {local_file_path} ssh.fr.cloud.gov:app/tmp/exported_tables.zip
+`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {target-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 {local_file_path} ssh.fr.cloud.gov:app/tmp/exported_tables.zip`
when prompted, supply the passcode retrieved in the 'cf ssh-code' command
example copy of local file in tmp to getgov-backup:
-scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-backup --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 tmp/exported_tables.zip ssh.fr.cloud.gov:app/tmp/exported_tables.zip
-
+`scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-backup --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 exported_tables.zip ssh.fr.cloud.gov:app/tmp/exported_tables.zip`
Then connect to a shell in the target environment, and run the following import command:
-cf ssh {target-app}
-/tmp/lifecycle/shell
-./manage.py import_tables
+`cf ssh {target-app}`
+`/tmp/lifecycle/shell`
+`./manage.py import_tables`
example cleaning getgov-backup:
-cf ssh getgov-backup
-/tmp/lifecycle/backup
-./manage.py import_tables --no-skipEppSave
+`cf ssh getgov-backup`
+`/tmp/lifecycle/shell`
+`./manage.py import_tables --no-skipEppSave`
For reference, this imports tables in the following order:
diff --git a/ops/manifests/manifest-el.yaml b/ops/manifests/manifest-el.yaml
new file mode 100644
index 000000000..4c7d4d4e4
--- /dev/null
+++ b/ops/manifests/manifest-el.yaml
@@ -0,0 +1,32 @@
+---
+applications:
+- name: getgov-el
+ buildpacks:
+ - python_buildpack
+ path: ../../src
+ instances: 1
+ memory: 512M
+ stack: cflinuxfs4
+ timeout: 180
+ command: ./run.sh
+ health-check-type: http
+ health-check-http-endpoint: /health
+ health-check-invocation-timeout: 40
+ env:
+ # Send stdout and stderr straight to the terminal without buffering
+ PYTHONUNBUFFERED: yup
+ # Tell Django where to find its configuration
+ DJANGO_SETTINGS_MODULE: registrar.config.settings
+ # Tell Django where it is being hosted
+ DJANGO_BASE_URL: https://getgov-el.app.cloud.gov
+ # Tell Django how much stuff to log
+ DJANGO_LOG_LEVEL: INFO
+ # default public site location
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
+ # Flag to disable/enable features in prod environments
+ IS_PRODUCTION: False
+ routes:
+ - route: getgov-el.app.cloud.gov
+ services:
+ - getgov-credentials
+ - getgov-el-database
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 0875fd6fd..eb99fc6e2 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -8,9 +8,7 @@ from django.http import HttpResponseRedirect
from django.conf import settings
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
-from registrar.models.domain_information import DomainInformation
-from registrar.models.domain_invitation import DomainInvitation
-from registrar.models.user_portfolio_permission import UserPortfolioPermission
+from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
@@ -22,7 +20,11 @@ from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch
-from registrar.utility.admin_helpers import get_all_action_needed_reason_emails, get_action_needed_reason_default_email
+from registrar.utility.admin_helpers import (
+ get_all_action_needed_reason_emails,
+ get_action_needed_reason_default_email,
+ get_field_links_as_list,
+)
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
@@ -757,9 +759,10 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
+ ("Associated portfolios", {"fields": ("portfolios",)}),
)
- readonly_fields = ("verification_type",)
+ readonly_fields = ("verification_type", "portfolios")
analyst_fieldsets = (
(
@@ -782,6 +785,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
},
),
("Important dates", {"fields": ("last_login", "date_joined")}),
+ ("Associated portfolios", {"fields": ("portfolios",)}),
)
# TODO: delete after we merge organization feature
@@ -861,6 +865,14 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
ordering = ["first_name", "last_name", "email"]
search_help_text = "Search by first name, last name, or email."
+ def portfolios(self, obj: models.User):
+ """Returns a list of links for each related suborg"""
+ portfolio_ids = obj.get_portfolios().values_list("portfolio", flat=True)
+ queryset = models.Portfolio.objects.filter(id__in=portfolio_ids)
+ return get_field_links_as_list(queryset, "portfolio", msg_for_none="No portfolios.")
+
+ portfolios.short_description = "Portfolios" # type: ignore
+
def get_search_results(self, request, queryset, search_term):
"""
Override for get_search_results. This affects any upstream model using autocomplete_fields,
@@ -1257,9 +1269,18 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
list_display = [
"user",
"portfolio",
+ "get_roles",
]
autocomplete_fields = ["user", "portfolio"]
+ search_fields = ["user__first_name", "user__last_name", "user__email", "portfolio__organization_name"]
+ search_help_text = "Search by first name, last name, email, or portfolio."
+
+ def get_roles(self, obj):
+ readable_roles = obj.get_readable_roles()
+ return ", ".join(readable_roles)
+
+ get_roles.short_description = "Roles" # type: ignore
class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
@@ -1543,33 +1564,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_form_template = "django/admin/domain_information_change_form.html"
- superuser_only_fields = [
- "portfolio",
- "sub_organization",
- ]
-
- # DEVELOPER's NOTE:
- # Normally, to exclude a field from an Admin form, we could simply utilize
- # Django's "exclude" feature. However, it causes a "missing key" error if we
- # go that route for this particular form. The error gets thrown by our
- # custom fieldset.html code and is due to the fact that "exclude" removes
- # fields from base_fields but not fieldsets. Rather than reworking our
- # custom frontend, it seems more straightforward (and easier to read) to simply
- # modify the fieldsets list so that it excludes any fields we want to remove
- # based on permissions (eg. superuser_only_fields) or other conditions.
- def get_fieldsets(self, request, obj=None):
- fieldsets = self.fieldsets
-
- # Create a modified version of fieldsets to exclude certain fields
- if not request.user.has_perm("registrar.full_access_permission"):
- modified_fieldsets = []
- for name, data in fieldsets:
- fields = data.get("fields", [])
- fields = [field for field in fields if field not in DomainInformationAdmin.superuser_only_fields]
- modified_fieldsets.append((name, {**data, "fields": fields}))
- return modified_fieldsets
- return fieldsets
-
def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements.
We have 1 conditions that determine which fields are read-only:
@@ -1865,33 +1859,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
- superuser_only_fields = [
- "portfolio",
- "sub_organization",
- ]
-
- # DEVELOPER's NOTE:
- # Normally, to exclude a field from an Admin form, we could simply utilize
- # Django's "exclude" feature. However, it causes a "missing key" error if we
- # go that route for this particular form. The error gets thrown by our
- # custom fieldset.html code and is due to the fact that "exclude" removes
- # fields from base_fields but not fieldsets. Rather than reworking our
- # custom frontend, it seems more straightforward (and easier to read) to simply
- # modify the fieldsets list so that it excludes any fields we want to remove
- # based on permissions (eg. superuser_only_fields) or other conditions.
- def get_fieldsets(self, request, obj=None):
- fieldsets = super().get_fieldsets(request, obj)
-
- # Create a modified version of fieldsets to exclude certain fields
- if not request.user.has_perm("registrar.full_access_permission"):
- modified_fieldsets = []
- for name, data in fieldsets:
- fields = data.get("fields", [])
- fields = tuple(field for field in fields if field not in self.superuser_only_fields)
- modified_fieldsets.append((name, {**data, "fields": fields}))
- return modified_fieldsets
- return fieldsets
-
# Table ordering
# NOTE: This impacts the select2 dropdowns (combobox)
# Currentl, there's only one for requests on DomainInfo
@@ -2009,18 +1976,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# If the status is not mapped properly, saving could cause
# weird issues down the line. Instead, we should block this.
+ # NEEDS A UNIT TEST
should_proceed = False
- return should_proceed
+ return (obj, should_proceed)
- request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
- if request_is_not_approved and not obj.domain_is_not_active():
- # If an admin tried to set an approved domain request to
- # another status and the related domain is already
- # active, shortcut the action and throw a friendly
- # error message. This action would still not go through
- # shortcut or not as the rules are duplicated on the model,
- # but the error would be an ugly Django error screen.
+ obj_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
+ if obj_is_not_approved and not obj.domain_is_not_active():
+ # REDUNDANT CHECK / ERROR SCREEN AVOIDANCE:
+ # This action (moving a request from approved to
+ # another status) when the domain is already active (READY),
+ # would still not go through even without this check as the rules are
+ # duplicated in the model and the error is raised from the model.
+ # This avoids an ugly Django error screen.
error_message = "This action is not permitted. The domain is already active."
+ elif (
+ original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED
+ and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED
+ and original_obj.requested_domain is not None
+ and Domain.objects.filter(name=original_obj.requested_domain.name).exists()
+ ):
+ # REDUNDANT CHECK:
+ # This action (approving a request when the domain exists)
+ # would still not go through even without this check as the rules are
+ # duplicated in the model and the error is raised from the model.
+ error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.APPROVE_DOMAIN_IN_USE)
elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason:
# This condition should never be triggered.
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
@@ -3006,39 +2985,59 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
class PortfolioAdmin(ListHeaderAdmin):
+
+ class Meta:
+ """Contains meta information about this class"""
+
+ model = models.Portfolio
+ fields = "__all__"
+
+ _meta = Meta()
+
change_form_template = "django/admin/portfolio_change_form.html"
fieldsets = [
- # created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
- (None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
- ("Portfolio members", {"fields": ["display_admins", "display_members"]}),
- ("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
+ # created_on is the created_at field
+ (None, {"fields": ["creator", "created_on", "notes"]}),
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
(
"Organization name and mailing address",
{
"fields": [
+ "organization_name",
"federal_agency",
+ ]
+ },
+ ),
+ (
+ "Show details",
+ {
+ "classes": ["collapse--dgfieldset"],
+ "description": "Extends organization name and mailing address",
+ "fields": [
"state_territory",
"address_line1",
"address_line2",
"city",
"zipcode",
"urbanization",
- ]
+ ],
},
),
+ ("Portfolio members", {"fields": ["display_admins", "display_members"]}),
+ ("Domains and requests", {"fields": ["domains", "domain_requests"]}),
("Suborganizations", {"fields": ["suborganizations"]}),
("Senior official", {"fields": ["senior_official"]}),
]
# This is the fieldset display when adding a new model
add_fieldsets = [
- (None, {"fields": ["organization_name", "creator", "notes"]}),
+ (None, {"fields": ["creator", "notes"]}),
("Type of organization", {"fields": ["organization_type"]}),
(
"Organization name and mailing address",
{
"fields": [
+ "organization_name",
"federal_agency",
"state_territory",
"address_line1",
@@ -3052,7 +3051,7 @@ class PortfolioAdmin(ListHeaderAdmin):
("Senior official", {"fields": ["senior_official"]}),
]
- list_display = ("organization_name", "federal_agency", "creator")
+ list_display = ("organization_name", "organization_type", "federal_type", "creator")
search_fields = ["organization_name"]
search_help_text = "Search by organization name."
readonly_fields = [
@@ -3065,23 +3064,35 @@ class PortfolioAdmin(ListHeaderAdmin):
"domains",
"domain_requests",
"suborganizations",
- "portfolio_type",
"display_admins",
"display_members",
"creator",
+ # As of now this means that only federal agency can update this, but this will change.
+ "senior_official",
+ ]
+
+ analyst_readonly_fields = [
+ "organization_name",
]
def get_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio
- admin_permissions = UserPortfolioPermission.objects.filter(
- portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
- )
+ admin_permissions = self.get_user_portfolio_permission_admins(obj)
# Get the user objects associated with these permissions
admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions)
return admin_users
+ def get_user_portfolio_permission_admins(self, obj):
+ """Returns each admin on UserPortfolioPermission for a given portfolio."""
+ if obj:
+ return obj.portfolio_users.filter(
+ portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ )
+ else:
+ return []
+
def get_non_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude(
@@ -3170,6 +3181,13 @@ class PortfolioAdmin(ListHeaderAdmin):
return self.get_field_links_as_list(members, "user", separator=", ")
+ def get_user_portfolio_permission_non_admins(self, obj):
+ """Returns each admin on UserPortfolioPermission for a given portfolio."""
+ if obj:
+ return obj.portfolio_users.exclude(roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
+ else:
+ return []
+
def federal_type(self, obj: models.Portfolio):
"""Returns the federal_type field"""
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
@@ -3183,16 +3201,10 @@ class PortfolioAdmin(ListHeaderAdmin):
created_on.short_description = "Created on" # type: ignore
- def portfolio_type(self, obj: models.Portfolio):
- """Returns the portfolio type, or "-" if the result is empty"""
- return obj.portfolio_type if obj.portfolio_type else "-"
-
- portfolio_type.short_description = "Portfolio type" # type: ignore
-
def suborganizations(self, obj: models.Portfolio):
"""Returns a list of links for each related suborg"""
queryset = obj.get_suborganizations()
- return self.get_field_links_as_list(queryset, "suborganization")
+ return get_field_links_as_list(queryset, "suborganization")
suborganizations.short_description = "Suborganizations" # type: ignore
@@ -3221,6 +3233,28 @@ class PortfolioAdmin(ListHeaderAdmin):
domain_requests.short_description = "Domain requests" # type: ignore
+ def display_admins(self, obj):
+ """Returns the number of administrators for this portfolio"""
+ admin_count = len(self.get_user_portfolio_permission_admins(obj))
+ if admin_count > 0:
+ url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
+ # Create a clickable link with the count
+ return format_html(f'{admin_count} administrators ')
+ return "No administrators found."
+
+ display_admins.short_description = "Administrators" # type: ignore
+
+ def display_members(self, obj):
+ """Returns the number of members for this portfolio"""
+ member_count = len(self.get_user_portfolio_permission_non_admins(obj))
+ if member_count > 0:
+ url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
+ # Create a clickable link with the count
+ return format_html(f'{member_count} members ')
+ return "No additional members found."
+
+ display_members.short_description = "Members" # type: ignore
+
# Creates select2 fields (with search bars)
autocomplete_fields = [
"creator",
@@ -3228,59 +3262,6 @@ class PortfolioAdmin(ListHeaderAdmin):
"senior_official",
]
- def get_field_links_as_list(
- self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
- ):
- """
- Generate HTML links for items in a queryset, using a specified attribute for link text.
-
- Args:
- queryset: The queryset of items to generate links for.
- model_name: The model name used to construct the admin change URL.
- attribute_name: The attribute or method name to use for link text. If None, the item itself is used.
- link_info_attribute: Appends f"({value_of_attribute})" to the end of the link.
- separator: The separator to use between links in the resulting HTML.
- If none, an unordered list is returned.
-
- Returns:
- A formatted HTML string with links to the admin change pages for each item.
- """
- links = []
- for item in queryset:
-
- # This allows you to pass in attribute_name="get_full_name" for instance.
- if attribute_name:
- item_display_value = self.value_of_attribute(item, attribute_name)
- else:
- item_display_value = item
-
- if item_display_value:
- change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk])
-
- link = f'{escape(item_display_value)} '
- if link_info_attribute:
- link += f" ({self.value_of_attribute(item, link_info_attribute)})"
-
- if separator:
- links.append(link)
- else:
- links.append(f"
{link} ")
-
- # If no separator is specified, just return an unordered list.
- if separator:
- return format_html(separator.join(links)) if links else "-"
- else:
- links = "".join(links)
- return format_html(f'') if links else "-"
-
- def value_of_attribute(self, obj, attribute_name: str):
- """Returns the value of getattr if the attribute isn't callable.
- If it is, execute the underlying function and return that result instead."""
- value = getattr(obj, attribute_name)
- if callable(value):
- value = value()
- return value
-
def get_fieldsets(self, request, obj=None):
"""Override of the default get_fieldsets definition to add an add_fieldsets view"""
# This is the add view if no obj exists
@@ -3313,10 +3294,15 @@ class PortfolioAdmin(ListHeaderAdmin):
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add related suborganizations and domain groups.
Add the summary for the portfolio members field (list of members that link to change_forms)."""
- obj = self.get_object(request, object_id)
+ obj: Portfolio = self.get_object(request, object_id)
extra_context = extra_context or {}
extra_context["skip_additional_contact_info"] = True
- extra_context["display_members_summary"] = self.display_members_summary(obj)
+
+ if obj:
+ extra_context["members"] = self.get_user_portfolio_permission_non_admins(obj)
+ extra_context["admins"] = self.get_user_portfolio_permission_admins(obj)
+ extra_context["domains"] = obj.get_domains(order_by=["domain__name"])
+ extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"])
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):
@@ -3333,6 +3319,14 @@ class PortfolioAdmin(ListHeaderAdmin):
is_federal = obj.organization_type == DomainRequest.OrganizationChoices.FEDERAL
if is_federal and obj.organization_name is None:
obj.organization_name = obj.federal_agency.agency
+
+ # Remove this line when senior_official is no longer readonly in /admin.
+ if obj.federal_agency:
+ if obj.federal_agency.so_federal_agency.exists():
+ obj.senior_official = obj.federal_agency.so_federal_agency.first()
+ else:
+ obj.senior_official = None
+
super().save_model(request, obj, form, change)
@@ -3347,7 +3341,7 @@ class FederalAgencyResource(resources.ModelResource):
class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["agency"]
search_fields = ["agency"]
- search_help_text = "Search by agency name."
+ search_help_text = "Search by federal agency."
ordering = ["agency"]
resource_classes = [FederalAgencyResource]
@@ -3404,6 +3398,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"portfolio",
]
search_fields = ["name"]
+ search_help_text = "Search by suborganization."
change_form_template = "django/admin/suborg_change_form.html"
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index a0fe48420..f44211c6d 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -793,10 +793,15 @@ document.addEventListener('DOMContentLoaded', function() {
// $ symbolically denotes that this is using jQuery
let $federalAgency = django.jQuery("#id_federal_agency");
let organizationType = document.getElementById("id_organization_type");
- if ($federalAgency && organizationType) {
+ let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly");
+
+ let organizationNameContainer = document.querySelector(".field-organization_name");
+ let federalType = document.querySelector(".field-federal_type");
+
+ if ($federalAgency && (organizationType || readonlyOrganizationType)) {
// Attach the change event listener
$federalAgency.on("change", function() {
- handleFederalAgencyChange($federalAgency, organizationType);
+ handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType);
});
}
@@ -812,9 +817,33 @@ document.addEventListener('DOMContentLoaded', function() {
handleStateTerritoryChange(stateTerritory, urbanizationField);
});
}
+
+ // Handle hiding the organization name field when the organization_type is federal.
+ // Run this first one page load, then secondly on a change event.
+ handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
+ organizationType.addEventListener("change", function() {
+ handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
+ });
});
- function handleFederalAgencyChange(federalAgency, organizationType) {
+ function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) {
+ if (organizationType && organizationNameContainer) {
+ let selectedValue = organizationType.value;
+ if (selectedValue === "federal") {
+ hideElement(organizationNameContainer);
+ if (federalType) {
+ showElement(federalType);
+ }
+ } else {
+ showElement(organizationNameContainer);
+ if (federalType) {
+ hideElement(federalType);
+ }
+ }
+ }
+ }
+
+ function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) {
// Don't do anything on page load
if (isInitialPageLoad) {
isInitialPageLoad = false;
@@ -829,27 +858,31 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
+ let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase();
if (selectedText !== "Non-Federal Agency") {
- if (organizationType.value !== "federal") {
- organizationType.value = "federal";
+ if (organizationTypeValue !== "federal") {
+ if (organizationType){
+ organizationType.value = "federal";
+ }else {
+ readonlyOrganizationType.innerText = "Federal"
+ }
}
}else {
- if (organizationType.value === "federal") {
- organizationType.value = "";
+ if (organizationTypeValue === "federal") {
+ if (organizationType){
+ organizationType.value = "";
+ }else {
+ readonlyOrganizationType.innerText = "-"
+ }
}
}
- // Get the associated senior official with this federal agency
- let $seniorOfficial = django.jQuery("#id_senior_official");
- if (!$seniorOfficial) {
- console.log("Could not find the senior official field");
- return;
- }
+ handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
// Determine if any changes are necessary to the display of portfolio type or federal type
// based on changes to the Federal Agency
let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value;
- fetch(`${federalPortfolioApi}?organization_type=${organizationType.value}&agency_name=${selectedText}`)
+ fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`)
.then(response => {
const statusCode = response.status;
return response.json().then(data => ({ statusCode, data }));
@@ -860,7 +893,6 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
updateReadOnly(data.federal_type, '.field-federal_type');
- updateReadOnly(data.portfolio_type, '.field-portfolio_type');
})
.catch(error => console.error("Error fetching federal and portfolio types: ", error));
@@ -868,6 +900,9 @@ document.addEventListener('DOMContentLoaded', function() {
// If we can update the contact information, it'll be shown again.
hideElement(contactList.parentElement);
+ let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value;
+ let $seniorOfficial = django.jQuery("#id_senior_official");
+ let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly");
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
.then(response => {
@@ -878,7 +913,12 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.error) {
// Clear the field if the SO doesn't exist.
if (statusCode === 404) {
- $seniorOfficial.val("").trigger("change");
+ if ($seniorOfficial && $seniorOfficial.length > 0) {
+ $seniorOfficial.val("").trigger("change");
+ }else {
+ // Show the "create one now" text if this field is none in readonly mode.
+ readonlySeniorOfficial.innerHTML = `No senior official found. Create one now. `;
+ }
console.warn("Record not found: " + data.error);
}else {
console.error("Error in AJAX call: " + data.error);
@@ -889,30 +929,43 @@ document.addEventListener('DOMContentLoaded', function() {
// Update the "contact details" blurb beneath senior official
updateContactInfo(data);
showElement(contactList.parentElement);
-
+
+ // Get the associated senior official with this federal agency
let seniorOfficialId = data.id;
let seniorOfficialName = [data.first_name, data.last_name].join(" ");
- if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){
- // Clear the field if the SO doesn't exist
- $seniorOfficial.val("").trigger("change");
- return;
- }
-
- // Add the senior official to the dropdown.
- // This format supports select2 - if we decide to convert this field in the future.
- if ($seniorOfficial.find(`option[value='${seniorOfficialId}']`).length) {
- // Select the value that is associated with the current Senior Official.
- $seniorOfficial.val(seniorOfficialId).trigger("change");
- } else {
- // Create a DOM Option that matches the desired Senior Official. Then append it and select it.
- let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true);
- $seniorOfficial.append(userOption).trigger("change");
+ if ($seniorOfficial && $seniorOfficial.length > 0) {
+ // If the senior official is a dropdown field, edit that
+ updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName);
+ }else {
+ if (readonlySeniorOfficial) {
+ let seniorOfficialLink = `${seniorOfficialName} `
+ readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-";
+ }
}
})
.catch(error => console.error("Error fetching senior official: ", error));
}
+ function updateSeniorOfficialDropdown(dropdown, seniorOfficialId, seniorOfficialName) {
+ if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){
+ // Clear the field if the SO doesn't exist
+ dropdown.val("").trigger("change");
+ return;
+ }
+
+ // Add the senior official to the dropdown.
+ // This format supports select2 - if we decide to convert this field in the future.
+ if (dropdown.find(`option[value='${seniorOfficialId}']`).length) {
+ // Select the value that is associated with the current Senior Official.
+ dropdown.val(seniorOfficialId).trigger("change");
+ } else {
+ // Create a DOM Option that matches the desired Senior Official. Then append it and select it.
+ let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true);
+ dropdown.append(userOption).trigger("change");
+ }
+ }
+
function handleStateTerritoryChange(stateTerritory, urbanizationField) {
let selectedValue = stateTerritory.value;
if (selectedValue === "PR") {
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 027ef4344..8a07b3f27 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -1498,12 +1498,23 @@ class DomainsTable extends LoadTableBase {
}
}
-
class DomainRequestsTable extends LoadTableBase {
constructor() {
super('.domain-requests__table', '.domain-requests__table-wrapper', '#domain-requests__search-field', '#domain-requests__search-field-submit', '.domain-requests__reset-search', '.domain-requests__reset-filters', '.domain-requests__no-data', '.domain-requests__no-search-results');
}
+
+ toggleExportButton(requests) {
+ const exportButton = document.getElementById('export-csv');
+ if (exportButton) {
+ if (requests.length > 0) {
+ showElement(exportButton);
+ } else {
+ hideElement(exportButton);
+ }
+ }
+}
+
/**
* Loads rows in the domains list, as well as updates pagination around the domains list
* based on the supplied attributes.
@@ -1517,6 +1528,7 @@ class DomainRequestsTable extends LoadTableBase {
*/
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) {
let baseUrl = document.getElementById("get_domain_requests_json_url");
+
if (!baseUrl) {
return;
}
@@ -1548,6 +1560,9 @@ class DomainRequestsTable extends LoadTableBase {
return;
}
+ // Manage "export as CSV" visibility for domain requests
+ this.toggleExportButton(data.domain_requests);
+
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
@@ -1853,6 +1868,125 @@ class DomainRequestsTable extends LoadTableBase {
}
}
+class MembersTable extends LoadTableBase {
+
+ constructor() {
+ super('.members__table', '.members__table-wrapper', '#members__search-field', '#members__search-field-submit', '.members__reset-search', '.members__reset-filters', '.members__no-data', '.members__no-search-results');
+ }
+ /**
+ * Loads rows in the members list, as well as updates pagination around the members list
+ * based on the supplied attributes.
+ * @param {*} page - the page number of the results (starts with 1)
+ * @param {*} sortBy - the sort column option
+ * @param {*} order - the sort order {asc, desc}
+ * @param {*} scroll - control for the scrollToElement functionality
+ * @param {*} status - control for the status filter
+ * @param {*} searchTerm - the search term
+ * @param {*} portfolio - the portfolio id
+ */
+ loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
+
+ // --------- SEARCH
+ let searchParams = new URLSearchParams(
+ {
+ "page": page,
+ "sort_by": sortBy,
+ "order": order,
+ "status": status,
+ "search_term": searchTerm
+ }
+ );
+ if (portfolio)
+ searchParams.append("portfolio", portfolio)
+
+
+ // --------- FETCH DATA
+ // fetch json of page of domais, given params
+ let baseUrl = document.getElementById("get_members_json_url");
+ if (!baseUrl) {
+ return;
+ }
+
+ let baseUrlValue = baseUrl.innerHTML;
+ if (!baseUrlValue) {
+ return;
+ }
+
+ let url = `${baseUrlValue}?${searchParams.toString()}` //TODO: uncomment for search function
+ fetch(url)
+ .then(response => response.json())
+ .then(data => {
+ if (data.error) {
+ console.error('Error in AJAX call: ' + data.error);
+ return;
+ }
+
+ // handle the display of proper messaging in the event that no members exist in the list or search returns no results
+ this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
+
+ // identify the DOM element where the domain list will be inserted into the DOM
+ const memberList = document.querySelector('.members__table tbody');
+ memberList.innerHTML = '';
+
+ data.members.forEach(member => {
+ // const actionUrl = domain.action_url;
+ const member_name = member.name;
+ const member_email = member.email;
+ const last_active = member.last_active;
+ const action_url = member.action_url;
+ const action_label = member.action_label;
+ const svg_icon = member.svg_icon;
+
+ const row = document.createElement('tr');
+
+ let admin_tagHTML = ``;
+ if (member.is_admin)
+ admin_tagHTML = `Admin `
+
+ row.innerHTML = `
+
+ ${member_email ? member_email : member_name} ${admin_tagHTML}
+
+
+ ${last_active}
+
+
+
+
+
+
+ ${action_label} ${member_name}
+
+
+ `;
+ memberList.appendChild(row);
+ });
+
+ // Do not scroll on first page load
+ if (scroll)
+ ScrollToElement('class', 'members');
+ this.scrollToTable = true;
+
+ // update pagination
+ this.updatePagination(
+ 'member',
+ '#members-pagination',
+ '#members-pagination .usa-pagination__counter',
+ '#members',
+ data.page,
+ data.num_pages,
+ data.has_previous,
+ data.has_next,
+ data.total,
+ );
+ this.currentSortBy = sortBy;
+ this.currentOrder = order;
+ this.currentSearchTerm = searchTerm;
+ })
+ .catch(error => console.error('Error fetching members:', error));
+ }
+}
+
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
@@ -1926,6 +2060,23 @@ const utcDateString = (dateString) => {
};
+
+/**
+ * An IIFE that listens for DOM Content to be loaded, then executes. This function
+ * initializes the domains list and associated functionality on the home page of the app.
+ *
+ */
+document.addEventListener('DOMContentLoaded', function() {
+ const isMembersPage = document.querySelector("#members")
+ if (isMembersPage){
+ const membersTable = new MembersTable();
+ if (membersTable.tableWrapper) {
+ // Initial load
+ membersTable.loadTable(1);
+ }
+ }
+});
+
/**
* An IIFE that displays confirmation modal on the user profile page
*/
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index 3d2ef8175..b6bc0d296 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -126,7 +126,8 @@ html[data-theme="light"] {
body.dashboard,
body.change-list,
body.change-form,
- .custom-admin-template, dt {
+ .custom-admin-template,
+ .dl-dja dt {
color: var(--body-fg);
}
.usa-table td {
@@ -155,7 +156,8 @@ html[data-theme="dark"] {
body.dashboard,
body.change-list,
body.change-form,
- .custom-admin-template, dt {
+ .custom-admin-template,
+ .dl-dja dt {
color: var(--body-fg);
}
.usa-table td {
@@ -480,7 +482,8 @@ details.dja-detail-table {
background-color: var(--body-bg);
.dja-details-summary {
cursor: pointer;
- color: var(--body-quiet-color);
+ color: var(--link-fg);
+ text-decoration: underline;
}
@media (max-width: 1024px){
@@ -889,4 +892,9 @@ ul.add-list-reset {
overflow: visible;
word-break: break-all;
max-width: 100%;
- }
\ No newline at end of file
+}
+
+.organization-admin-label {
+ font-weight: 600;
+ font-size: .8125rem;
+}
diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss
index 44c224aad..08e35b19f 100644
--- a/src/registrar/assets/sass/_theme/_forms.scss
+++ b/src/registrar/assets/sass/_theme/_forms.scss
@@ -68,21 +68,6 @@ legend.float-left-tablet + button.float-right-tablet {
}
}
-// Custom style for disabled inputs
-@media (prefers-color-scheme: light) {
- .usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
- background-color: #eeeeee;
- color: #666666;
- }
-}
-
-@media (prefers-color-scheme: dark) {
- .usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled {
- background-color: var(--body-fg);
- color: var(--close-button-hover-bg);
- }
-}
-
.read-only-label {
font-size: size('body', 'sm');
color: color('primary-dark');
diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py
index 9eb649ad8..d2689242a 100644
--- a/src/registrar/config/settings.py
+++ b/src/registrar/config/settings.py
@@ -476,6 +476,8 @@ class JsonServerFormatter(ServerFormatter):
def format(self, record):
formatted_record = super().format(record)
+ if not hasattr(record, "server_time"):
+ record.server_time = self.formatTime(record, self.datefmt)
log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record}
return json.dumps(log_entry)
@@ -721,6 +723,7 @@ ALLOWED_HOSTS = [
"getgov-stable.app.cloud.gov",
"getgov-staging.app.cloud.gov",
"getgov-development.app.cloud.gov",
+ "getgov-el.app.cloud.gov",
"getgov-ad.app.cloud.gov",
"getgov-ms.app.cloud.gov",
"getgov-ag.app.cloud.gov",
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index df5733238..436ca3ae0 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -20,21 +20,24 @@ from registrar.views.report_views import (
AnalyticsView,
ExportDomainRequestDataFull,
ExportDataTypeUser,
+ ExportDataTypeRequests,
)
-from registrar.views.domain_request import Step
+# --jsons
from registrar.views.domain_requests_json import get_domain_requests_json
-from registrar.views.transfer_user import TransferUserView
+from registrar.views.domains_json import get_domains_json
+from registrar.views.portfolio_members_json import get_portfolio_members_json
from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json,
get_federal_and_portfolio_types_from_federal_agency_json,
get_action_needed_email_for_user_json,
)
-from registrar.views.domains_json import get_domains_json
+
+from registrar.views.domain_request import Step
+from registrar.views.transfer_user import TransferUserView
from registrar.views.utility import always_404
from api.views import available, rdap, get_current_federal, get_current_full
-
DOMAIN_REQUEST_NAMESPACE = views.DomainRequestWizard.URL_NAMESPACE
domain_request_urls = [
path("", views.DomainRequestWizard.as_view(), name=""),
@@ -74,6 +77,16 @@ urlpatterns = [
views.PortfolioNoDomainsView.as_view(),
name="no-portfolio-domains",
),
+ path(
+ "members/",
+ views.PortfolioMembersView.as_view(),
+ name="members",
+ ),
+ # path(
+ # "no-organization-members/",
+ # views.PortfolioNoMembersView.as_view(),
+ # name="no-portfolio-members",
+ # ),
path(
"requests/",
views.PortfolioDomainRequestsView.as_view(),
@@ -165,6 +178,11 @@ urlpatterns = [
ExportDataTypeUser.as_view(),
name="export_data_type_user",
),
+ path(
+ "reports/export_data_type_requests/",
+ ExportDataTypeRequests.as_view(),
+ name="export_data_type_requests",
+ ),
path(
"domain-request//edit/",
views.DomainRequestWizard.as_view(),
@@ -276,6 +294,7 @@ urlpatterns = [
),
path("get-domains-json/", get_domains_json, name="get_domains_json"),
path("get-domain-requests-json/", get_domain_requests_json, name="get_domain_requests_json"),
+ path("get-portfolio-members-json/", get_portfolio_members_json, name="get_portfolio_members_json"),
]
# Djangooidc strips out context data from that context, so we define a custom error
diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py
index c62c9a7c4..53f6e8ae7 100644
--- a/src/registrar/context_processors.py
+++ b/src/registrar/context_processors.py
@@ -97,5 +97,5 @@ def portfolio_permissions(request):
def is_widescreen_mode(request):
- widescreen_paths = ["/domains/", "/requests/"]
+ widescreen_paths = ["/domains/", "/requests/", "/members/"]
return {"is_widescreen_mode": any(path in request.path for path in widescreen_paths) or request.path == "/"}
diff --git a/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py
index 506405b78..50b481e7f 100644
--- a/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py
+++ b/src/registrar/management/commands/populate_federal_agency_initials_and_fceb.py
@@ -36,13 +36,13 @@ class Command(BaseCommand, PopulateScriptTemplate):
self.federal_agency_dict[agency_name.strip()] = (initials, agency_status)
# Update every federal agency record
- self.mass_update_records(FederalAgency, {"agency__isnull": False}, ["initials", "is_fceb"])
+ self.mass_update_records(FederalAgency, {"agency__isnull": False}, ["acronym", "is_fceb"])
def update_record(self, record: FederalAgency):
"""For each record, update the initials and is_fceb field if data exists for it"""
initials, agency_status = self.federal_agency_dict.get(record.agency)
- record.initials = initials
+ record.acronym = initials
if agency_status and isinstance(agency_status, str) and agency_status.strip().upper() == "FCEB":
record.is_fceb = True
else:
diff --git a/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py b/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py
new file mode 100644
index 000000000..dae994f8e
--- /dev/null
+++ b/src/registrar/migrations/0129_alter_portfolioinvitation_portfolio_roles_and_more.py
@@ -0,0 +1,40 @@
+# Generated by Django 4.2.10 on 2024-09-25 00:49
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0128_alter_domaininformation_state_territory_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="portfolioinvitation",
+ name="portfolio_roles",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[("organization_admin", "Admin"), ("organization_member", "Member")], max_length=50
+ ),
+ blank=True,
+ help_text="Select one or more roles.",
+ null=True,
+ size=None,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userportfoliopermission",
+ name="roles",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[("organization_admin", "Admin"), ("organization_member", "Member")], max_length=50
+ ),
+ blank=True,
+ help_text="Select one or more roles.",
+ null=True,
+ size=None,
+ ),
+ ),
+ ]
diff --git a/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py b/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
new file mode 100644
index 000000000..c0e2a0b22
--- /dev/null
+++ b/src/registrar/migrations/0130_remove_federalagency_initials_federalagency_acronym_and_more.py
@@ -0,0 +1,146 @@
+# Generated by Django 4.2.10 on 2024-09-30 17:59
+
+from django.db import migrations, models
+import django.db.models.deletion
+import registrar.models.federal_agency
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0129_alter_portfolioinvitation_portfolio_roles_and_more"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="federalagency",
+ old_name="initials",
+ new_name="acronym",
+ ),
+ migrations.AlterField(
+ model_name="federalagency",
+ name="acronym",
+ field=models.CharField(
+ blank=True,
+ help_text="Acronym commonly used to reference the federal agency (Optional)",
+ max_length=10,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domaininformation",
+ name="portfolio",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, domain is not associated with a portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="information_portfolio",
+ to="registrar.portfolio",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domaininformation",
+ name="sub_organization",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, domain is associated with the overarching organization for this portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="information_sub_organization",
+ to="registrar.suborganization",
+ verbose_name="Suborganization",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="portfolio",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, request is not associated with a portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="DomainRequest_portfolio",
+ to="registrar.portfolio",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="sub_organization",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="If blank, request is associated with the overarching organization for this portfolio.",
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="request_sub_organization",
+ to="registrar.suborganization",
+ verbose_name="Suborganization",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="federalagency",
+ name="federal_type",
+ field=models.CharField(
+ blank=True,
+ choices=[("executive", "Executive"), ("judicial", "Judicial"), ("legislative", "Legislative")],
+ max_length=20,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="federalagency",
+ name="is_fceb",
+ field=models.BooleanField(
+ blank=True, help_text="Federal Civilian Executive Branch (FCEB)", null=True, verbose_name="FCEB"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="portfolio",
+ name="federal_agency",
+ field=models.ForeignKey(
+ default=registrar.models.federal_agency.FederalAgency.get_non_federal_agency,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="registrar.federalagency",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="portfolio",
+ name="organization_name",
+ field=models.CharField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name="portfolio",
+ name="organization_type",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("federal", "Federal"),
+ ("interstate", "Interstate"),
+ ("state_or_territory", "State or territory"),
+ ("tribal", "Tribal"),
+ ("county", "County"),
+ ("city", "City"),
+ ("special_district", "Special district"),
+ ("school_district", "School district"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="portfolio",
+ name="senior_official",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="portfolios",
+ to="registrar.seniorofficial",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="suborganization",
+ name="name",
+ field=models.CharField(max_length=1000, unique=True, verbose_name="Suborganization"),
+ ),
+ ]
diff --git a/src/registrar/migrations/0131_create_groups_v17.py b/src/registrar/migrations/0131_create_groups_v17.py
new file mode 100644
index 000000000..04cd5163c
--- /dev/null
+++ b/src/registrar/migrations/0131_create_groups_v17.py
@@ -0,0 +1,37 @@
+# This migration creates the create_full_access_group and create_cisa_analyst_group groups
+# It is dependent on 0079 (which populates federal agencies)
+# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
+# in the user_group model then:
+# [NOT RECOMMENDED]
+# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
+# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
+# step 3: fake run the latest migration in the migrations list
+# [RECOMMENDED]
+# Alternatively:
+# step 1: duplicate the migration that loads data
+# step 2: docker-compose exec app ./manage.py migrate
+
+from django.db import migrations
+from registrar.models import UserGroup
+from typing import Any
+
+
+# For linting: RunPython expects a function reference,
+# so let's give it one
+def create_groups(apps, schema_editor) -> Any:
+ UserGroup.create_cisa_analyst_group(apps, schema_editor)
+ UserGroup.create_full_access_group(apps, schema_editor)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("registrar", "0130_remove_federalagency_initials_federalagency_acronym_and_more"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ create_groups,
+ reverse_code=migrations.RunPython.noop,
+ atomic=True,
+ ),
+ ]
diff --git a/src/registrar/migrations/0132_alter_domaininformation_portfolio_and_more.py b/src/registrar/migrations/0132_alter_domaininformation_portfolio_and_more.py
new file mode 100644
index 000000000..e0d2b35f9
--- /dev/null
+++ b/src/registrar/migrations/0132_alter_domaininformation_portfolio_and_more.py
@@ -0,0 +1,36 @@
+# Generated by Django 4.2.10 on 2024-10-02 14:17
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0131_create_groups_v17"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="domaininformation",
+ name="portfolio",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="information_portfolio",
+ to="registrar.portfolio",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="domainrequest",
+ name="portfolio",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="DomainRequest_portfolio",
+ to="registrar.portfolio",
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py
index d04f09c07..5f98197bd 100644
--- a/src/registrar/models/domain_information.py
+++ b/src/registrar/models/domain_information.py
@@ -63,7 +63,6 @@ class DomainInformation(TimeStampedModel):
null=True,
blank=True,
related_name="information_portfolio",
- help_text="Portfolio associated with this domain",
)
sub_organization = models.ForeignKey(
@@ -72,7 +71,8 @@ class DomainInformation(TimeStampedModel):
null=True,
blank=True,
related_name="information_sub_organization",
- help_text="The suborganization that this domain is included under",
+ help_text="If blank, domain is associated with the overarching organization for this portfolio.",
+ verbose_name="Suborganization",
)
domain_request = models.OneToOneField(
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index bb8693ac1..79bc223e9 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -327,7 +327,6 @@ class DomainRequest(TimeStampedModel):
null=True,
blank=True,
related_name="DomainRequest_portfolio",
- help_text="Portfolio associated with this domain request",
)
sub_organization = models.ForeignKey(
@@ -336,7 +335,8 @@ class DomainRequest(TimeStampedModel):
null=True,
blank=True,
related_name="request_sub_organization",
- help_text="The suborganization that this domain request is included under",
+ help_text="If blank, request is associated with the overarching organization for this portfolio.",
+ verbose_name="Suborganization",
)
# This is the domain request user who created this domain request.
diff --git a/src/registrar/models/federal_agency.py b/src/registrar/models/federal_agency.py
index 5cc87b38c..aeeebac8c 100644
--- a/src/registrar/models/federal_agency.py
+++ b/src/registrar/models/federal_agency.py
@@ -22,21 +22,20 @@ class FederalAgency(TimeStampedModel):
choices=BranchChoices.choices,
null=True,
blank=True,
- help_text="Federal agency type (executive, judicial, legislative, etc.)",
)
- initials = models.CharField(
+ acronym = models.CharField(
max_length=10,
null=True,
blank=True,
- help_text="Agency initials",
+ help_text="Acronym commonly used to reference the federal agency (Optional)",
)
is_fceb = models.BooleanField(
null=True,
blank=True,
verbose_name="FCEB",
- help_text="Determines if this agency is FCEB",
+ help_text="Federal Civilian Executive Branch (FCEB)",
)
def __str__(self) -> str:
diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py
index 61d4f7a30..8d820e105 100644
--- a/src/registrar/models/portfolio.py
+++ b/src/registrar/models/portfolio.py
@@ -2,7 +2,6 @@ from django.db import models
from registrar.models.domain_request import DomainRequest
from registrar.models.federal_agency import FederalAgency
-from registrar.utility.constants import BranchChoices
from .utility.time_stamped_model import TimeStampedModel
@@ -34,7 +33,6 @@ class Portfolio(TimeStampedModel):
organization_name = models.CharField(
null=True,
blank=True,
- verbose_name="Portfolio organization",
)
organization_type = models.CharField(
@@ -42,7 +40,6 @@ class Portfolio(TimeStampedModel):
choices=OrganizationChoices.choices,
null=True,
blank=True,
- help_text="Type of organization",
)
notes = models.TextField(
@@ -53,7 +50,6 @@ class Portfolio(TimeStampedModel):
federal_agency = models.ForeignKey(
"registrar.FederalAgency",
on_delete=models.PROTECT,
- help_text="Associated federal agency",
unique=False,
default=FederalAgency.get_non_federal_agency,
)
@@ -64,6 +60,7 @@ class Portfolio(TimeStampedModel):
unique=False,
null=True,
blank=True,
+ related_name="portfolios",
)
address_line1 = models.CharField(
@@ -125,23 +122,6 @@ class Portfolio(TimeStampedModel):
super().save(*args, **kwargs)
- @property
- def portfolio_type(self):
- """
- Returns a combination of organization_type / federal_type, seperated by ' - '.
- If no federal_type is found, we just return the org type.
- """
- return self.get_portfolio_type(self.organization_type, self.federal_type)
-
- @classmethod
- def get_portfolio_type(cls, organization_type, federal_type):
- org_type_label = cls.OrganizationChoices.get_org_label(organization_type)
- agency_type_label = BranchChoices.get_branch_label(federal_type)
- if organization_type == cls.OrganizationChoices.FEDERAL and agency_type_label:
- return " - ".join([org_type_label, agency_type_label])
- else:
- return org_type_label
-
@property
def federal_type(self):
"""Returns the federal_type value on the underlying federal_agency field"""
@@ -152,13 +132,19 @@ class Portfolio(TimeStampedModel):
return federal_agency.federal_type if federal_agency else None
# == Getters for domains == #
- def get_domains(self):
+ def get_domains(self, order_by=None):
"""Returns all DomainInformations associated with this portfolio"""
- return self.information_portfolio.all()
+ if not order_by:
+ return self.information_portfolio.all()
+ else:
+ return self.information_portfolio.all().order_by(*order_by)
- def get_domain_requests(self):
+ def get_domain_requests(self, order_by=None):
"""Returns all DomainRequests associated with this portfolio"""
- return self.DomainRequest_portfolio.all()
+ if not order_by:
+ return self.DomainRequest_portfolio.all()
+ else:
+ return self.DomainRequest_portfolio.all().order_by(*order_by)
# == Getters for suborganization == #
def get_suborganizations(self):
diff --git a/src/registrar/models/suborganization.py b/src/registrar/models/suborganization.py
index feeee0669..6ad80fdc0 100644
--- a/src/registrar/models/suborganization.py
+++ b/src/registrar/models/suborganization.py
@@ -10,7 +10,7 @@ class Suborganization(TimeStampedModel):
name = models.CharField(
unique=True,
max_length=1000,
- help_text="Suborganization",
+ verbose_name="Suborganization",
)
portfolio = models.ForeignKey(
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index ae76d648b..80c972d38 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -229,6 +229,10 @@ class User(AbstractUser):
"""Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)
+ def has_view_all_domain_requests_portfolio_permission(self, portfolio):
+ """Determines if the current user can view all available domains in a given portfolio"""
+ return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
+
def has_any_requests_portfolio_permission(self, portfolio):
# BEGIN
# Note code below is to add organization_request feature
@@ -458,3 +462,12 @@ class User(AbstractUser):
return DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
else:
return UserDomainRole.objects.filter(user=self).values_list("domain_id", flat=True)
+
+ def get_user_domain_request_ids(self, request):
+ """Returns either the domain request ids associated with this user on UserDomainRole or Portfolio"""
+ portfolio = request.session.get("portfolio")
+
+ if self.is_org_user(request) and self.has_view_all_domain_requests_portfolio_permission(portfolio):
+ return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
+ else:
+ return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)
diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py
index 182d16e95..4770f34bc 100644
--- a/src/registrar/models/user_group.py
+++ b/src/registrar/models/user_group.py
@@ -66,6 +66,30 @@ class UserGroup(Group):
"model": "federalagency",
"permissions": ["add_federalagency", "change_federalagency", "delete_federalagency"],
},
+ {
+ "app_label": "registrar",
+ "model": "portfolio",
+ "permissions": ["add_portfolio", "change_portfolio", "delete_portfolio"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "suborganization",
+ "permissions": ["add_suborganization", "change_suborganization", "delete_suborganization"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "seniorofficial",
+ "permissions": ["add_seniorofficial", "change_seniorofficial", "delete_seniorofficial"],
+ },
+ {
+ "app_label": "registrar",
+ "model": "userportfoliopermission",
+ "permissions": [
+ "add_userportfoliopermission",
+ "change_userportfoliopermission",
+ "delete_userportfoliopermission",
+ ],
+ },
]
# Avoid error: You can't execute queries until the end
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index f7bafc8c6..241afd328 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -15,8 +15,6 @@ class UserPortfolioPermission(TimeStampedModel):
PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
- UserPortfolioPermissionChoices.VIEW_MEMBERS,
- UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
@@ -25,14 +23,6 @@ class UserPortfolioPermission(TimeStampedModel):
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
],
- UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
- UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
- UserPortfolioPermissionChoices.VIEW_MEMBERS,
- UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
- UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
- # Domain: field specific permissions
- UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
- ],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
],
@@ -75,7 +65,19 @@ class UserPortfolioPermission(TimeStampedModel):
)
def __str__(self):
- return f"User '{self.user}' on Portfolio '{self.portfolio}' " f"" if self.roles else ""
+ readable_roles = []
+ if self.roles:
+ readable_roles = self.get_readable_roles()
+ return f"{self.user}" f" " if self.roles else ""
+
+ def get_readable_roles(self):
+ """Returns a readable list of self.roles"""
+ readable_roles = []
+ if self.roles:
+ readable_roles = sorted(
+ [UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles]
+ )
+ return readable_roles
def _get_portfolio_permissions(self):
"""
@@ -103,7 +105,8 @@ class UserPortfolioPermission(TimeStampedModel):
existing_permissions = UserPortfolioPermission.objects.filter(user=self.user)
if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permissions.exists():
raise ValidationError(
- "Only one portfolio permission is allowed per user when multiple portfolios are disabled."
+ "This user is already assigned to a portfolio. "
+ "Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
)
# Check if portfolio is set without accessing the related object.
diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py
index 3cafe87c4..5e425f5a3 100644
--- a/src/registrar/models/utility/generic_helper.py
+++ b/src/registrar/models/utility/generic_helper.py
@@ -334,3 +334,12 @@ def get_url_name(path):
except Resolver404:
logger.error(f"No matching URL name found for path: {path}")
return None
+
+
+def value_of_attribute(obj, attribute_name: str):
+ """Returns the value of getattr if the attribute isn't callable.
+ If it is, execute the underlying function and return that result instead."""
+ value = getattr(obj, attribute_name)
+ if callable(value):
+ value = value()
+ return value
diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py
index 7f34221fd..ddb487f71 100644
--- a/src/registrar/models/utility/portfolio_helper.py
+++ b/src/registrar/models/utility/portfolio_helper.py
@@ -7,9 +7,12 @@ class UserPortfolioRoleChoices(models.TextChoices):
"""
ORGANIZATION_ADMIN = "organization_admin", "Admin"
- ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only"
ORGANIZATION_MEMBER = "organization_member", "Member"
+ @classmethod
+ def get_user_portfolio_role_label(cls, user_portfolio_role):
+ return cls(user_portfolio_role).label if user_portfolio_role else None
+
class UserPortfolioPermissionChoices(models.TextChoices):
""" """
@@ -29,3 +32,7 @@ class UserPortfolioPermissionChoices(models.TextChoices):
# Domain: field specific permissions
VIEW_SUBORGANIZATION = "view_suborganization", "View suborganization"
EDIT_SUBORGANIZATION = "edit_suborganization", "Edit suborganization"
+
+ @classmethod
+ def get_user_portfolio_permission_label(cls, user_portfolio_permission):
+ return cls(user_portfolio_permission).label if user_portfolio_permission else None
diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py
index 5a75577df..2ccea9321 100644
--- a/src/registrar/registrar_middleware.py
+++ b/src/registrar/registrar_middleware.py
@@ -49,11 +49,15 @@ class CheckUserProfileMiddleware:
self.setup_page,
self.logout_page,
"/admin",
+ # These are here as there is a bug with this middleware that breaks djangos built in debug console.
+ # The debug console uses this directory, but since this overrides that, it throws errors.
+ "/__debug__",
]
self.other_excluded_pages = [
self.profile_page,
self.logout_page,
"/admin",
+ "/__debug__",
]
self.excluded_pages = {
diff --git a/src/registrar/templates/django/admin/includes/contact_detail_list.html b/src/registrar/templates/django/admin/includes/contact_detail_list.html
index b855dcf12..84fb07f33 100644
--- a/src/registrar/templates/django/admin/includes/contact_detail_list.html
+++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html
@@ -39,7 +39,7 @@
None
{% endif %}
- {% else %}
+ {% elif not hide_no_contact_info_message %}
No additional contact information found.
{% endif %}
diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
index 90c02f386..2369f235b 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -119,7 +119,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %}
{% endwith %}
- {% elif field.field.name == "display_admins" or field.field.name == "domain_managers" or field.field.namd == "invited_domain_managers" %}
+ {% elif field.field.name == "domain_managers" or field.field.name == "invited_domain_managers" %}
{{ field.contents|safe }}
{% elif field.field.name == "display_members" %}
@@ -301,13 +301,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endif %}
{% endwith %}
- {% elif field.field.name == "display_members" and field.contents %}
-
- Details
-
- {{ field.contents|safe }}
-
-
{% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
diff --git a/src/registrar/templates/django/admin/includes/details_button.html b/src/registrar/templates/django/admin/includes/details_button.html
new file mode 100644
index 000000000..73748f170
--- /dev/null
+++ b/src/registrar/templates/django/admin/includes/details_button.html
@@ -0,0 +1,9 @@
+
+{% comment %} This view provides a detail button that can be used to show/hide content {% endcomment %}
+
+ Details
+
+ {% block detail_content %}
+ {% endblock detail_content%}
+
+
diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html
new file mode 100644
index 000000000..4ea9225da
--- /dev/null
+++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html
@@ -0,0 +1,48 @@
+{% extends "django/admin/includes/details_button.html" %}
+{% load static url_helpers %}
+
+{% block detail_content %}
+
+{% endblock detail_content %}
diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html
new file mode 100644
index 000000000..46303efce
--- /dev/null
+++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domain_requests_table.html
@@ -0,0 +1,26 @@
+{% extends "django/admin/includes/details_button.html" %}
+{% load static url_helpers %}
+
+{% block detail_content %}
+
+
+
+ Name
+ Status
+
+
+
+ {% for domain_request in domain_requests %}
+ {% url 'admin:registrar_domainrequest_change' domain_request.pk as url %}
+
+ {{ domain_request }}
+ {% if domain_request.get_status_display %}
+ {{ domain_request.get_status_display }}
+ {% else %}
+ None
+ {% endif %}
+
+ {% endfor %}
+
+
+{% endblock detail_content %}
diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html
new file mode 100644
index 000000000..56621b769
--- /dev/null
+++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_domains_table.html
@@ -0,0 +1,30 @@
+{% extends "django/admin/includes/details_button.html" %}
+{% load static url_helpers %}
+
+{% block detail_content %}
+
+
+
+ Name
+ State
+
+
+
+ {% for domain_info in domains %}
+ {% if domain_info.domain %}
+ {% with domain=domain_info.domain %}
+ {% url 'admin:registrar_domain_change' domain.pk as url %}
+
+ {{ domain }}
+ {% if domain and domain.get_state_display %}
+ {{ domain.get_state_display }}
+ {% else %}
+ None
+ {% endif %}
+
+ {% endwith %}
+ {% endif %}
+ {% endfor %}
+
+
+{% endblock detail_content%}
diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html
new file mode 100644
index 000000000..87b56cb60
--- /dev/null
+++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html
@@ -0,0 +1,61 @@
+{% extends "django/admin/includes/detail_table_fieldset.html" %}
+{% load custom_filters %}
+{% load static url_helpers %}
+
+{% block field_readonly %}
+ {% if field.field.name == "display_admins" or field.field.name == "display_members" %}
+ {{ field.contents|safe }}
+ {% elif field.field.name == "roles" %}
+
+ {% if get_readable_roles %}
+ {{ get_readable_roles }}
+ {% else %}
+
No roles found.
+ {% endif %}
+
+ {% elif field.field.name == "additional_permissions" %}
+
+ {% if display_permissions %}
+ {{ display_permissions }}
+ {% else %}
+
No additional permissions found.
+ {% endif %}
+
+ {% elif field.field.name == "senior_official" %}
+ {% if original_object.senior_official %}
+ {{ field.contents }}
+ {% else %}
+ {% url "admin:registrar_seniorofficial_add" as url %}
+
+ {% endif %}
+ {% else %}
+ {{ field.contents }}
+ {% endif %}
+{% endblock field_readonly%}
+
+{% block after_help_text %}
+ {% if field.field.name == "senior_official" %}
+
+
+ {% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly hide_no_contact_info_message=True %}
+
+ {% elif field.field.name == "display_admins" %}
+ {% if admins|length > 0 %}
+ {% include "django/admin/includes/portfolio/portfolio_admins_table.html" with admins=admins %}
+ {% endif %}
+ {% elif field.field.name == "display_members" %}
+ {% if members|length > 0 %}
+ {% include "django/admin/includes/portfolio/portfolio_members_table.html" with members=members %}
+ {% endif %}
+ {% elif field.field.name == "domains" %}
+ {% if domains|length > 0 %}
+ {% include "django/admin/includes/portfolio/portfolio_domains_table.html" with domains=domains %}
+ {% endif %}
+ {% elif field.field.name == "domain_requests" %}
+ {% if domain_requests|length > 0 %}
+ {% include "django/admin/includes/portfolio/portfolio_domain_requests_table.html" with domain_requests=domain_requests %}
+ {% endif %}
+ {% endif %}
+{% endblock after_help_text %}
diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html
new file mode 100644
index 000000000..136fe3a5a
--- /dev/null
+++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html
@@ -0,0 +1,55 @@
+{% extends "django/admin/includes/details_button.html" %}
+{% load custom_filters %}
+{% load static url_helpers %}
+
+{% block detail_content %}
+
+{% endblock %}
diff --git a/src/registrar/templates/django/admin/portfolio_change_form.html b/src/registrar/templates/django/admin/portfolio_change_form.html
index 8dae8a080..8de6cd5eb 100644
--- a/src/registrar/templates/django/admin/portfolio_change_form.html
+++ b/src/registrar/templates/django/admin/portfolio_change_form.html
@@ -8,19 +8,14 @@
{% url 'get-federal-and-portfolio-types-from-federal-agency-json' as url %}
+ {% url "admin:registrar_seniorofficial_add" as url %}
+
{{ block.super }}
{% endblock content %}
{% block field_sets %}
{% for fieldset in adminform %}
- {% comment %}
- This is a placeholder for now.
-
- Disclaimer:
- When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
- detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
- {% endcomment %}
- {% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
+ {% include "django/admin/includes/portfolio/portfolio_fieldset.html" with original_object=original %}
{% endfor %}
{% endblock %}
diff --git a/src/registrar/templates/django/admin/suborg_change_form.html b/src/registrar/templates/django/admin/suborg_change_form.html
index 005d67aec..25fe5700d 100644
--- a/src/registrar/templates/django/admin/suborg_change_form.html
+++ b/src/registrar/templates/django/admin/suborg_change_form.html
@@ -8,27 +8,35 @@
Domains
- {% for domain in domains %}
-
-
- {{ domain.name }}
-
- ({{ domain.state }})
-
- {% endfor %}
+ {% if domains|length > 0 %}
+ {% for domain in domains %}
+
+
+ {{ domain.name }}
+
+ ({{ domain.state }})
+
+ {% endfor %}
+ {% else %}
+ No domains.
+ {% endif %}
diff --git a/src/registrar/templates/django/admin/user_change_form.html b/src/registrar/templates/django/admin/user_change_form.html
index 736f12ba4..c0ddd8caf 100644
--- a/src/registrar/templates/django/admin/user_change_form.html
+++ b/src/registrar/templates/django/admin/user_change_form.html
@@ -17,26 +17,6 @@
{% endblock %}
{% block after_related_objects %}
- {% if portfolios %}
-
-
Portfolio information
-
-
- {% endif %}
-
Associated requests and domains
diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html
new file mode 100644
index 000000000..1249a486c
--- /dev/null
+++ b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html
@@ -0,0 +1,16 @@
+{% extends 'django/admin/email_clipboard_change_form.html' %}
+{% load custom_filters %}
+{% load i18n static %}
+
+{% block field_sets %}
+ {% for fieldset in adminform %}
+ {% comment %}
+ This is a placeholder for now.
+
+ Disclaimer:
+ When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
+ detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
+ {% endcomment %}
+ {% include "django/admin/includes/user_portfolio_permission_fieldset.html" with original_object=original %}
+ {% endfor %}
+{% endblock %}
\ No newline at end of file
diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html
index 375e0229c..4bef23870 100644
--- a/src/registrar/templates/includes/domain_requests_table.html
+++ b/src/registrar/templates/includes/domain_requests_table.html
@@ -3,204 +3,203 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domain_requests_json' as url %}
{{url}}
+
-
- {% if not portfolio %}
-
-
Domain requests
-
+
+
{% if portfolio %}
-
Filter by
-
-
-
Filter by
+
+
+
+ Status
+
+
+
+
+
+
+
+
- Status
-
-
+ class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domain-requests__reset-filters display-none"
+ >
+ Clear filters
+
+
-
-
-
-
-
- Clear filters
-
-
-
-
+
{% endif %}
+
-
- Your domain requests
-
-
- Domain name
- Submitted
- {% if portfolio %}
- Created by
- {% endif %}
- Status
- Action
-
-
-
-
-
-
-
-
+
+ Your domain requests
+
+
+ Domain name
+ Submitted
+ {% if portfolio %}
+ Created by
+ {% endif %}
+ Status
+ Action
+
+
+
+
+
+
+
+
+
-
You haven't requested any domains.
+
You haven't requested any domains.
+
-
-
+ No results found
+
+
+
+
diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html
index ab53dd5cf..a3b2364a9 100644
--- a/src/registrar/templates/includes/header_extended.html
+++ b/src/registrar/templates/includes/header_extended.html
@@ -91,9 +91,9 @@
{% endif %}
- {% if has_organization_members_flag %}
+ {% if has_organization_members_flag and has_view_members_portfolio_permission %}
-
+
Members
diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html
new file mode 100644
index 000000000..529d2629d
--- /dev/null
+++ b/src/registrar/templates/includes/members_table.html
@@ -0,0 +1,80 @@
+{% load static %}
+
+
+
+{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
+{% url 'get_portfolio_members_json' as url %}
+{{url}}
+
+
+
+
+
+
+
You don't have any members.
+
+
+
+
diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html
new file mode 100644
index 000000000..82e06c808
--- /dev/null
+++ b/src/registrar/templates/portfolio_members.html
@@ -0,0 +1,33 @@
+{% extends 'portfolio_base.html' %}
+
+{% load static %}
+
+{% block title %} Members | {% endblock %}
+
+{% block wrapper_class %}
+ {{ block.super }} dashboard--grey-1
+{% endblock %}
+
+{% block portfolio_content %}
+{% block messages %}
+ {% include "includes/form_messages.html" %}
+{% endblock %}
+
+
+
+
+ {% include "includes/members_table.html" with portfolio=portfolio %}
+
+{% endblock %}
diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py
index c6c7c97d1..a3f35ae8e 100644
--- a/src/registrar/templatetags/custom_filters.py
+++ b/src/registrar/templatetags/custom_filters.py
@@ -239,3 +239,23 @@ def is_portfolio_subpage(path):
"senior-official",
]
return get_url_name(path) in url_names
+
+
+@register.filter(name="is_members_subpage")
+def is_members_subpage(path):
+ """Checks if the given page is a subpage of members.
+ Takes a path name, like '/organization/'."""
+ # Since our pages aren't unified under a common path, we need this approach for now.
+ url_names = [
+ "members",
+ ]
+ return get_url_name(path) in url_names
+
+
+@register.filter(name="portfolio_role_summary")
+def portfolio_role_summary(user, portfolio):
+ """Returns the value of user.portfolio_role_summary"""
+ if user and portfolio:
+ return user.portfolio_role_summary(portfolio)
+ else:
+ return []
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
index 3b50c7746..9c5e3b582 100644
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -2097,36 +2097,11 @@ class TestPortfolioAdmin(TestCase):
)
display_admins = self.admin.display_admins(self.portfolio)
-
- self.assertIn(
- f'Gerald Meoward meaoward@gov.gov ',
- display_admins,
- )
- self.assertIn("Captain", display_admins)
- self.assertIn(
- f'Arnold Poopy poopy@gov.gov ', display_admins
- )
- self.assertIn("Major", display_admins)
-
- display_members_summary = self.admin.display_members_summary(self.portfolio)
-
- self.assertIn(
- f'Mad Max madmax@gov.gov ',
- display_members_summary,
- )
- self.assertIn(
- f'Agent Smith thematrix@gov.gov ',
- display_members_summary,
- )
+ url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={self.portfolio.id}"
+ self.assertIn(f'2 administrators ', display_admins)
display_members = self.admin.display_members(self.portfolio)
-
- self.assertIn("Mad Max", display_members)
- self.assertIn("Member ", display_members)
- self.assertIn("Road warrior", display_members)
- self.assertIn("Agent Smith", display_members)
- self.assertIn("Domain requestor ", display_members)
- self.assertIn("Program", display_members)
+ self.assertIn(f'2 members ', display_members)
class TestTransferUser(WebTest):
diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py
index 8dfb777a0..f19008ca1 100644
--- a/src/registrar/tests/test_admin_request.py
+++ b/src/registrar/tests/test_admin_request.py
@@ -1846,6 +1846,58 @@ class TestDomainRequestAdmin(MockEppLib):
def test_side_effects_when_saving_approved_to_ineligible(self):
self.trigger_saving_approved_to_another_state(False, DomainRequest.DomainRequestStatus.INELIGIBLE)
+ @less_console_noise
+ def test_error_when_saving_to_approved_and_domain_exists(self):
+ """Redundant admin check on model transition not allowed."""
+ Domain.objects.create(name="wabbitseason.gov")
+
+ new_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.SUBMITTED, name="wabbitseason.gov"
+ )
+
+ # Create a request object with a superuser
+ request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
+ request.user = self.superuser
+
+ request.session = {}
+
+ # Use ExitStack to combine patch contexts
+ with ExitStack() as stack:
+ # Patch django.contrib.messages.error
+ stack.enter_context(patch.object(messages, "error"))
+
+ new_request.status = DomainRequest.DomainRequestStatus.APPROVED
+
+ self.admin.save_model(request, new_request, None, True)
+
+ messages.error.assert_called_once_with(
+ request,
+ "Cannot approve. Requested domain is already in use.",
+ )
+
+ @less_console_noise
+ def test_no_error_when_saving_to_approved_and_domain_exists(self):
+ """The negative of the redundant admin check on model transition not allowed."""
+ new_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED)
+
+ # Create a request object with a superuser
+ request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(new_request.pk))
+ request.user = self.superuser
+
+ request.session = {}
+
+ # Use ExitStack to combine patch contexts
+ with ExitStack() as stack:
+ # Patch Domain.is_active and django.contrib.messages.error simultaneously
+ stack.enter_context(patch.object(messages, "error"))
+
+ new_request.status = DomainRequest.DomainRequestStatus.APPROVED
+
+ self.admin.save_model(request, new_request, None, True)
+
+ # Assert that the error message was never called
+ messages.error.assert_not_called()
+
def test_has_correct_filters(self):
"""
This test verifies that DomainRequestAdmin has the correct filters set up.
diff --git a/src/registrar/tests/test_api.py b/src/registrar/tests/test_api.py
index ef5385d72..218c63d4f 100644
--- a/src/registrar/tests/test_api.py
+++ b/src/registrar/tests/test_api.py
@@ -100,7 +100,6 @@ class GetFederalPortfolioTypeJsonTest(TestCase):
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["federal_type"], "Judicial")
- self.assertEqual(data["portfolio_type"], "Federal - Judicial")
@less_console_noise_decorator
def test_get_federal_and_portfolio_types_json_authenticated_regularuser(self):
diff --git a/src/registrar/tests/test_management_scripts.py b/src/registrar/tests/test_management_scripts.py
index cbdc2c034..9fcd261f7 100644
--- a/src/registrar/tests/test_management_scripts.py
+++ b/src/registrar/tests/test_management_scripts.py
@@ -1387,18 +1387,18 @@ class TestPopulateFederalAgencyInitialsAndFceb(TestCase):
self.agency4.refresh_from_db()
# Check if FederalAgency objects were updated correctly
- self.assertEqual(self.agency1.initials, "ABMC")
+ self.assertEqual(self.agency1.acronym, "ABMC")
self.assertTrue(self.agency1.is_fceb)
- self.assertEqual(self.agency2.initials, "ACHP")
+ self.assertEqual(self.agency2.acronym, "ACHP")
self.assertTrue(self.agency2.is_fceb)
# We expect that this field doesn't have any data,
# as none is specified in the CSV
- self.assertIsNone(self.agency3.initials)
+ self.assertIsNone(self.agency3.acronym)
self.assertIsNone(self.agency3.is_fceb)
- self.assertEqual(self.agency4.initials, "KC")
+ self.assertEqual(self.agency4.acronym, "KC")
self.assertFalse(self.agency4.is_fceb)
@less_console_noise_decorator
@@ -1411,7 +1411,7 @@ class TestPopulateFederalAgencyInitialsAndFceb(TestCase):
# Verify that the missing agency was not updated
missing_agency.refresh_from_db()
- self.assertIsNone(missing_agency.initials)
+ self.assertIsNone(missing_agency.acronym)
self.assertIsNone(missing_agency.is_fceb)
diff --git a/src/registrar/tests/test_migrations.py b/src/registrar/tests/test_migrations.py
index 6d8ff7151..eaaae8727 100644
--- a/src/registrar/tests/test_migrations.py
+++ b/src/registrar/tests/test_migrations.py
@@ -40,10 +40,22 @@ class TestGroups(TestCase):
"add_federalagency",
"change_federalagency",
"delete_federalagency",
+ "add_portfolio",
+ "change_portfolio",
+ "delete_portfolio",
+ "add_seniorofficial",
+ "change_seniorofficial",
+ "delete_seniorofficial",
+ "add_suborganization",
+ "change_suborganization",
+ "delete_suborganization",
"analyst_access_permission",
"change_user",
"delete_userdomainrole",
"view_userdomainrole",
+ "add_userportfoliopermission",
+ "change_userportfoliopermission",
+ "delete_userportfoliopermission",
"add_verifiedbystaff",
"change_verifiedbystaff",
"delete_verifiedbystaff",
@@ -51,6 +63,7 @@ class TestGroups(TestCase):
# Get the codenames of actual permissions associated with the group
actual_permissions = [p.codename for p in cisa_analysts_group.permissions.all()]
+ self.maxDiff = None
# Assert that the actual permissions match the expected permissions
self.assertListEqual(actual_permissions, expected_permissions)
diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py
index a6cac1389..33ae90da9 100644
--- a/src/registrar/tests/test_models.py
+++ b/src/registrar/tests/test_models.py
@@ -1,7 +1,5 @@
from django.forms import ValidationError
from django.test import TestCase
-from django.db.utils import IntegrityError
-from django.db import transaction
from unittest.mock import patch
from django.test import RequestFactory
@@ -20,1045 +18,23 @@ from registrar.models import (
UserPortfolioPermission,
AllowedEmail,
)
-
import boto3_mocking
from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.transition_domain import TransitionDomain
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.verified_by_staff import VerifiedByStaff # type: ignore
-from registrar.utility.constants import BranchChoices
from .common import (
MockSESClient,
- less_console_noise,
completed_domain_request,
- set_domain_request_investigators,
create_test_user,
)
-from django_fsm import TransitionNotAllowed
from waffle.testutils import override_flag
from api.tests.common import less_console_noise_decorator
-@boto3_mocking.patching
-class TestDomainRequest(TestCase):
- @less_console_noise_decorator
- def setUp(self):
-
- self.dummy_user, _ = Contact.objects.get_or_create(
- email="mayor@igorville.com", first_name="Hello", last_name="World"
- )
- self.dummy_user_2, _ = User.objects.get_or_create(
- username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World"
- )
- self.started_domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.STARTED,
- name="started.gov",
- )
- self.submitted_domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.SUBMITTED,
- name="submitted.gov",
- )
- self.in_review_domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.IN_REVIEW,
- name="in-review.gov",
- )
- self.action_needed_domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
- name="action-needed.gov",
- )
- self.approved_domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.APPROVED,
- name="approved.gov",
- )
- self.withdrawn_domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.WITHDRAWN,
- name="withdrawn.gov",
- )
- self.rejected_domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.REJECTED,
- name="rejected.gov",
- )
- self.ineligible_domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.INELIGIBLE,
- name="ineligible.gov",
- )
-
- # Store all domain request statuses in a variable for ease of use
- self.all_domain_requests = [
- self.started_domain_request,
- self.submitted_domain_request,
- self.in_review_domain_request,
- self.action_needed_domain_request,
- self.approved_domain_request,
- self.withdrawn_domain_request,
- self.rejected_domain_request,
- self.ineligible_domain_request,
- ]
-
- self.mock_client = MockSESClient()
-
- def tearDown(self):
- super().tearDown()
- DomainInformation.objects.all().delete()
- DomainRequest.objects.all().delete()
- DraftDomain.objects.all().delete()
- Domain.objects.all().delete()
- User.objects.all().delete()
- self.mock_client.EMAILS_SENT.clear()
-
- def assertNotRaises(self, exception_type):
- """Helper method for testing allowed transitions."""
- with less_console_noise():
- return self.assertRaises(Exception, None, exception_type)
-
- @less_console_noise_decorator
- def test_request_is_withdrawable(self):
- """Tests the is_withdrawable function"""
- domain_request_1 = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.SUBMITTED,
- name="city2.gov",
- )
- domain_request_2 = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.IN_REVIEW,
- name="city3.gov",
- )
- domain_request_3 = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
- name="city4.gov",
- )
- domain_request_4 = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.REJECTED,
- name="city5.gov",
- )
- self.assertTrue(domain_request_1.is_withdrawable())
- self.assertTrue(domain_request_2.is_withdrawable())
- self.assertTrue(domain_request_3.is_withdrawable())
- self.assertFalse(domain_request_4.is_withdrawable())
-
- @less_console_noise_decorator
- def test_request_is_awaiting_review(self):
- """Tests the is_awaiting_review function"""
- domain_request_1 = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.SUBMITTED,
- name="city2.gov",
- )
- domain_request_2 = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.IN_REVIEW,
- name="city3.gov",
- )
- domain_request_3 = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
- name="city4.gov",
- )
- domain_request_4 = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.REJECTED,
- name="city5.gov",
- )
- self.assertTrue(domain_request_1.is_awaiting_review())
- self.assertTrue(domain_request_2.is_awaiting_review())
- self.assertFalse(domain_request_3.is_awaiting_review())
- self.assertFalse(domain_request_4.is_awaiting_review())
-
- @less_console_noise_decorator
- def test_federal_agency_set_to_non_federal_on_approve(self):
- """Ensures that when the federal_agency field is 'none' when .approve() is called,
- the field is set to the 'Non-Federal Agency' record"""
- domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.IN_REVIEW,
- name="city2.gov",
- federal_agency=None,
- )
-
- # Ensure that the federal agency is None
- self.assertEqual(domain_request.federal_agency, None)
-
- # Approve the request
- domain_request.approve()
- self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED)
-
- # After approval, it should be "Non-Federal agency"
- expected_federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first()
- self.assertEqual(domain_request.federal_agency, expected_federal_agency)
-
- def test_empty_create_fails(self):
- """Can't create a completely empty domain request."""
- with less_console_noise():
- with transaction.atomic():
- with self.assertRaisesRegex(IntegrityError, "creator"):
- DomainRequest.objects.create()
-
- @less_console_noise_decorator
- def test_minimal_create(self):
- """Can create with just a creator."""
- user, _ = User.objects.get_or_create(username="testy")
- domain_request = DomainRequest.objects.create(creator=user)
- self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.STARTED)
-
- @less_console_noise_decorator
- def test_full_create(self):
- """Can create with all fields."""
- user, _ = User.objects.get_or_create(username="testy")
- contact = Contact.objects.create()
- com_website, _ = Website.objects.get_or_create(website="igorville.com")
- gov_website, _ = Website.objects.get_or_create(website="igorville.gov")
- domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
- domain_request = DomainRequest.objects.create(
- creator=user,
- investigator=user,
- generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
- federal_type=BranchChoices.EXECUTIVE,
- is_election_board=False,
- organization_name="Test",
- address_line1="100 Main St.",
- address_line2="APT 1A",
- state_territory="CA",
- zipcode="12345-6789",
- senior_official=contact,
- requested_domain=domain,
- purpose="Igorville rules!",
- anything_else="All of Igorville loves the dotgov program.",
- is_policy_acknowledged=True,
- )
- domain_request.current_websites.add(com_website)
- domain_request.alternative_domains.add(gov_website)
- domain_request.other_contacts.add(contact)
- domain_request.save()
-
- @less_console_noise_decorator
- def test_domain_info(self):
- """Can create domain info with all fields."""
- user, _ = User.objects.get_or_create(username="testy")
- contact = Contact.objects.create()
- domain, _ = Domain.objects.get_or_create(name="igorville.gov")
- information = DomainInformation.objects.create(
- creator=user,
- generic_org_type=DomainInformation.OrganizationChoices.FEDERAL,
- federal_type=BranchChoices.EXECUTIVE,
- is_election_board=False,
- organization_name="Test",
- address_line1="100 Main St.",
- address_line2="APT 1A",
- state_territory="CA",
- zipcode="12345-6789",
- senior_official=contact,
- purpose="Igorville rules!",
- anything_else="All of Igorville loves the dotgov program.",
- is_policy_acknowledged=True,
- domain=domain,
- )
- information.other_contacts.add(contact)
- information.save()
- self.assertEqual(information.domain.id, domain.id)
- self.assertEqual(information.id, domain.domain_info.id)
-
- @less_console_noise_decorator
- def test_status_fsm_submit_fail(self):
- user, _ = User.objects.get_or_create(username="testy")
- domain_request = DomainRequest.objects.create(creator=user)
-
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- with less_console_noise():
- with self.assertRaises(ValueError):
- # can't submit a domain request with a null domain name
- domain_request.submit()
-
- @less_console_noise_decorator
- def test_status_fsm_submit_succeed(self):
- user, _ = User.objects.get_or_create(username="testy")
- site = DraftDomain.objects.create(name="igorville.gov")
- domain_request = DomainRequest.objects.create(creator=user, requested_domain=site)
-
- # no email sent to creator so this emits a log warning
-
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- with less_console_noise():
- domain_request.submit()
- self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED)
-
- @less_console_noise_decorator
- def check_email_sent(
- self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
- ):
- """Check if an email was sent after performing an action."""
- email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)
- with self.subTest(msg=msg, action=action):
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- # Perform the specified action
- action_method = getattr(domain_request, action)
- action_method()
-
- # Check if an email was sent
- sent_emails = [
- email
- for email in MockSESClient.EMAILS_SENT
- if expected_email in email["kwargs"]["Destination"]["ToAddresses"]
- ]
- self.assertEqual(len(sent_emails), expected_count)
-
- if expected_content:
- email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
- self.assertIn(expected_content, email_content)
-
- email_allowed.delete()
-
- @less_console_noise_decorator
- def test_submit_from_started_sends_email_to_creator(self):
- """tests that we send an email to the creator"""
- msg = "Create a domain request and submit it and see if email was sent when the feature flag is on."
- domain_request = completed_domain_request(user=self.dummy_user_2)
- self.check_email_sent(
- domain_request, msg, "submit", 1, expected_content="Lava", expected_email="intern@igorville.com"
- )
-
- @less_console_noise_decorator
- def test_submit_from_withdrawn_sends_email(self):
- msg = "Create a withdrawn domain request and submit it and see if email was sent."
- user, _ = User.objects.get_or_create(username="testy")
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user)
- self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email)
-
- @less_console_noise_decorator
- def test_submit_from_action_needed_does_not_send_email(self):
- msg = "Create a domain request with ACTION_NEEDED status and submit it, check if email was not sent."
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.ACTION_NEEDED)
- self.check_email_sent(domain_request, msg, "submit", 0)
-
- @less_console_noise_decorator
- def test_submit_from_in_review_does_not_send_email(self):
- msg = "Create a withdrawn domain request and submit it and see if email was sent."
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
- self.check_email_sent(domain_request, msg, "submit", 0)
-
- @less_console_noise_decorator
- def test_approve_sends_email(self):
- msg = "Create a domain request and approve it and see if email was sent."
- user, _ = User.objects.get_or_create(username="testy")
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
- self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email)
-
- @less_console_noise_decorator
- def test_withdraw_sends_email(self):
- msg = "Create a domain request and withdraw it and see if email was sent."
- user, _ = User.objects.get_or_create(username="testy")
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
- self.check_email_sent(
- domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email
- )
-
- @less_console_noise_decorator
- def test_reject_sends_email(self):
- msg = "Create a domain request and reject it and see if email was sent."
- user, _ = User.objects.get_or_create(username="testy")
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user)
- self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hi", expected_email=user.email)
-
- @less_console_noise_decorator
- def test_reject_with_prejudice_does_not_send_email(self):
- msg = "Create a domain request and reject it with prejudice and see if email was sent."
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED)
- self.check_email_sent(domain_request, msg, "reject_with_prejudice", 0)
-
- @less_console_noise_decorator
- def assert_fsm_transition_raises_error(self, test_cases, method_to_run):
- """Given a list of test cases, check if each transition throws the intended error"""
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise():
- for domain_request, exception_type in test_cases:
- with self.subTest(domain_request=domain_request, exception_type=exception_type):
- with self.assertRaises(exception_type):
- # Retrieve the method by name from the domain_request object and call it
- method = getattr(domain_request, method_to_run)
- # Call the method
- method()
-
- @less_console_noise_decorator
- def assert_fsm_transition_does_not_raise_error(self, test_cases, method_to_run):
- """Given a list of test cases, ensure that none of them throw transition errors"""
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise():
- for domain_request, exception_type in test_cases:
- with self.subTest(domain_request=domain_request, exception_type=exception_type):
- try:
- # Retrieve the method by name from the DomainRequest object and call it
- method = getattr(domain_request, method_to_run)
- # Call the method
- method()
- except exception_type:
- self.fail(f"{exception_type} was raised, but it was not expected.")
-
- @less_console_noise_decorator
- def test_submit_transition_allowed_with_no_investigator(self):
- """
- Tests for attempting to transition without an investigator.
- For submit, this should be valid in all cases.
- """
-
- test_cases = [
- (self.started_domain_request, TransitionNotAllowed),
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.withdrawn_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to none
- set_domain_request_investigators(self.all_domain_requests, None)
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "submit")
-
- @less_console_noise_decorator
- def test_submit_transition_allowed_with_investigator_not_staff(self):
- """
- Tests for attempting to transition with an investigator user that is not staff.
- For submit, this should be valid in all cases.
- """
-
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to a user with no staff privs
- user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
- set_domain_request_investigators(self.all_domain_requests, user)
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "submit")
-
- @less_console_noise_decorator
- def test_submit_transition_allowed(self):
- """
- Test that calling submit from allowable statuses does raises TransitionNotAllowed.
- """
- test_cases = [
- (self.started_domain_request, TransitionNotAllowed),
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.withdrawn_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "submit")
-
- @less_console_noise_decorator
- def test_submit_transition_allowed_twice(self):
- """
- Test that rotating between submit and in_review doesn't throw an error
- """
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- try:
- # Make a submission
- self.in_review_domain_request.submit()
-
- # Rerun the old method to get back to the original state
- self.in_review_domain_request.in_review()
-
- # Make another submission
- self.in_review_domain_request.submit()
- except TransitionNotAllowed:
- self.fail("TransitionNotAllowed was raised, but it was not expected.")
-
- self.assertEqual(self.in_review_domain_request.status, DomainRequest.DomainRequestStatus.SUBMITTED)
-
- @less_console_noise_decorator
- def test_submit_transition_not_allowed(self):
- """
- Test that calling submit against transition rules raises TransitionNotAllowed.
- """
- test_cases = [
- (self.submitted_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_raises_error(test_cases, "submit")
-
- @less_console_noise_decorator
- def test_in_review_transition_allowed(self):
- """
- Test that calling in_review from allowable statuses does raises TransitionNotAllowed.
- """
- test_cases = [
- (self.submitted_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "in_review")
-
- @less_console_noise_decorator
- def test_in_review_transition_not_allowed_with_no_investigator(self):
- """
- Tests for attempting to transition without an investigator
- """
-
- test_cases = [
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to none
- set_domain_request_investigators(self.all_domain_requests, None)
-
- self.assert_fsm_transition_raises_error(test_cases, "in_review")
-
- @less_console_noise_decorator
- def test_in_review_transition_not_allowed_with_investigator_not_staff(self):
- """
- Tests for attempting to transition with an investigator that is not staff.
- This should throw an exception.
- """
-
- test_cases = [
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to a user with no staff privs
- user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
- set_domain_request_investigators(self.all_domain_requests, user)
-
- self.assert_fsm_transition_raises_error(test_cases, "in_review")
-
- @less_console_noise_decorator
- def test_in_review_transition_not_allowed(self):
- """
- Test that calling in_review against transition rules raises TransitionNotAllowed.
- """
- test_cases = [
- (self.started_domain_request, TransitionNotAllowed),
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.withdrawn_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_raises_error(test_cases, "in_review")
-
- @less_console_noise_decorator
- def test_action_needed_transition_allowed(self):
- """
- Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
- """
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "action_needed")
-
- @less_console_noise_decorator
- def test_action_needed_transition_not_allowed_with_no_investigator(self):
- """
- Tests for attempting to transition without an investigator
- """
-
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to none
- set_domain_request_investigators(self.all_domain_requests, None)
-
- self.assert_fsm_transition_raises_error(test_cases, "action_needed")
-
- @less_console_noise_decorator
- def test_action_needed_transition_not_allowed_with_investigator_not_staff(self):
- """
- Tests for attempting to transition with an investigator that is not staff
- """
-
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to a user with no staff privs
- user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
- set_domain_request_investigators(self.all_domain_requests, user)
-
- self.assert_fsm_transition_raises_error(test_cases, "action_needed")
-
- @less_console_noise_decorator
- def test_action_needed_transition_not_allowed(self):
- """
- Test that calling action_needed against transition rules raises TransitionNotAllowed.
- """
- test_cases = [
- (self.started_domain_request, TransitionNotAllowed),
- (self.submitted_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.withdrawn_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_raises_error(test_cases, "action_needed")
-
- @less_console_noise_decorator
- def test_approved_transition_allowed(self):
- """
- Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
- """
- test_cases = [
- (self.submitted_domain_request, TransitionNotAllowed),
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "approve")
-
- @less_console_noise_decorator
- def test_approved_transition_not_allowed_with_no_investigator(self):
- """
- Tests for attempting to transition without an investigator
- """
-
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to none
- set_domain_request_investigators(self.all_domain_requests, None)
-
- self.assert_fsm_transition_raises_error(test_cases, "approve")
-
- @less_console_noise_decorator
- def test_approved_transition_not_allowed_with_investigator_not_staff(self):
- """
- Tests for attempting to transition with an investigator that is not staff
- """
-
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to a user with no staff privs
- user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
- set_domain_request_investigators(self.all_domain_requests, user)
-
- self.assert_fsm_transition_raises_error(test_cases, "approve")
-
- @less_console_noise_decorator
- def test_approved_skips_sending_email(self):
- """
- Test that calling .approve with send_email=False doesn't actually send
- an email
- """
-
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- self.submitted_domain_request.approve(send_email=False)
-
- # Assert that no emails were sent
- self.assertEqual(len(self.mock_client.EMAILS_SENT), 0)
-
- @less_console_noise_decorator
- def test_approved_transition_not_allowed(self):
- """
- Test that calling action_needed against transition rules raises TransitionNotAllowed.
- """
- test_cases = [
- (self.started_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.withdrawn_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
- self.assert_fsm_transition_raises_error(test_cases, "approve")
-
- @less_console_noise_decorator
- def test_withdraw_transition_allowed(self):
- """
- Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
- """
- test_cases = [
- (self.submitted_domain_request, TransitionNotAllowed),
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw")
-
- @less_console_noise_decorator
- def test_withdraw_transition_allowed_with_no_investigator(self):
- """
- Tests for attempting to transition without an investigator.
- For withdraw, this should be valid in all cases.
- """
-
- test_cases = [
- (self.submitted_domain_request, TransitionNotAllowed),
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to none
- set_domain_request_investigators(self.all_domain_requests, None)
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw")
-
- @less_console_noise_decorator
- def test_withdraw_transition_allowed_with_investigator_not_staff(self):
- """
- Tests for attempting to transition when investigator is not staff.
- For withdraw, this should be valid in all cases.
- """
-
- test_cases = [
- (self.submitted_domain_request, TransitionNotAllowed),
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to a user with no staff privs
- user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
- set_domain_request_investigators(self.all_domain_requests, user)
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw")
-
- @less_console_noise_decorator
- def test_withdraw_transition_not_allowed(self):
- """
- Test that calling action_needed against transition rules raises TransitionNotAllowed.
- """
- test_cases = [
- (self.started_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.withdrawn_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_raises_error(test_cases, "withdraw")
-
- @less_console_noise_decorator
- def test_reject_transition_allowed(self):
- """
- Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
- """
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "reject")
-
- @less_console_noise_decorator
- def test_reject_transition_not_allowed_with_no_investigator(self):
- """
- Tests for attempting to transition without an investigator
- """
-
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to none
- set_domain_request_investigators(self.all_domain_requests, None)
-
- self.assert_fsm_transition_raises_error(test_cases, "reject")
-
- @less_console_noise_decorator
- def test_reject_transition_not_allowed_with_investigator_not_staff(self):
- """
- Tests for attempting to transition when investigator is not staff
- """
-
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to a user with no staff privs
- user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
- set_domain_request_investigators(self.all_domain_requests, user)
-
- self.assert_fsm_transition_raises_error(test_cases, "reject")
-
- @less_console_noise_decorator
- def test_reject_transition_not_allowed(self):
- """
- Test that calling action_needed against transition rules raises TransitionNotAllowed.
- """
- test_cases = [
- (self.started_domain_request, TransitionNotAllowed),
- (self.submitted_domain_request, TransitionNotAllowed),
- (self.withdrawn_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_raises_error(test_cases, "reject")
-
- @less_console_noise_decorator
- def test_reject_with_prejudice_transition_allowed(self):
- """
- Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
- """
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_does_not_raise_error(test_cases, "reject_with_prejudice")
-
- @less_console_noise_decorator
- def test_reject_with_prejudice_transition_not_allowed_with_no_investigator(self):
- """
- Tests for attempting to transition without an investigator
- """
-
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to none
- set_domain_request_investigators(self.all_domain_requests, None)
-
- self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice")
-
- @less_console_noise_decorator
- def test_reject_with_prejudice_not_allowed_with_investigator_not_staff(self):
- """
- Tests for attempting to transition when investigator is not staff
- """
-
- test_cases = [
- (self.in_review_domain_request, TransitionNotAllowed),
- (self.action_needed_domain_request, TransitionNotAllowed),
- (self.approved_domain_request, TransitionNotAllowed),
- (self.rejected_domain_request, TransitionNotAllowed),
- ]
-
- # Set all investigators to a user with no staff privs
- user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
- set_domain_request_investigators(self.all_domain_requests, user)
-
- self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice")
-
- @less_console_noise_decorator
- def test_reject_with_prejudice_transition_not_allowed(self):
- """
- Test that calling action_needed against transition rules raises TransitionNotAllowed.
- """
- test_cases = [
- (self.started_domain_request, TransitionNotAllowed),
- (self.submitted_domain_request, TransitionNotAllowed),
- (self.withdrawn_domain_request, TransitionNotAllowed),
- (self.ineligible_domain_request, TransitionNotAllowed),
- ]
-
- self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice")
-
- @less_console_noise_decorator
- def test_transition_not_allowed_approved_in_review_when_domain_is_active(self):
- """Create a domain request with status approved, create a matching domain that
- is active, and call in_review against transition rules"""
-
- domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name)
- self.approved_domain_request.approved_domain = domain
- self.approved_domain_request.save()
-
- # Define a custom implementation for is_active
- def custom_is_active(self):
- return True # Override to return True
-
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- # Use patch to temporarily replace is_active with the custom implementation
- with patch.object(Domain, "is_active", custom_is_active):
- # Now, when you call is_active on Domain, it will return True
- with self.assertRaises(TransitionNotAllowed):
- self.approved_domain_request.in_review()
-
- @less_console_noise_decorator
- def test_transition_not_allowed_approved_action_needed_when_domain_is_active(self):
- """Create a domain request with status approved, create a matching domain that
- is active, and call action_needed against transition rules"""
-
- domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name)
- self.approved_domain_request.approved_domain = domain
- self.approved_domain_request.save()
-
- # Define a custom implementation for is_active
- def custom_is_active(self):
- return True # Override to return True
-
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- # Use patch to temporarily replace is_active with the custom implementation
- with patch.object(Domain, "is_active", custom_is_active):
- # Now, when you call is_active on Domain, it will return True
- with self.assertRaises(TransitionNotAllowed):
- self.approved_domain_request.action_needed()
-
- @less_console_noise_decorator
- def test_transition_not_allowed_approved_rejected_when_domain_is_active(self):
- """Create a domain request with status approved, create a matching domain that
- is active, and call reject against transition rules"""
-
- domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name)
- self.approved_domain_request.approved_domain = domain
- self.approved_domain_request.save()
-
- # Define a custom implementation for is_active
- def custom_is_active(self):
- return True # Override to return True
-
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- # Use patch to temporarily replace is_active with the custom implementation
- with patch.object(Domain, "is_active", custom_is_active):
- # Now, when you call is_active on Domain, it will return True
- with self.assertRaises(TransitionNotAllowed):
- self.approved_domain_request.reject()
-
- @less_console_noise_decorator
- def test_transition_not_allowed_approved_ineligible_when_domain_is_active(self):
- """Create a domain request with status approved, create a matching domain that
- is active, and call reject_with_prejudice against transition rules"""
-
- domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name)
- self.approved_domain_request.approved_domain = domain
- self.approved_domain_request.save()
-
- # Define a custom implementation for is_active
- def custom_is_active(self):
- return True # Override to return True
-
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- # Use patch to temporarily replace is_active with the custom implementation
- with patch.object(Domain, "is_active", custom_is_active):
- # Now, when you call is_active on Domain, it will return True
- with self.assertRaises(TransitionNotAllowed):
- self.approved_domain_request.reject_with_prejudice()
-
- @less_console_noise_decorator
- def test_approve_from_rejected_clears_rejection_reason(self):
- """When transitioning from rejected to approved on a domain request,
- the rejection_reason is cleared."""
-
- # Create a sample domain request
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED)
- domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
-
- # Approve
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- domain_request.approve()
-
- self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED)
- self.assertEqual(domain_request.rejection_reason, None)
-
- @less_console_noise_decorator
- def test_in_review_from_rejected_clears_rejection_reason(self):
- """When transitioning from rejected to in_review on a domain request,
- the rejection_reason is cleared."""
-
- # Create a sample domain request
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED)
- domain_request.domain_is_not_active = True
- domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
-
- # Approve
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- domain_request.in_review()
-
- self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.IN_REVIEW)
- self.assertEqual(domain_request.rejection_reason, None)
-
- @less_console_noise_decorator
- def test_action_needed_from_rejected_clears_rejection_reason(self):
- """When transitioning from rejected to action_needed on a domain request,
- the rejection_reason is cleared."""
-
- # Create a sample domain request
- domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED)
- domain_request.domain_is_not_active = True
- domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
-
- # Approve
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- domain_request.action_needed()
-
- self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.ACTION_NEEDED)
- self.assertEqual(domain_request.rejection_reason, None)
-
- @less_console_noise_decorator
- def test_has_rationale_returns_true(self):
- """has_rationale() returns true when a domain request has no_other_contacts_rationale"""
- self.started_domain_request.no_other_contacts_rationale = "You talkin' to me?"
- self.started_domain_request.save()
- self.assertEquals(self.started_domain_request.has_rationale(), True)
-
- @less_console_noise_decorator
- def test_has_rationale_returns_false(self):
- """has_rationale() returns false when a domain request has no no_other_contacts_rationale"""
- self.assertEquals(self.started_domain_request.has_rationale(), False)
-
- @less_console_noise_decorator
- def test_has_other_contacts_returns_true(self):
- """has_other_contacts() returns true when a domain request has other_contacts"""
- # completed_domain_request has other contacts by default
- self.assertEquals(self.started_domain_request.has_other_contacts(), True)
-
- @less_console_noise_decorator
- def test_has_other_contacts_returns_false(self):
- """has_other_contacts() returns false when a domain request has no other_contacts"""
- domain_request = completed_domain_request(
- status=DomainRequest.DomainRequestStatus.STARTED, name="no-others.gov", has_other_contacts=False
- )
- self.assertEquals(domain_request.has_other_contacts(), False)
-
-
-class TestPermissions(TestCase):
- """Test the User-Domain-Role connection."""
-
- def setUp(self):
- super().setUp()
- self.mock_client = MockSESClient()
-
- def tearDown(self):
- super().tearDown()
- self.mock_client.EMAILS_SENT.clear()
-
- @boto3_mocking.patching
- @less_console_noise_decorator
- def test_approval_creates_role(self):
- draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
- user, _ = User.objects.get_or_create()
- investigator, _ = User.objects.get_or_create(username="frenchtoast", is_staff=True)
- domain_request = DomainRequest.objects.create(
- creator=user, requested_domain=draft_domain, investigator=investigator
- )
-
- with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
- # skip using the submit method
- domain_request.status = DomainRequest.DomainRequestStatus.SUBMITTED
- domain_request.approve()
-
- # should be a role for this user
- domain = Domain.objects.get(name="igorville.gov")
- self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain))
-
-
class TestDomainInformation(TestCase):
"""Test the DomainInformation model, when approved or otherwise"""
@@ -1332,7 +308,10 @@ class TestUserPortfolioPermission(TestCase):
self.assertEqual(
cm.exception.message,
- "Only one portfolio permission is allowed per user when multiple portfolios are disabled.",
+ (
+ "This user is already assigned to a portfolio. "
+ "Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
+ ),
)
diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py
new file mode 100644
index 000000000..9e86f5f9c
--- /dev/null
+++ b/src/registrar/tests/test_models_requests.py
@@ -0,0 +1,1029 @@
+from django.test import TestCase
+from django.db.utils import IntegrityError
+from django.db import transaction
+from unittest.mock import patch
+
+
+from registrar.models import (
+ Contact,
+ DomainRequest,
+ DomainInformation,
+ User,
+ Website,
+ Domain,
+ DraftDomain,
+ FederalAgency,
+ AllowedEmail,
+)
+
+import boto3_mocking
+from registrar.utility.constants import BranchChoices
+from registrar.utility.errors import FSMDomainRequestError
+
+from .common import (
+ MockSESClient,
+ less_console_noise,
+ completed_domain_request,
+ set_domain_request_investigators,
+)
+from django_fsm import TransitionNotAllowed
+
+from api.tests.common import less_console_noise_decorator
+
+
+@boto3_mocking.patching
+class TestDomainRequest(TestCase):
+ @less_console_noise_decorator
+ def setUp(self):
+
+ self.dummy_user, _ = Contact.objects.get_or_create(
+ email="mayor@igorville.com", first_name="Hello", last_name="World"
+ )
+ self.dummy_user_2, _ = User.objects.get_or_create(
+ username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World"
+ )
+ self.started_domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED,
+ name="started.gov",
+ )
+ self.submitted_domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.SUBMITTED,
+ name="submitted.gov",
+ )
+ self.in_review_domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ name="in-review.gov",
+ )
+ self.action_needed_domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
+ name="action-needed.gov",
+ )
+ self.approved_domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.APPROVED,
+ name="approved.gov",
+ )
+ self.withdrawn_domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.WITHDRAWN,
+ name="withdrawn.gov",
+ )
+ self.rejected_domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.REJECTED,
+ name="rejected.gov",
+ )
+ self.ineligible_domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.INELIGIBLE,
+ name="ineligible.gov",
+ )
+
+ # Store all domain request statuses in a variable for ease of use
+ self.all_domain_requests = [
+ self.started_domain_request,
+ self.submitted_domain_request,
+ self.in_review_domain_request,
+ self.action_needed_domain_request,
+ self.approved_domain_request,
+ self.withdrawn_domain_request,
+ self.rejected_domain_request,
+ self.ineligible_domain_request,
+ ]
+
+ self.mock_client = MockSESClient()
+
+ def tearDown(self):
+ super().tearDown()
+ DomainInformation.objects.all().delete()
+ DomainRequest.objects.all().delete()
+ DraftDomain.objects.all().delete()
+ Domain.objects.all().delete()
+ User.objects.all().delete()
+ self.mock_client.EMAILS_SENT.clear()
+
+ def assertNotRaises(self, exception_type):
+ """Helper method for testing allowed transitions."""
+ with less_console_noise():
+ return self.assertRaises(Exception, None, exception_type)
+
+ @less_console_noise_decorator
+ def test_request_is_withdrawable(self):
+ """Tests the is_withdrawable function"""
+ domain_request_1 = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.SUBMITTED,
+ name="city2.gov",
+ )
+ domain_request_2 = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ name="city3.gov",
+ )
+ domain_request_3 = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
+ name="city4.gov",
+ )
+ domain_request_4 = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.REJECTED,
+ name="city5.gov",
+ )
+ self.assertTrue(domain_request_1.is_withdrawable())
+ self.assertTrue(domain_request_2.is_withdrawable())
+ self.assertTrue(domain_request_3.is_withdrawable())
+ self.assertFalse(domain_request_4.is_withdrawable())
+
+ @less_console_noise_decorator
+ def test_request_is_awaiting_review(self):
+ """Tests the is_awaiting_review function"""
+ domain_request_1 = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.SUBMITTED,
+ name="city2.gov",
+ )
+ domain_request_2 = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ name="city3.gov",
+ )
+ domain_request_3 = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
+ name="city4.gov",
+ )
+ domain_request_4 = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.REJECTED,
+ name="city5.gov",
+ )
+ self.assertTrue(domain_request_1.is_awaiting_review())
+ self.assertTrue(domain_request_2.is_awaiting_review())
+ self.assertFalse(domain_request_3.is_awaiting_review())
+ self.assertFalse(domain_request_4.is_awaiting_review())
+
+ @less_console_noise_decorator
+ def test_federal_agency_set_to_non_federal_on_approve(self):
+ """Ensures that when the federal_agency field is 'none' when .approve() is called,
+ the field is set to the 'Non-Federal Agency' record"""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.IN_REVIEW,
+ name="city2.gov",
+ federal_agency=None,
+ )
+
+ # Ensure that the federal agency is None
+ self.assertEqual(domain_request.federal_agency, None)
+
+ # Approve the request
+ domain_request.approve()
+ self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED)
+
+ # After approval, it should be "Non-Federal agency"
+ expected_federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first()
+ self.assertEqual(domain_request.federal_agency, expected_federal_agency)
+
+ def test_empty_create_fails(self):
+ """Can't create a completely empty domain request."""
+ with less_console_noise():
+ with transaction.atomic():
+ with self.assertRaisesRegex(IntegrityError, "creator"):
+ DomainRequest.objects.create()
+
+ @less_console_noise_decorator
+ def test_minimal_create(self):
+ """Can create with just a creator."""
+ user, _ = User.objects.get_or_create(username="testy")
+ domain_request = DomainRequest.objects.create(creator=user)
+ self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.STARTED)
+
+ @less_console_noise_decorator
+ def test_full_create(self):
+ """Can create with all fields."""
+ user, _ = User.objects.get_or_create(username="testy")
+ contact = Contact.objects.create()
+ com_website, _ = Website.objects.get_or_create(website="igorville.com")
+ gov_website, _ = Website.objects.get_or_create(website="igorville.gov")
+ domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
+ domain_request = DomainRequest.objects.create(
+ creator=user,
+ investigator=user,
+ generic_org_type=DomainRequest.OrganizationChoices.FEDERAL,
+ federal_type=BranchChoices.EXECUTIVE,
+ is_election_board=False,
+ organization_name="Test",
+ address_line1="100 Main St.",
+ address_line2="APT 1A",
+ state_territory="CA",
+ zipcode="12345-6789",
+ senior_official=contact,
+ requested_domain=domain,
+ purpose="Igorville rules!",
+ anything_else="All of Igorville loves the dotgov program.",
+ is_policy_acknowledged=True,
+ )
+ domain_request.current_websites.add(com_website)
+ domain_request.alternative_domains.add(gov_website)
+ domain_request.other_contacts.add(contact)
+ domain_request.save()
+
+ @less_console_noise_decorator
+ def test_domain_info(self):
+ """Can create domain info with all fields."""
+ user, _ = User.objects.get_or_create(username="testy")
+ contact = Contact.objects.create()
+ domain, _ = Domain.objects.get_or_create(name="igorville.gov")
+ information = DomainInformation.objects.create(
+ creator=user,
+ generic_org_type=DomainInformation.OrganizationChoices.FEDERAL,
+ federal_type=BranchChoices.EXECUTIVE,
+ is_election_board=False,
+ organization_name="Test",
+ address_line1="100 Main St.",
+ address_line2="APT 1A",
+ state_territory="CA",
+ zipcode="12345-6789",
+ senior_official=contact,
+ purpose="Igorville rules!",
+ anything_else="All of Igorville loves the dotgov program.",
+ is_policy_acknowledged=True,
+ domain=domain,
+ )
+ information.other_contacts.add(contact)
+ information.save()
+ self.assertEqual(information.domain.id, domain.id)
+ self.assertEqual(information.id, domain.domain_info.id)
+
+ @less_console_noise_decorator
+ def test_status_fsm_submit_fail(self):
+ user, _ = User.objects.get_or_create(username="testy")
+ domain_request = DomainRequest.objects.create(creator=user)
+
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ with less_console_noise():
+ with self.assertRaises(ValueError):
+ # can't submit a domain request with a null domain name
+ domain_request.submit()
+
+ @less_console_noise_decorator
+ def test_status_fsm_submit_succeed(self):
+ user, _ = User.objects.get_or_create(username="testy")
+ site = DraftDomain.objects.create(name="igorville.gov")
+ domain_request = DomainRequest.objects.create(creator=user, requested_domain=site)
+
+ # no email sent to creator so this emits a log warning
+
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ with less_console_noise():
+ domain_request.submit()
+ self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED)
+
+ @less_console_noise_decorator
+ def check_email_sent(
+ self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
+ ):
+ """Check if an email was sent after performing an action."""
+ email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)
+ with self.subTest(msg=msg, action=action):
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ # Perform the specified action
+ action_method = getattr(domain_request, action)
+ action_method()
+
+ # Check if an email was sent
+ sent_emails = [
+ email
+ for email in MockSESClient.EMAILS_SENT
+ if expected_email in email["kwargs"]["Destination"]["ToAddresses"]
+ ]
+ self.assertEqual(len(sent_emails), expected_count)
+
+ if expected_content:
+ email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
+ self.assertIn(expected_content, email_content)
+
+ email_allowed.delete()
+
+ @less_console_noise_decorator
+ def test_submit_from_started_sends_email_to_creator(self):
+ """tests that we send an email to the creator"""
+ msg = "Create a domain request and submit it and see if email was sent when the feature flag is on."
+ domain_request = completed_domain_request(user=self.dummy_user_2)
+ self.check_email_sent(
+ domain_request, msg, "submit", 1, expected_content="Lava", expected_email="intern@igorville.com"
+ )
+
+ @less_console_noise_decorator
+ def test_submit_from_withdrawn_sends_email(self):
+ msg = "Create a withdrawn domain request and submit it and see if email was sent."
+ user, _ = User.objects.get_or_create(username="testy")
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user)
+ self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email)
+
+ @less_console_noise_decorator
+ def test_submit_from_action_needed_does_not_send_email(self):
+ msg = "Create a domain request with ACTION_NEEDED status and submit it, check if email was not sent."
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.ACTION_NEEDED)
+ self.check_email_sent(domain_request, msg, "submit", 0)
+
+ @less_console_noise_decorator
+ def test_submit_from_in_review_does_not_send_email(self):
+ msg = "Create a withdrawn domain request and submit it and see if email was sent."
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
+ self.check_email_sent(domain_request, msg, "submit", 0)
+
+ @less_console_noise_decorator
+ def test_approve_sends_email(self):
+ msg = "Create a domain request and approve it and see if email was sent."
+ user, _ = User.objects.get_or_create(username="testy")
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
+ self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email)
+
+ @less_console_noise_decorator
+ def test_withdraw_sends_email(self):
+ msg = "Create a domain request and withdraw it and see if email was sent."
+ user, _ = User.objects.get_or_create(username="testy")
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
+ self.check_email_sent(
+ domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email
+ )
+
+ @less_console_noise_decorator
+ def test_reject_sends_email(self):
+ msg = "Create a domain request and reject it and see if email was sent."
+ user, _ = User.objects.get_or_create(username="testy")
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user)
+ self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hi", expected_email=user.email)
+
+ @less_console_noise_decorator
+ def test_reject_with_prejudice_does_not_send_email(self):
+ msg = "Create a domain request and reject it with prejudice and see if email was sent."
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED)
+ self.check_email_sent(domain_request, msg, "reject_with_prejudice", 0)
+
+ @less_console_noise_decorator
+ def assert_fsm_transition_raises_error(self, test_cases, method_to_run):
+ """Given a list of test cases, check if each transition throws the intended error"""
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise():
+ for domain_request, exception_type in test_cases:
+ with self.subTest(domain_request=domain_request, exception_type=exception_type):
+ with self.assertRaises(exception_type):
+ # Retrieve the method by name from the domain_request object and call it
+ method = getattr(domain_request, method_to_run)
+ # Call the method
+ method()
+
+ @less_console_noise_decorator
+ def assert_fsm_transition_does_not_raise_error(self, test_cases, method_to_run):
+ """Given a list of test cases, ensure that none of them throw transition errors"""
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client), less_console_noise():
+ for domain_request, exception_type in test_cases:
+ with self.subTest(domain_request=domain_request, exception_type=exception_type):
+ try:
+ # Retrieve the method by name from the DomainRequest object and call it
+ method = getattr(domain_request, method_to_run)
+ # Call the method
+ method()
+ except exception_type:
+ self.fail(f"{exception_type} was raised, but it was not expected.")
+
+ @less_console_noise_decorator
+ def test_submit_transition_allowed_with_no_investigator(self):
+ """
+ Tests for attempting to transition without an investigator.
+ For submit, this should be valid in all cases.
+ """
+
+ test_cases = [
+ (self.started_domain_request, TransitionNotAllowed),
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.withdrawn_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to none
+ set_domain_request_investigators(self.all_domain_requests, None)
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "submit")
+
+ @less_console_noise_decorator
+ def test_submit_transition_allowed_with_investigator_not_staff(self):
+ """
+ Tests for attempting to transition with an investigator user that is not staff.
+ For submit, this should be valid in all cases.
+ """
+
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to a user with no staff privs
+ user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
+ set_domain_request_investigators(self.all_domain_requests, user)
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "submit")
+
+ @less_console_noise_decorator
+ def test_submit_transition_allowed(self):
+ """
+ Test that calling submit from allowable statuses does raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.started_domain_request, TransitionNotAllowed),
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.withdrawn_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "submit")
+
+ @less_console_noise_decorator
+ def test_submit_transition_allowed_twice(self):
+ """
+ Test that rotating between submit and in_review doesn't throw an error
+ """
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ try:
+ # Make a submission
+ self.in_review_domain_request.submit()
+
+ # Rerun the old method to get back to the original state
+ self.in_review_domain_request.in_review()
+
+ # Make another submission
+ self.in_review_domain_request.submit()
+ except TransitionNotAllowed:
+ self.fail("TransitionNotAllowed was raised, but it was not expected.")
+
+ self.assertEqual(self.in_review_domain_request.status, DomainRequest.DomainRequestStatus.SUBMITTED)
+
+ @less_console_noise_decorator
+ def test_submit_transition_not_allowed(self):
+ """
+ Test that calling submit against transition rules raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.submitted_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_raises_error(test_cases, "submit")
+
+ @less_console_noise_decorator
+ def test_in_review_transition_allowed(self):
+ """
+ Test that calling in_review from allowable statuses does raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.submitted_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "in_review")
+
+ @less_console_noise_decorator
+ def test_in_review_transition_not_allowed_with_no_investigator(self):
+ """
+ Tests for attempting to transition without an investigator
+ """
+
+ test_cases = [
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to none
+ set_domain_request_investigators(self.all_domain_requests, None)
+
+ self.assert_fsm_transition_raises_error(test_cases, "in_review")
+
+ @less_console_noise_decorator
+ def test_in_review_transition_not_allowed_with_investigator_not_staff(self):
+ """
+ Tests for attempting to transition with an investigator that is not staff.
+ This should throw an exception.
+ """
+
+ test_cases = [
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to a user with no staff privs
+ user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
+ set_domain_request_investigators(self.all_domain_requests, user)
+
+ self.assert_fsm_transition_raises_error(test_cases, "in_review")
+
+ @less_console_noise_decorator
+ def test_in_review_transition_not_allowed(self):
+ """
+ Test that calling in_review against transition rules raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.started_domain_request, TransitionNotAllowed),
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.withdrawn_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_raises_error(test_cases, "in_review")
+
+ @less_console_noise_decorator
+ def test_action_needed_transition_allowed(self):
+ """
+ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "action_needed")
+
+ @less_console_noise_decorator
+ def test_action_needed_transition_not_allowed_with_no_investigator(self):
+ """
+ Tests for attempting to transition without an investigator
+ """
+
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to none
+ set_domain_request_investigators(self.all_domain_requests, None)
+
+ self.assert_fsm_transition_raises_error(test_cases, "action_needed")
+
+ @less_console_noise_decorator
+ def test_action_needed_transition_not_allowed_with_investigator_not_staff(self):
+ """
+ Tests for attempting to transition with an investigator that is not staff
+ """
+
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to a user with no staff privs
+ user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
+ set_domain_request_investigators(self.all_domain_requests, user)
+
+ self.assert_fsm_transition_raises_error(test_cases, "action_needed")
+
+ @less_console_noise_decorator
+ def test_action_needed_transition_not_allowed(self):
+ """
+ Test that calling action_needed against transition rules raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.started_domain_request, TransitionNotAllowed),
+ (self.submitted_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.withdrawn_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_raises_error(test_cases, "action_needed")
+
+ @less_console_noise_decorator
+ def test_approved_transition_allowed(self):
+ """
+ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.submitted_domain_request, TransitionNotAllowed),
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "approve")
+
+ @less_console_noise_decorator
+ def test_approved_transition_not_allowed_with_no_investigator(self):
+ """
+ Tests for attempting to transition without an investigator
+ """
+
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to none
+ set_domain_request_investigators(self.all_domain_requests, None)
+
+ self.assert_fsm_transition_raises_error(test_cases, "approve")
+
+ @less_console_noise_decorator
+ def test_approved_transition_not_allowed_with_investigator_not_staff(self):
+ """
+ Tests for attempting to transition with an investigator that is not staff
+ """
+
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to a user with no staff privs
+ user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
+ set_domain_request_investigators(self.all_domain_requests, user)
+
+ self.assert_fsm_transition_raises_error(test_cases, "approve")
+
+ @less_console_noise_decorator
+ def test_approved_skips_sending_email(self):
+ """
+ Test that calling .approve with send_email=False doesn't actually send
+ an email
+ """
+
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ self.submitted_domain_request.approve(send_email=False)
+
+ # Assert that no emails were sent
+ self.assertEqual(len(self.mock_client.EMAILS_SENT), 0)
+
+ @less_console_noise_decorator
+ def test_approved_transition_not_allowed(self):
+ """
+ Test that calling approve against transition rules raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.started_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.withdrawn_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+ self.assert_fsm_transition_raises_error(test_cases, "approve")
+
+ @less_console_noise_decorator
+ def test_approved_transition_not_allowed_when_domain_already_approved(self):
+ """
+ Test that calling approve whith an already approved requested domain raises
+ TransitionNotAllowed.
+ """
+ Domain.objects.all().create(name=self.submitted_domain_request.requested_domain.name)
+ test_cases = [
+ (self.submitted_domain_request, FSMDomainRequestError),
+ ]
+ self.assert_fsm_transition_raises_error(test_cases, "approve")
+
+ @less_console_noise_decorator
+ def test_withdraw_transition_allowed(self):
+ """
+ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.submitted_domain_request, TransitionNotAllowed),
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw")
+
+ @less_console_noise_decorator
+ def test_withdraw_transition_allowed_with_no_investigator(self):
+ """
+ Tests for attempting to transition without an investigator.
+ For withdraw, this should be valid in all cases.
+ """
+
+ test_cases = [
+ (self.submitted_domain_request, TransitionNotAllowed),
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to none
+ set_domain_request_investigators(self.all_domain_requests, None)
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw")
+
+ @less_console_noise_decorator
+ def test_withdraw_transition_allowed_with_investigator_not_staff(self):
+ """
+ Tests for attempting to transition when investigator is not staff.
+ For withdraw, this should be valid in all cases.
+ """
+
+ test_cases = [
+ (self.submitted_domain_request, TransitionNotAllowed),
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to a user with no staff privs
+ user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
+ set_domain_request_investigators(self.all_domain_requests, user)
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "withdraw")
+
+ @less_console_noise_decorator
+ def test_withdraw_transition_not_allowed(self):
+ """
+ Test that calling action_needed against transition rules raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.started_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.withdrawn_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_raises_error(test_cases, "withdraw")
+
+ @less_console_noise_decorator
+ def test_reject_transition_allowed(self):
+ """
+ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "reject")
+
+ @less_console_noise_decorator
+ def test_reject_transition_not_allowed_with_no_investigator(self):
+ """
+ Tests for attempting to transition without an investigator
+ """
+
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to none
+ set_domain_request_investigators(self.all_domain_requests, None)
+
+ self.assert_fsm_transition_raises_error(test_cases, "reject")
+
+ @less_console_noise_decorator
+ def test_reject_transition_not_allowed_with_investigator_not_staff(self):
+ """
+ Tests for attempting to transition when investigator is not staff
+ """
+
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to a user with no staff privs
+ user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
+ set_domain_request_investigators(self.all_domain_requests, user)
+
+ self.assert_fsm_transition_raises_error(test_cases, "reject")
+
+ @less_console_noise_decorator
+ def test_reject_transition_not_allowed(self):
+ """
+ Test that calling action_needed against transition rules raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.started_domain_request, TransitionNotAllowed),
+ (self.submitted_domain_request, TransitionNotAllowed),
+ (self.withdrawn_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_raises_error(test_cases, "reject")
+
+ @less_console_noise_decorator
+ def test_reject_with_prejudice_transition_allowed(self):
+ """
+ Test that calling action_needed from allowable statuses does raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_does_not_raise_error(test_cases, "reject_with_prejudice")
+
+ @less_console_noise_decorator
+ def test_reject_with_prejudice_transition_not_allowed_with_no_investigator(self):
+ """
+ Tests for attempting to transition without an investigator
+ """
+
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to none
+ set_domain_request_investigators(self.all_domain_requests, None)
+
+ self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice")
+
+ @less_console_noise_decorator
+ def test_reject_with_prejudice_not_allowed_with_investigator_not_staff(self):
+ """
+ Tests for attempting to transition when investigator is not staff
+ """
+
+ test_cases = [
+ (self.in_review_domain_request, TransitionNotAllowed),
+ (self.action_needed_domain_request, TransitionNotAllowed),
+ (self.approved_domain_request, TransitionNotAllowed),
+ (self.rejected_domain_request, TransitionNotAllowed),
+ ]
+
+ # Set all investigators to a user with no staff privs
+ user, _ = User.objects.get_or_create(username="pancakesyrup", is_staff=False)
+ set_domain_request_investigators(self.all_domain_requests, user)
+
+ self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice")
+
+ @less_console_noise_decorator
+ def test_reject_with_prejudice_transition_not_allowed(self):
+ """
+ Test that calling action_needed against transition rules raises TransitionNotAllowed.
+ """
+ test_cases = [
+ (self.started_domain_request, TransitionNotAllowed),
+ (self.submitted_domain_request, TransitionNotAllowed),
+ (self.withdrawn_domain_request, TransitionNotAllowed),
+ (self.ineligible_domain_request, TransitionNotAllowed),
+ ]
+
+ self.assert_fsm_transition_raises_error(test_cases, "reject_with_prejudice")
+
+ @less_console_noise_decorator
+ def test_transition_not_allowed_approved_in_review_when_domain_is_active(self):
+ """Create a domain request with status approved, create a matching domain that
+ is active, and call in_review against transition rules"""
+
+ domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name)
+ self.approved_domain_request.approved_domain = domain
+ self.approved_domain_request.save()
+
+ # Define a custom implementation for is_active
+ def custom_is_active(self):
+ return True # Override to return True
+
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ # Use patch to temporarily replace is_active with the custom implementation
+ with patch.object(Domain, "is_active", custom_is_active):
+ # Now, when you call is_active on Domain, it will return True
+ with self.assertRaises(TransitionNotAllowed):
+ self.approved_domain_request.in_review()
+
+ @less_console_noise_decorator
+ def test_transition_not_allowed_approved_action_needed_when_domain_is_active(self):
+ """Create a domain request with status approved, create a matching domain that
+ is active, and call action_needed against transition rules"""
+
+ domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name)
+ self.approved_domain_request.approved_domain = domain
+ self.approved_domain_request.save()
+
+ # Define a custom implementation for is_active
+ def custom_is_active(self):
+ return True # Override to return True
+
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ # Use patch to temporarily replace is_active with the custom implementation
+ with patch.object(Domain, "is_active", custom_is_active):
+ # Now, when you call is_active on Domain, it will return True
+ with self.assertRaises(TransitionNotAllowed):
+ self.approved_domain_request.action_needed()
+
+ @less_console_noise_decorator
+ def test_transition_not_allowed_approved_rejected_when_domain_is_active(self):
+ """Create a domain request with status approved, create a matching domain that
+ is active, and call reject against transition rules"""
+
+ domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name)
+ self.approved_domain_request.approved_domain = domain
+ self.approved_domain_request.save()
+
+ # Define a custom implementation for is_active
+ def custom_is_active(self):
+ return True # Override to return True
+
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ # Use patch to temporarily replace is_active with the custom implementation
+ with patch.object(Domain, "is_active", custom_is_active):
+ # Now, when you call is_active on Domain, it will return True
+ with self.assertRaises(TransitionNotAllowed):
+ self.approved_domain_request.reject()
+
+ @less_console_noise_decorator
+ def test_transition_not_allowed_approved_ineligible_when_domain_is_active(self):
+ """Create a domain request with status approved, create a matching domain that
+ is active, and call reject_with_prejudice against transition rules"""
+
+ domain = Domain.objects.create(name=self.approved_domain_request.requested_domain.name)
+ self.approved_domain_request.approved_domain = domain
+ self.approved_domain_request.save()
+
+ # Define a custom implementation for is_active
+ def custom_is_active(self):
+ return True # Override to return True
+
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ # Use patch to temporarily replace is_active with the custom implementation
+ with patch.object(Domain, "is_active", custom_is_active):
+ # Now, when you call is_active on Domain, it will return True
+ with self.assertRaises(TransitionNotAllowed):
+ self.approved_domain_request.reject_with_prejudice()
+
+ @less_console_noise_decorator
+ def test_approve_from_rejected_clears_rejection_reason(self):
+ """When transitioning from rejected to approved on a domain request,
+ the rejection_reason is cleared."""
+
+ # Create a sample domain request
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED)
+ domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
+
+ # Approve
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ domain_request.approve()
+
+ self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.APPROVED)
+ self.assertEqual(domain_request.rejection_reason, None)
+
+ @less_console_noise_decorator
+ def test_in_review_from_rejected_clears_rejection_reason(self):
+ """When transitioning from rejected to in_review on a domain request,
+ the rejection_reason is cleared."""
+
+ # Create a sample domain request
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED)
+ domain_request.domain_is_not_active = True
+ domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
+
+ # Approve
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ domain_request.in_review()
+
+ self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.IN_REVIEW)
+ self.assertEqual(domain_request.rejection_reason, None)
+
+ @less_console_noise_decorator
+ def test_action_needed_from_rejected_clears_rejection_reason(self):
+ """When transitioning from rejected to action_needed on a domain request,
+ the rejection_reason is cleared."""
+
+ # Create a sample domain request
+ domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.REJECTED)
+ domain_request.domain_is_not_active = True
+ domain_request.rejection_reason = DomainRequest.RejectionReasons.DOMAIN_PURPOSE
+
+ # Approve
+ with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
+ domain_request.action_needed()
+
+ self.assertEqual(domain_request.status, DomainRequest.DomainRequestStatus.ACTION_NEEDED)
+ self.assertEqual(domain_request.rejection_reason, None)
+
+ @less_console_noise_decorator
+ def test_has_rationale_returns_true(self):
+ """has_rationale() returns true when a domain request has no_other_contacts_rationale"""
+ self.started_domain_request.no_other_contacts_rationale = "You talkin' to me?"
+ self.started_domain_request.save()
+ self.assertEquals(self.started_domain_request.has_rationale(), True)
+
+ @less_console_noise_decorator
+ def test_has_rationale_returns_false(self):
+ """has_rationale() returns false when a domain request has no no_other_contacts_rationale"""
+ self.assertEquals(self.started_domain_request.has_rationale(), False)
+
+ @less_console_noise_decorator
+ def test_has_other_contacts_returns_true(self):
+ """has_other_contacts() returns true when a domain request has other_contacts"""
+ # completed_domain_request has other contacts by default
+ self.assertEquals(self.started_domain_request.has_other_contacts(), True)
+
+ @less_console_noise_decorator
+ def test_has_other_contacts_returns_false(self):
+ """has_other_contacts() returns false when a domain request has no other_contacts"""
+ domain_request = completed_domain_request(
+ status=DomainRequest.DomainRequestStatus.STARTED, name="no-others.gov", has_other_contacts=False
+ )
+ self.assertEquals(domain_request.has_other_contacts(), False)
diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py
index ed2b75791..eebb11422 100644
--- a/src/registrar/tests/test_reports.py
+++ b/src/registrar/tests/test_reports.py
@@ -6,7 +6,7 @@ from registrar.models import (
Domain,
UserDomainRole,
)
-from registrar.models import Portfolio
+from registrar.models import Portfolio, DraftDomain
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.csv_export import (
@@ -14,6 +14,7 @@ from registrar.utility.csv_export import (
DomainDataType,
DomainDataFederal,
DomainDataTypeUser,
+ DomainRequestsDataType,
DomainGrowth,
DomainManaged,
DomainUnmanaged,
@@ -356,11 +357,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
self.assertIn(self.domain_3.name, csv_content)
self.assertNotIn(self.domain_2.name, csv_content)
- # Test the output for readonly admin
- portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY]
- portfolio_permission.save()
- portfolio_permission.refresh_from_db()
-
# Get the csv content
csv_content = self._run_domain_data_type_user_export(request)
self.assertIn(self.domain_1.name, csv_content)
@@ -394,6 +390,77 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
return csv_content
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_requests", active=True)
+ def test_domain_request_data_type_user_with_portfolio(self):
+ """Tests DomainRequestsDataType export with portfolio permissions"""
+
+ # Create a portfolio and assign it to the user
+ portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
+ portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
+
+ # Create DraftDomain objects
+ dd_1 = DraftDomain.objects.create(name="example1.com")
+ dd_2 = DraftDomain.objects.create(name="example2.com")
+ dd_3 = DraftDomain.objects.create(name="example3.com")
+
+ # Create some domain requests
+ dr_1 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_1, portfolio=portfolio)
+ dr_2 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_2)
+ dr_3 = DomainRequest.objects.create(creator=self.user, requested_domain=dd_3, portfolio=portfolio)
+
+ # Set up user permissions
+ portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
+ portfolio_permission.save()
+ portfolio_permission.refresh_from_db()
+
+ # Make a GET request using self.client to get a request object
+ request = get_wsgi_request_object(client=self.client, user=self.user)
+
+ # Get the CSV content
+ csv_content = self._run_domain_request_data_type_user_export(request)
+
+ # We expect only domain requests associated with the user's portfolio
+ self.assertIn(dd_1.name, csv_content)
+ self.assertIn(dd_3.name, csv_content)
+ self.assertNotIn(dd_2.name, csv_content)
+
+ # Get the csv content
+ csv_content = self._run_domain_request_data_type_user_export(request)
+ self.assertIn(dd_1.name, csv_content)
+ self.assertIn(dd_3.name, csv_content)
+ self.assertNotIn(dd_2.name, csv_content)
+
+ portfolio_permission.roles = [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
+ portfolio_permission.save()
+ portfolio_permission.refresh_from_db()
+
+ # Domain Request NOT in Portfolio
+ csv_content = self._run_domain_request_data_type_user_export(request)
+ self.assertNotIn(dd_1.name, csv_content)
+ self.assertNotIn(dd_3.name, csv_content)
+ self.assertNotIn(dd_2.name, csv_content)
+
+ # Clean up the created objects
+ dr_1.delete()
+ dr_2.delete()
+ dr_3.delete()
+ portfolio.delete()
+
+ def _run_domain_request_data_type_user_export(self, request):
+ """Helper function to run the exporting_dr_data_to_csv function on DomainRequestsDataType"""
+
+ csv_file = StringIO()
+
+ DomainRequestsDataType.exporting_dr_data_to_csv(csv_file, request=request)
+
+ csv_file.seek(0)
+
+ csv_content = csv_file.read()
+
+ return csv_content
+
@less_console_noise_decorator
def test_domain_data_full(self):
"""Shows security contacts, filtered by state"""
diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py
index 8fb92df72..127b78a4a 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -1568,7 +1568,7 @@ class TestDomainSuborganization(TestDomainOverview):
# Add portfolio perms to the user object
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
- user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY]
+ user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
self.assertEqual(self.domain_information.sub_organization, suborg)
diff --git a/src/registrar/tests/test_views_members_json.py b/src/registrar/tests/test_views_members_json.py
new file mode 100644
index 000000000..75c3a3a66
--- /dev/null
+++ b/src/registrar/tests/test_views_members_json.py
@@ -0,0 +1,175 @@
+from django.urls import reverse
+
+from registrar.models.portfolio import Portfolio
+from registrar.models.user import User
+from registrar.models.user_portfolio_permission import UserPortfolioPermission
+from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
+from .test_views import TestWithUser
+from django_webtest import WebTest # type: ignore
+
+
+class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ # Create additional users
+ cls.user2 = User.objects.create(
+ username="test_user2",
+ first_name="Second",
+ last_name="User",
+ email="second@example.com",
+ phone="8003112345",
+ title="Member",
+ )
+ cls.user3 = User.objects.create(
+ username="test_user3",
+ first_name="Third",
+ last_name="User",
+ email="third@example.com",
+ phone="8003113456",
+ title="Member",
+ )
+ cls.user4 = User.objects.create(
+ username="test_user4",
+ first_name="Fourth",
+ last_name="User",
+ email="fourth@example.com",
+ phone="8003114567",
+ title="Admin",
+ )
+
+ # Create Portfolio
+ cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
+
+ # Assign permissions
+ UserPortfolioPermission.objects.create(
+ user=cls.user,
+ portfolio=cls.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+ UserPortfolioPermission.objects.create(
+ user=cls.user2,
+ portfolio=cls.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=cls.user3,
+ portfolio=cls.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ UserPortfolioPermission.objects.create(
+ user=cls.user4,
+ portfolio=cls.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
+ def setUp(self):
+ super().setUp()
+ self.app.set_user(self.user.username)
+
+ def test_get_portfolio_members_json_authenticated(self):
+ """Test that portfolio members are returned properly for an authenticated user."""
+ response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+
+ # Check pagination info
+ self.assertEqual(data["page"], 1)
+ self.assertFalse(data["has_previous"])
+ self.assertFalse(data["has_next"])
+ self.assertEqual(data["num_pages"], 1)
+ self.assertEqual(data["total"], 4)
+ self.assertEqual(data["unfiltered_total"], 4)
+
+ # Check the number of members
+ self.assertEqual(len(data["members"]), 4)
+
+ # Check member fields
+ expected_emails = {self.user.email, self.user2.email, self.user3.email, self.user4.email}
+ actual_emails = {member["email"] for member in data["members"]}
+ self.assertEqual(expected_emails, actual_emails)
+
+ def test_pagination(self):
+ """Test that pagination works properly when there are more members than page size."""
+ # Create additional members to exceed page size of 10
+ for i in range(5, 15):
+ user, _ = User.objects.get_or_create(
+ username=f"test_user{i}",
+ first_name=f"User{i}",
+ last_name=f"Last{i}",
+ email=f"user{i}@example.com",
+ phone=f"80031156{i}",
+ title="Member",
+ )
+ UserPortfolioPermission.objects.create(
+ user=user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+
+ response = self.app.get(
+ reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "page": 1}
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+
+ # Check pagination info
+ self.assertEqual(data["page"], 1)
+ self.assertTrue(data["has_next"])
+ self.assertFalse(data["has_previous"])
+ self.assertEqual(data["num_pages"], 2)
+ self.assertEqual(data["total"], 14)
+ self.assertEqual(data["unfiltered_total"], 14)
+
+ # Check the number of members on page 1
+ self.assertEqual(len(data["members"]), 10)
+
+ response = self.app.get(
+ reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "page": 2}
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+
+ # Check pagination info for page 2
+ self.assertEqual(data["page"], 2)
+ self.assertFalse(data["has_next"])
+ self.assertTrue(data["has_previous"])
+ self.assertEqual(data["num_pages"], 2)
+
+ # Check the number of members on page 2
+ self.assertEqual(len(data["members"]), 4)
+
+ def test_search(self):
+ """Test search functionality for portfolio members."""
+ # Search by name
+ response = self.app.get(
+ reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "Second"}
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+ self.assertEqual(len(data["members"]), 1)
+ self.assertEqual(data["members"][0]["name"], "Second User")
+ self.assertEqual(data["members"][0]["email"], "second@example.com")
+
+ # Search by email
+ response = self.app.get(
+ reverse("get_portfolio_members_json"),
+ params={"portfolio": self.portfolio.id, "search_term": "fourth@example.com"},
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+ self.assertEqual(len(data["members"]), 1)
+ self.assertEqual(data["members"][0]["email"], "fourth@example.com")
+
+ # Search with no matching results
+ response = self.app.get(
+ reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "NonExistent"}
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json
+ self.assertEqual(len(data["members"]), 0)
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index e7c593a45..dfb0469d0 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -10,6 +10,7 @@ from registrar.models import (
UserDomainRole,
User,
)
+from registrar.models.user_group import UserGroup
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .common import MockSESClient, completed_domain_request, create_test_user
@@ -666,6 +667,195 @@ class TestPortfolio(WebTest):
self.assertContains(home, "Hotel California")
self.assertContains(home, "Members")
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_cannot_view_members_table(self):
+ """Test that user without proper permission is denied access to members view"""
+
+ # Users can only view the members table if they have
+ # Portfolio Permission "view_members" selected.
+ # NOTE: Admins, by default, do NOT have permission
+ # to view/edit members. This must be enabled explicitly
+ # in the "additional permissions" section for a portfolio
+ # permission.
+ #
+ # Scenarios to test include;
+ # (1) - User is not admin and can view portfolio, but not the members table
+ # (1) - User is admin and can view portfolio, but not the members table
+
+ # --- non-admin
+ self.app.set_user(self.user.username)
+
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ ],
+ )
+ # Verify that the user cannot access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Assert the response is a 403 Forbidden
+ self.assertEqual(response.status_code, 403)
+
+ # --- admin
+ UserPortfolioPermission.objects.filter(user=self.user, portfolio=self.portfolio).update(
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ )
+
+ # Verify that the user cannot access the members page
+ # This will redirect the user to the members page.
+ response = self.client.get(reverse("members"), follow=True)
+ # Assert the response is a 403 Forbidden
+ self.assertEqual(response.status_code, 403)
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_can_view_members_table(self):
+ """Test that user with proper permission is able to access members view"""
+
+ self.app.set_user(self.user.username)
+
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ ],
+ )
+
+ # Verify that the user can access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Make sure the page loaded
+ self.assertEqual(response.status_code, 200)
+
+ # ---- Useful debugging stub to see what "assertContains" is finding
+ # pattern = r'Members'
+ # matches = re.findall(pattern, response.content.decode('utf-8'))
+ # for match in matches:
+ # TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{match}")
+
+ # Make sure the page loaded
+ self.assertContains(response, "Members")
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_can_manage_members(self):
+ """Test that user with proper permission is able to manage members"""
+ user = self.user
+ self.app.set_user(user.username)
+
+ # give user permissions to view AND manage members
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
+ # Give user permissions to modify user objects in the DB
+ group, _ = UserGroup.objects.get_or_create(name="full_access_group")
+ # Add the user to the group
+ user.groups.set([group])
+
+ # Verify that the user can access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Make sure the page loaded
+ self.assertEqual(response.status_code, 200)
+
+ # Verify that manage settings are sent in the dynamic HTML
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
+ self.assertContains(response, '"action_label": "Manage"')
+ self.assertContains(response, '"svg_icon": "settings"')
+
+ @less_console_noise_decorator
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_view_only_members(self):
+ """Test that user with view only permission settings can only
+ view members (not manage them)"""
+ user = self.user
+ self.app.set_user(user.username)
+
+ # give user permissions to view AND manage members
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ ],
+ )
+ # Give user permissions to modify user objects in the DB
+ group, _ = UserGroup.objects.get_or_create(name="full_access_group")
+ # Add the user to the group
+ user.groups.set([group])
+
+ # Verify that the user can access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Make sure the page loaded
+ self.assertEqual(response.status_code, 200)
+
+ # Verify that view-only settings are sent in the dynamic HTML
+ response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
+ print(response.content)
+ self.assertContains(response, '"action_label": "View"')
+ self.assertContains(response, '"svg_icon": "visibility"')
+
+ @override_flag("organization_feature", active=True)
+ @override_flag("organization_members", active=True)
+ def test_members_admin_detection(self):
+ """Test that user with proper permission is able to manage members"""
+ user = self.user
+ self.app.set_user(user.username)
+
+ # give user permissions to view AND manage members
+ UserPortfolioPermission.objects.get_or_create(
+ user=self.user,
+ portfolio=self.portfolio,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
+ additional_permissions=[
+ UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
+ UserPortfolioPermissionChoices.VIEW_MEMBERS,
+ UserPortfolioPermissionChoices.EDIT_MEMBERS,
+ ],
+ )
+
+ # Give user permissions to modify user objects in the DB
+ group, _ = UserGroup.objects.get_or_create(name="full_access_group")
+ # Add the user to the group
+ user.groups.set([group])
+
+ # Verify that the user can access the members page
+ # This will redirect the user to the members page.
+ self.client.force_login(self.user)
+ response = self.client.get(reverse("members"), follow=True)
+ # Make sure the page loaded
+ self.assertEqual(response.status_code, 200)
+ # Verify that admin info is sent in the dynamic HTML
+ response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
+ # TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}")
+ self.assertContains(response, '"is_admin": true')
+
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_portfolio_domain_requests_page_when_user_has_no_permissions(self):
diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py
index 8530859e2..ff2e61939 100644
--- a/src/registrar/tests/test_views_request.py
+++ b/src/registrar/tests/test_views_request.py
@@ -82,7 +82,6 @@ class DomainRequestTests(TestWithUser, WebTest):
response = self.app.get(f"/domain-request/{domain_request.id}")
# Ensure that the date is still set to None
self.assertIsNone(domain_request.last_status_update)
- print(response)
# We should still grab a date for this field in this event - but it should come from the audit log instead
self.assertContains(response, "Started on:")
self.assertContains(response, fixed_date.strftime("%B %-d, %Y"))
diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py
index 0b99bba13..2af9d0b3c 100644
--- a/src/registrar/utility/admin_helpers.py
+++ b/src/registrar/utility/admin_helpers.py
@@ -1,5 +1,9 @@
from registrar.models.domain_request import DomainRequest
from django.template.loader import get_template
+from django.utils.html import format_html
+from django.urls import reverse
+from django.utils.html import escape
+from registrar.models.utility.generic_helper import value_of_attribute
def get_all_action_needed_reason_emails(request, domain_request):
@@ -34,3 +38,56 @@ def get_action_needed_reason_default_email(request, domain_request, action_neede
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
return email_body_text_cleaned
+
+
+def get_field_links_as_list(
+ queryset,
+ model_name,
+ attribute_name=None,
+ link_info_attribute=None,
+ separator=None,
+ msg_for_none="-",
+):
+ """
+ Generate HTML links for items in a queryset, using a specified attribute for link text.
+
+ Args:
+ queryset: The queryset of items to generate links for.
+ model_name: The model name used to construct the admin change URL.
+ attribute_name: The attribute or method name to use for link text. If None, the item itself is used.
+ link_info_attribute: Appends f"({value_of_attribute})" to the end of the link.
+ separator: The separator to use between links in the resulting HTML.
+ If none, an unordered list is returned.
+ msg_for_none: What to return when the field would otherwise display None.
+ Defaults to `-`.
+
+ Returns:
+ A formatted HTML string with links to the admin change pages for each item.
+ """
+ links = []
+ for item in queryset:
+
+ # This allows you to pass in attribute_name="get_full_name" for instance.
+ if attribute_name:
+ item_display_value = value_of_attribute(item, attribute_name)
+ else:
+ item_display_value = item
+
+ if item_display_value:
+ change_url = reverse(f"admin:registrar_{model_name}_change", args=[item.pk])
+
+ link = f'{escape(item_display_value)} '
+ if link_info_attribute:
+ link += f" ({value_of_attribute(item, link_info_attribute)})"
+
+ if separator:
+ links.append(link)
+ else:
+ links.append(f"{link} ")
+
+ # If no separator is specified, just return an unordered list.
+ if separator:
+ return format_html(separator.join(links)) if links else msg_for_none
+ else:
+ links = "".join(links)
+ return format_html(f'') if links else msg_for_none
diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py
index 7ca3b7e97..ce710ef53 100644
--- a/src/registrar/utility/csv_export.py
+++ b/src/registrar/utility/csv_export.py
@@ -583,6 +583,105 @@ class DomainDataTypeUser(DomainDataType):
return Q(domain__id__in=request.user.get_user_domain_ids(request))
+class DomainRequestsDataType:
+ """
+ The DomainRequestsDataType report, but filtered based on the current request user
+ """
+
+ @classmethod
+ def get_filter_conditions(cls, request=None):
+ if request is None or not hasattr(request, "user") or not request.user.is_authenticated:
+ return Q(id__in=[])
+
+ request_ids = request.user.get_user_domain_request_ids(request)
+ return Q(id__in=request_ids)
+
+ @classmethod
+ def get_queryset(cls, request):
+ return DomainRequest.objects.filter(cls.get_filter_conditions(request))
+
+ def safe_get(attribute, default="N/A"):
+ # Return the attribute value or default if not present
+ return attribute if attribute is not None else default
+
+ @classmethod
+ def exporting_dr_data_to_csv(cls, response, request=None):
+ import csv
+
+ writer = csv.writer(response)
+
+ # CSV headers
+ writer.writerow(
+ [
+ "Domain request",
+ "Region",
+ "Status",
+ "Election office",
+ "Federal type",
+ "Domain type",
+ "Request additional details",
+ "Creator approved domains count",
+ "Creator active requests count",
+ "Alternative domains",
+ "Other contacts",
+ "Current websites",
+ "Federal agency",
+ "SO first name",
+ "SO last name",
+ "SO email",
+ "SO title/role",
+ "Creator first name",
+ "Creator last name",
+ "Creator email",
+ "Organization name",
+ "City",
+ "State/territory",
+ "Request purpose",
+ "CISA regional representative",
+ "Last submitted date",
+ "First submitted date",
+ "Last status update",
+ ]
+ )
+
+ queryset = cls.get_queryset(request)
+ for request in queryset:
+ writer.writerow(
+ [
+ request.requested_domain,
+ cls.safe_get(getattr(request, "region_field", None)),
+ request.status,
+ cls.safe_get(getattr(request, "election_office", None)),
+ request.federal_type,
+ cls.safe_get(getattr(request, "domain_type", None)),
+ cls.safe_get(getattr(request, "additional_details", None)),
+ cls.safe_get(getattr(request, "creator_approved_domains_count", None)),
+ cls.safe_get(getattr(request, "creator_active_requests_count", None)),
+ cls.safe_get(getattr(request, "all_alternative_domains", None)),
+ cls.safe_get(getattr(request, "all_other_contacts", None)),
+ cls.safe_get(getattr(request, "all_current_websites", None)),
+ cls.safe_get(getattr(request, "federal_agency", None)),
+ cls.safe_get(getattr(request.senior_official, "first_name", None)),
+ cls.safe_get(getattr(request.senior_official, "last_name", None)),
+ cls.safe_get(getattr(request.senior_official, "email", None)),
+ cls.safe_get(getattr(request.senior_official, "title", None)),
+ cls.safe_get(getattr(request.creator, "first_name", None)),
+ cls.safe_get(getattr(request.creator, "last_name", None)),
+ cls.safe_get(getattr(request.creator, "email", None)),
+ cls.safe_get(getattr(request, "organization_name", None)),
+ cls.safe_get(getattr(request, "city", None)),
+ cls.safe_get(getattr(request, "state_territory", None)),
+ cls.safe_get(getattr(request, "purpose", None)),
+ cls.safe_get(getattr(request, "cisa_representative_email", None)),
+ cls.safe_get(getattr(request, "last_submitted_date", None)),
+ cls.safe_get(getattr(request, "first_submitted_date", None)),
+ cls.safe_get(getattr(request, "last_status_update", None)),
+ ]
+ )
+
+ return response
+
+
class DomainDataFull(DomainExport):
"""
Shows security contacts, filtered by state
diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py
new file mode 100644
index 000000000..133e6750e
--- /dev/null
+++ b/src/registrar/views/portfolio_members_json.py
@@ -0,0 +1,125 @@
+from django.http import JsonResponse
+from django.core.paginator import Paginator
+from django.contrib.auth.decorators import login_required
+from django.db.models import Q
+
+from registrar.models.portfolio_invitation import PortfolioInvitation
+from registrar.models.user import User
+from registrar.models.user_portfolio_permission import UserPortfolioPermission
+from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
+
+
+@login_required
+def get_portfolio_members_json(request):
+ """Given the current request,
+ get all members that are associated with the given portfolio"""
+ portfolio = request.GET.get("portfolio")
+ member_ids = get_member_ids_from_request(request, portfolio)
+ objects = User.objects.filter(id__in=member_ids)
+
+ admin_ids = UserPortfolioPermission.objects.filter(
+ portfolio=portfolio,
+ roles__overlap=[
+ UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
+ ],
+ ).values_list("user__id", flat=True)
+ portfolio_invitation_emails = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list(
+ "email", flat=True
+ )
+
+ unfiltered_total = objects.count()
+
+ objects = apply_search(objects, request)
+ # objects = apply_status_filter(objects, request)
+ objects = apply_sorting(objects, request)
+
+ paginator = Paginator(objects, 10)
+ page_number = request.GET.get("page", 1)
+ page_obj = paginator.get_page(page_number)
+ members = [
+ serialize_members(request, portfolio, member, request.user, admin_ids, portfolio_invitation_emails)
+ for member in page_obj.object_list
+ ]
+
+ return JsonResponse(
+ {
+ "members": members,
+ "page": page_obj.number,
+ "num_pages": paginator.num_pages,
+ "has_previous": page_obj.has_previous(),
+ "has_next": page_obj.has_next(),
+ "total": paginator.count,
+ "unfiltered_total": unfiltered_total,
+ }
+ )
+
+
+def get_member_ids_from_request(request, portfolio):
+ """Given the current request,
+ get all members that are associated with the given portfolio"""
+ member_ids = []
+ if portfolio:
+ member_ids = UserPortfolioPermission.objects.filter(portfolio=portfolio).values_list("user__id", flat=True)
+ return member_ids
+
+
+def apply_search(queryset, request):
+ search_term = request.GET.get("search_term")
+
+ if search_term:
+ queryset = queryset.filter(
+ Q(username__icontains=search_term)
+ | Q(first_name__icontains=search_term)
+ | Q(last_name__icontains=search_term)
+ | Q(email__icontains=search_term)
+ )
+ return queryset
+
+
+def apply_sorting(queryset, request):
+ sort_by = request.GET.get("sort_by", "id") # Default to 'id'
+ order = request.GET.get("order", "asc") # Default to 'asc'
+
+ if sort_by == "member":
+ sort_by = ["email", "first_name", "middle_name", "last_name"]
+ else:
+ sort_by = [sort_by]
+
+ if order == "desc":
+ sort_by = [f"-{field}" for field in sort_by]
+
+ return queryset.order_by(*sort_by)
+
+
+def serialize_members(request, portfolio, member, user, admin_ids, portfolio_invitation_emails):
+ # ------- VIEW ONLY
+ # If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link.
+ # If view_only (the user only has view user permissions), show the "View" link (no gear icon).
+ # We check on user_group_permision to account for the upcoming "Manage portfolio" button on admin.
+ user_can_edit_other_users = False
+ for user_group_permission in ["registrar.full_access_permission", "registrar.change_user"]:
+ if user.has_perm(user_group_permission):
+ user_can_edit_other_users = True
+ break
+
+ view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
+
+ # ------- USER STATUSES
+ is_invited = member.email in portfolio_invitation_emails
+ last_active = "Invited" if is_invited else "Unknown"
+ if member.last_login:
+ last_active = member.last_login.strftime("%b. %d, %Y")
+ is_admin = member.id in admin_ids
+
+ # ------- SERIALIZE
+ member_json = {
+ "id": member.id,
+ "name": member.get_formatted_name(),
+ "email": member.email,
+ "is_admin": is_admin,
+ "last_active": last_active,
+ "action_url": "#", # reverse("members", kwargs={"pk": member.id}), # TODO: Future ticket?
+ "action_label": ("View" if view_only else "Manage"),
+ "svg_icon": ("visibility" if view_only else "settings"),
+ }
+ return member_json
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index 885dca636..552fdb6ff 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -12,6 +12,7 @@ from registrar.views.utility.permission_views import (
PortfolioDomainsPermissionView,
PortfolioBasePermissionView,
NoPortfolioDomainsPermissionView,
+ PortfolioMembersPermissionView,
)
from django.views.generic import View
from django.views.generic.edit import FormMixin
@@ -41,6 +42,15 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
return render(request, "portfolio_requests.html")
+class PortfolioMembersView(PortfolioMembersPermissionView, View):
+
+ template_name = "portfolio_members.html"
+
+ def get(self, request):
+ """Add additional context data to the template."""
+ return render(request, "portfolio_members.html")
+
+
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains.
This is a custom view which explains that to the user - and denotes who to contact.
diff --git a/src/registrar/views/report_views.py b/src/registrar/views/report_views.py
index abdbd37c9..d9c4d192c 100644
--- a/src/registrar/views/report_views.py
+++ b/src/registrar/views/report_views.py
@@ -169,6 +169,17 @@ class ExportDataTypeUser(View):
return response
+class ExportDataTypeRequests(View):
+ """Returns a domain requests report for a given user on the request"""
+
+ def get(self, request, *args, **kwargs):
+ response = HttpResponse(content_type="text/csv")
+ response["Content-Disposition"] = 'attachment; filename="domain-requests.csv"'
+ csv_export.DomainRequestsDataType.exporting_dr_data_to_csv(response, request=request)
+
+ return response
+
+
class ExportDataFull(View):
def get(self, request, *args, **kwargs):
# Smaller export based on 1
diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py
index 6a6269baa..f9522e2e9 100644
--- a/src/registrar/views/utility/api_views.py
+++ b/src/registrar/views/utility/api_views.py
@@ -55,11 +55,9 @@ def get_federal_and_portfolio_types_from_federal_agency_json(request):
portfolio_type = None
agency_name = request.GET.get("agency_name")
- organization_type = request.GET.get("organization_type")
agency = FederalAgency.objects.filter(agency=agency_name).first()
if agency:
federal_type = Portfolio.get_federal_type(agency)
- portfolio_type = Portfolio.get_portfolio_type(organization_type, federal_type)
federal_type = BranchChoices.get_branch_label(federal_type) if federal_type else "-"
response_data = {
diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py
index d8c48e01e..2cb2a599b 100644
--- a/src/registrar/views/utility/mixins.py
+++ b/src/registrar/views/utility/mixins.py
@@ -490,7 +490,7 @@ class PortfolioMembersPermission(PortfolioBasePermission):
up from the portfolio's primary key in self.kwargs["pk"]"""
portfolio = self.request.session.get("portfolio")
- if not self.request.user.has_view_members(portfolio):
+ if not self.request.user.has_view_members_portfolio_permission(portfolio):
return False
return super().has_permission()