From 4dd16ec37058486a6b9f9f729eb77c8f815f75a0 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 3 Mar 2025 16:53:28 -0500 Subject: [PATCH 01/64] add omb analyst group, add omb analyst permission, refine is_staff decorator as appropriate --- src/registrar/decorators.py | 6 ++ .../migrations/0141_create_groups_v18.py | 38 +++++++ src/registrar/models/user.py | 1 + src/registrar/models/user_group.py | 98 +++++++++++++++++++ src/registrar/views/report_views.py | 20 ++-- src/registrar/views/transfer_user.py | 4 +- src/registrar/views/utility/api_views.py | 50 ++-------- 7 files changed, 162 insertions(+), 55 deletions(-) create mode 100644 src/registrar/migrations/0141_create_groups_v18.py diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py index 7cb2792f4..8188b152e 100644 --- a/src/registrar/decorators.py +++ b/src/registrar/decorators.py @@ -6,6 +6,9 @@ from registrar.models import Domain, DomainInformation, DomainInvitation, Domain # Constants for clarity ALL = "all" IS_STAFF = "is_staff" +IS_CISA_ANALYST = "is_cisa_analyst" +IS_OMB_ANALYST = "is_omb_analyst" +IS_FULL_ACCESS = "is_full_access" IS_DOMAIN_MANAGER = "is_domain_manager" IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator" IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain" @@ -101,6 +104,9 @@ def _user_has_permission(user, request, rules, **kwargs): # Define permission checks permission_checks = [ (IS_STAFF, lambda: user.is_staff), + (IS_CISA_ANALYST, lambda: user.has_perm("registrar.analyst_access_permission")), + (IS_OMB_ANALYST, lambda: user.has_perm("registrar.omb_analyst_access_permission")), + (IS_FULL_ACCESS, lambda: user.has_perm("registrar.full_access_permission")), (IS_DOMAIN_MANAGER, lambda: _is_domain_manager(user, **kwargs)), (IS_STAFF_MANAGING_DOMAIN, lambda: _is_staff_managing_domain(request, **kwargs)), (IS_PORTFOLIO_MEMBER, lambda: user.is_org_user(request)), diff --git a/src/registrar/migrations/0141_create_groups_v18.py b/src/registrar/migrations/0141_create_groups_v18.py new file mode 100644 index 000000000..c416f0f1e --- /dev/null +++ b/src/registrar/migrations/0141_create_groups_v18.py @@ -0,0 +1,38 @@ +# 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_omb_analyst_group(apps, schema_editor) + UserGroup.create_full_access_group(apps, schema_editor) + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0140_alter_portfolioinvitation_additional_permissions_and_more"), + ] + + operations = [ + migrations.RunPython( + create_groups, + reverse_code=migrations.RunPython.noop, + atomic=True, + ), + ] diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index d5476ab9a..7ee4fefdf 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -40,6 +40,7 @@ class User(AbstractUser): permissions = [ ("analyst_access_permission", "Analyst Access Permission"), + ("omb_analyst_access_permission", "OMB Analyst Access Permission"), ("full_access_permission", "Full Access Permission"), ] diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index 4770f34bc..b3bddee66 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -141,6 +141,104 @@ class UserGroup(Group): except Exception as e: logger.error(f"Error creating analyst permissions group: {e}") + def create_omb_analyst_group(apps, schema_editor): + """This method gets run from a data migration.""" + + # Hard to pass self to these methods as the calls from migrations + # are only expecting apps and schema_editor, so we'll just define + # apps, schema_editor in the local scope instead + OMB_ANALYST_GROUP_PERMISSIONS = [ + { + "app_label": "registrar", + "model": "domainrequest", + "permissions": ["change_domainrequest"], + }, + { + "app_label": "registrar", + "model": "domain", + "permissions": ["view_domain"], + }, + { + "app_label": "registrar", + "model": "user", + "permissions": ["omb_analyst_access_permission"], + }, + { + "app_label": "registrar", + "model": "domaininvitation", + "permissions": ["view_domaininvitation"], + }, + { + "app_label": "registrar", + "model": "federalagency", + "permissions": ["change_federalagency", "delete_federalagency"], + }, + { + "app_label": "registrar", + "model": "portfolio", + "permissions": ["change_portfolio", "delete_portfolio"], + }, + { + "app_label": "registrar", + "model": "suborganization", + "permissions": ["change_suborganization", "delete_suborganization"], + }, + { + "app_label": "registrar", + "model": "seniorofficial", + "permissions": ["change_seniorofficial", "delete_seniorofficial"], + }, + ] + + # Avoid error: You can't execute queries until the end + # of the 'atomic' block. + # From django docs: + # https://docs.djangoproject.com/en/4.2/topics/migrations/#data-migrations + # We can’t import the Person model directly as it may be a newer + # version than this migration expects. We use the historical version. + ContentType = apps.get_model("contenttypes", "ContentType") + Permission = apps.get_model("auth", "Permission") + UserGroup = apps.get_model("registrar", "UserGroup") + + logger.info("Going to create the OMB Analyst Group") + try: + omb_analysts_group, _ = UserGroup.objects.get_or_create( + name="omb_analysts_group", + ) + + omb_analysts_group.permissions.clear() + + for permission in OMB_ANALYST_GROUP_PERMISSIONS: + app_label = permission["app_label"] + model_name = permission["model"] + permissions = permission["permissions"] + + # Retrieve the content type for the app and model + content_type = ContentType.objects.get(app_label=app_label, model=model_name) + + # Retrieve the permissions based on their codenames + permissions = Permission.objects.filter(content_type=content_type, codename__in=permissions) + + # Assign the permissions to the group + omb_analysts_group.permissions.add(*permissions) + + # Convert the permissions QuerySet to a list of codenames + permission_list = list(permissions.values_list("codename", flat=True)) + + logger.debug( + app_label + + " | " + + model_name + + " | " + + ", ".join(permission_list) + + " added to group " + + omb_analysts_group.name + ) + + logger.debug("OMB Analyst permissions added to group " + omb_analysts_group.name) + except Exception as e: + logger.error(f"Error creating analyst permissions group: {e}") + def create_full_access_group(apps, schema_editor): """This method gets run from a data migration.""" diff --git a/src/registrar/views/report_views.py b/src/registrar/views/report_views.py index c07dcfc1b..7f1e63e32 100644 --- a/src/registrar/views/report_views.py +++ b/src/registrar/views/report_views.py @@ -6,7 +6,7 @@ from django.shortcuts import render from django.contrib import admin from django.db.models import Avg, F -from registrar.decorators import ALL, HAS_PORTFOLIO_MEMBERS_VIEW, IS_STAFF, grant_access +from registrar.decorators import ALL, HAS_PORTFOLIO_MEMBERS_VIEW, IS_CISA_ANALYST, IS_FULL_ACCESS, grant_access from .. import models import datetime from django.utils import timezone @@ -16,7 +16,7 @@ import logging logger = logging.getLogger(__name__) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class AnalyticsView(View): def get(self, request): thirty_days_ago = datetime.datetime.today() - datetime.timedelta(days=30) @@ -176,7 +176,7 @@ class AnalyticsView(View): return render(request, "admin/analytics.html", context) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataType(View): def get(self, request, *args, **kwargs): # match the CSV example with all the fields @@ -227,7 +227,7 @@ class ExportMembersPortfolio(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataFull(View): def get(self, request, *args, **kwargs): # Smaller export based on 1 @@ -237,7 +237,7 @@ class ExportDataFull(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataFederal(View): def get(self, request, *args, **kwargs): # Federal only @@ -247,7 +247,7 @@ class ExportDataFederal(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDomainRequestDataFull(View): """Generates a downloaded report containing all Domain Requests (except started)""" @@ -259,7 +259,7 @@ class ExportDomainRequestDataFull(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataDomainsGrowth(View): def get(self, request, *args, **kwargs): start_date = request.GET.get("start_date", "") @@ -272,7 +272,7 @@ class ExportDataDomainsGrowth(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataRequestsGrowth(View): def get(self, request, *args, **kwargs): start_date = request.GET.get("start_date", "") @@ -285,7 +285,7 @@ class ExportDataRequestsGrowth(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataManagedDomains(View): def get(self, request, *args, **kwargs): start_date = request.GET.get("start_date", "") @@ -297,7 +297,7 @@ class ExportDataManagedDomains(View): return response -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class ExportDataUnmanagedDomains(View): def get(self, request, *args, **kwargs): start_date = request.GET.get("start_date", "") diff --git a/src/registrar/views/transfer_user.py b/src/registrar/views/transfer_user.py index 62cd0a9d2..ee8ebad35 100644 --- a/src/registrar/views/transfer_user.py +++ b/src/registrar/views/transfer_user.py @@ -4,7 +4,7 @@ from django.db.models import ForeignKey, OneToOneField, ManyToManyField, ManyToO from django.shortcuts import render, get_object_or_404, redirect from django.views import View -from registrar.decorators import IS_STAFF, grant_access +from registrar.decorators import IS_CISA_ANALYST, IS_FULL_ACCESS, grant_access from registrar.models.domain import Domain from registrar.models.domain_request import DomainRequest from registrar.models.user import User @@ -19,7 +19,7 @@ from registrar.utility.db_helpers import ignore_unique_violation logger = logging.getLogger(__name__) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_FULL_ACCESS) class TransferUserView(View): """Transfer user methods that set up the transfer_user template and handle the forms on it.""" diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index 6d0a2b5ec..ea794e185 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -1,7 +1,7 @@ import logging from django.http import JsonResponse from django.forms.models import model_to_dict -from registrar.decorators import IS_STAFF, grant_access +from registrar.decorators import IS_CISA_ANALYST, IS_FULL_ACCESS, IS_OMB_ANALYST, grant_access from registrar.models import FederalAgency, SeniorOfficial, DomainRequest from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email from registrar.models.portfolio import Portfolio @@ -10,16 +10,10 @@ from registrar.utility.constants import BranchChoices logger = logging.getLogger(__name__) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_senior_official_from_federal_agency_json(request): """Returns federal_agency information as a JSON""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - agency_name = request.GET.get("agency_name") agency = FederalAgency.objects.filter(agency=agency_name).first() senior_official = SeniorOfficial.objects.filter(federal_agency=agency).first() @@ -37,16 +31,10 @@ def get_senior_official_from_federal_agency_json(request): return JsonResponse({"error": "Senior Official not found"}, status=404) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_portfolio_json(request): """Returns portfolio information as a JSON""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - portfolio_id = request.GET.get("id") try: portfolio = Portfolio.objects.get(id=portfolio_id) @@ -93,16 +81,10 @@ def get_portfolio_json(request): return JsonResponse(portfolio_dict) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_suborganization_list_json(request): """Returns suborganization list information for a portfolio as a JSON""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - portfolio_id = request.GET.get("portfolio_id") try: portfolio = Portfolio.objects.get(id=portfolio_id) @@ -115,17 +97,11 @@ def get_suborganization_list_json(request): return JsonResponse({"results": results, "pagination": {"more": False}}) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_federal_and_portfolio_types_from_federal_agency_json(request): """Returns specific portfolio information as a JSON. Request must have both agency_name and organization_type.""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - federal_type = None portfolio_type = None @@ -143,16 +119,10 @@ def get_federal_and_portfolio_types_from_federal_agency_json(request): return JsonResponse(response_data) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_action_needed_email_for_user_json(request): """Returns a default action needed email for a given user""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - reason = request.GET.get("reason") domain_request_id = request.GET.get("domain_request_id") if not reason: @@ -167,16 +137,10 @@ def get_action_needed_email_for_user_json(request): return JsonResponse({"email": email}, status=200) -@grant_access(IS_STAFF) +@grant_access(IS_CISA_ANALYST, IS_OMB_ANALYST, IS_FULL_ACCESS) def get_rejection_email_for_user_json(request): """Returns a default rejection email for a given user""" - # This API is only accessible to admins and analysts - superuser_perm = request.user.has_perm("registrar.full_access_permission") - analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): - return JsonResponse({"error": "You do not have access to this resource"}, status=403) - reason = request.GET.get("reason") domain_request_id = request.GET.get("domain_request_id") if not reason: From 562ff46faa8ed44ee12b97507f1d9851a8579722 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 3 Mar 2025 17:05:57 -0500 Subject: [PATCH 02/64] refine permissions on analytics --- src/registrar/templates/admin/app_list.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html index ecce12a3e..6f30e3031 100644 --- a/src/registrar/templates/admin/app_list.html +++ b/src/registrar/templates/admin/app_list.html @@ -63,6 +63,7 @@ {% endfor %} + {% if perms.registrar.analyst_access_permission or perms.full_access_permission %}
@@ -78,6 +79,7 @@
Analytics
+ {% endif %} {% else %}

{% translate 'You don’t have permission to view or edit anything.' %}

{% endif %} From a93dbdc5cc1375a7837794a5d46f9619c37385da Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 3 Mar 2025 17:20:31 -0500 Subject: [PATCH 03/64] import and export buttons and functions limited by permission --- src/registrar/admin.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 4b05bbb6d..7808a7cc7 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -75,6 +75,15 @@ from django.utils.translation import gettext_lazy as _ logger = logging.getLogger(__name__) +class ImportExportRegistrarModelAdmin(ImportExportModelAdmin): + + def has_import_permission(self, request): + return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm("registrar.full_access_permission") + + def has_export_permission(self, request): + return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm("registrar.full_access_permission") + + class FsmModelResource(resources.ModelResource): """ModelResource is extended to support importing of tables which have FSMFields. ModelResource is extended with the following changes @@ -751,7 +760,7 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin): return filters -class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): +class MyUserAdmin(BaseUserAdmin, ImportExportRegistrarModelAdmin): """Custom user admin class to use our inlines.""" resource_classes = [UserResource] @@ -1044,7 +1053,7 @@ class HostResource(resources.ModelResource): model = models.Host -class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin): +class MyHostAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin): """Custom host admin class to use our inlines.""" resource_classes = [HostResource] @@ -1070,7 +1079,7 @@ class HostIpResource(resources.ModelResource): model = models.HostIP -class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin): +class HostIpAdmin(AuditedAdmin, ImportExportRegistrarModelAdmin): """Custom host ip admin class""" resource_classes = [HostIpResource] @@ -1093,7 +1102,7 @@ class ContactResource(resources.ModelResource): model = models.Contact -class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class ContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom contact admin class to add search.""" resource_classes = [ContactResource] @@ -1244,7 +1253,7 @@ class WebsiteResource(resources.ModelResource): model = models.Website -class WebsiteAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class WebsiteAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom website admin class.""" resource_classes = [WebsiteResource] @@ -1344,7 +1353,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin): obj.delete() # Calls the overridden delete method on each instance -class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom user domain role admin class.""" resource_classes = [UserDomainRoleResource] @@ -1760,7 +1769,7 @@ class DomainInformationResource(resources.ModelResource): model = models.DomainInformation -class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DomainInformationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Customize domain information admin class.""" class GenericOrgFilter(admin.SimpleListFilter): @@ -2098,7 +2107,7 @@ class DomainRequestResource(FsmModelResource): model = models.DomainRequest -class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom domain requests admin class.""" resource_classes = [DomainRequestResource] @@ -3309,7 +3318,7 @@ class DomainResource(FsmModelResource): model = models.Domain -class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom domain admin class to add extra buttons.""" resource_classes = [DomainResource] @@ -3902,7 +3911,7 @@ class DraftDomainResource(resources.ModelResource): model = models.DraftDomain -class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DraftDomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom draft domain admin class.""" resource_classes = [DraftDomainResource] @@ -4022,7 +4031,7 @@ class PublicContactResource(resources.ModelResource): self.after_save_instance(instance, using_transactions, dry_run) -class PublicContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class PublicContactAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): """Custom PublicContact admin class.""" resource_classes = [PublicContactResource] @@ -4358,7 +4367,7 @@ class FederalAgencyResource(resources.ModelResource): model = models.FederalAgency -class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): list_display = ["agency"] search_fields = ["agency"] search_help_text = "Search by federal agency." @@ -4415,11 +4424,11 @@ class WaffleFlagAdmin(FlagAdmin): return super().changelist_view(request, extra_context=extra_context) -class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class DomainGroupAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): list_display = ["name", "portfolio"] -class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): +class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): list_display = ["name", "portfolio"] autocomplete_fields = [ From 2bd188b267a185ccaa715d75dd4e0108918113e4 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 4 Mar 2025 06:38:34 -0500 Subject: [PATCH 04/64] filtered admin views based on specific permission groups --- src/registrar/admin.py | 193 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 7808a7cc7..d58ded2f9 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1244,6 +1244,32 @@ class SeniorOfficialAdmin(ListHeaderAdmin): # in autocomplete_fields for Senior Official ordering = ["first_name", "last_name"] + def get_annotated_queryset(self, queryset): + return queryset.annotate( + converted_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(federal_agency__isnull=False), + then=F("federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=Value(""), + ), + ) + + def get_queryset(self, request): + """Restrict queryset based on user permissions.""" + qs = super().get_queryset(request) + + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + annotated_qs = self.get_annotated_queryset(qs) + return annotated_qs.filter( + converted_federal_type=BranchChoices.EXECUTIVE, + ) + + return qs # Return full queryset if the user doesn't have the restriction + class WebsiteResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -1536,6 +1562,39 @@ class DomainInvitationAdmin(BaseInvitationAdmin): # Override for the delete confirmation page on the domain table (bulk delete action) delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html" + def get_annotated_queryset(self, queryset): + return queryset.annotate( + converted_generic_org_type=Case( + # When portfolio is present, use its value instead + When(domain__domain_info__portfolio__isnull=False, then=F("domain__domain_info__portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("domain__domain_info__generic_org_type"), + ), + converted_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(domain__domain_info__portfolio__isnull=False) & Q(domain__domain_info__portfolio__federal_agency__isnull=False), + then=F("domain__domain_info__portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=F("domain__domain_info__federal_agency__federal_type"), + ), + ) + + def get_queryset(self, request): + """Restrict queryset based on user permissions.""" + qs = super().get_queryset(request) + + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + annotated_qs = self.get_annotated_queryset(qs) + return annotated_qs.filter( + converted_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + converted_federal_type=BranchChoices.EXECUTIVE, + ) + + return qs # Return full queryset if the user doesn't have the restriction + # Select domain invitations to change -> Domain invitations def changelist_view(self, request, extra_context=None): if extra_context is None: @@ -2098,6 +2157,38 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): use_sort = db_field.name != "senior_official" return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs) + def get_annotated_queryset(self, queryset): + return queryset.annotate( + conv_generic_org_type=Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("generic_org_type"), + ), + conv_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=F("federal_agency__federal_type"), + ), + ) + + def get_queryset(self, request): + """Custom get_queryset to filter by portfolio if portfolio is in the + request params.""" + qs = super().get_queryset(request) + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + annotated_qs = self.get_annotated_queryset(qs) + return annotated_qs.filter( + conv_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + conv_federal_type=BranchChoices.EXECUTIVE, + ) + return qs + class DomainRequestResource(FsmModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -3050,6 +3141,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): use_sort = db_field.name != "senior_official" return super().formfield_for_foreignkey(db_field, request, use_admin_sort_fields=use_sort, **kwargs) + def get_annotated_queryset(self, queryset): + return queryset.annotate( + conv_generic_org_type=Case( + # When portfolio is present, use its value instead + When(portfolio__isnull=False, then=F("portfolio__organization_type")), + # Otherwise, return the natively assigned value + default=F("generic_org_type"), + ), + conv_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=F("federal_agency__federal_type"), + ), + ) + def get_queryset(self, request): """Custom get_queryset to filter by portfolio if portfolio is in the request params.""" @@ -3059,6 +3169,13 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if portfolio_id: # Further filter the queryset by the portfolio qs = qs.filter(portfolio=portfolio_id) + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + annotated_qs = self.get_annotated_queryset(qs) + return annotated_qs.filter( + conv_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + conv_federal_type=BranchChoices.EXECUTIVE, + ) return qs def get_search_results(self, request, queryset, search_term): @@ -3900,6 +4017,12 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if portfolio_id: # Further filter the queryset by the portfolio qs = qs.filter(domain_info__portfolio=portfolio_id) + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + return qs.filter( + converted_generic_org_type=DomainRequest.OrganizationChoices.FEDERAL, + converted_federal_type=BranchChoices.EXECUTIVE, + ) return qs @@ -4314,6 +4437,34 @@ class PortfolioAdmin(ListHeaderAdmin): readonly_fields.extend([field for field in self.analyst_readonly_fields]) return readonly_fields + def get_annotated_queryset(self, queryset): + return queryset.annotate( + converted_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(federal_agency__isnull=False), + then=F("federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=Value(""), + ), + ) + + def get_queryset(self, request): + """Restrict queryset based on user permissions.""" + qs = super().get_queryset(request) + + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + annotated_qs = self.get_annotated_queryset(qs) + return annotated_qs.filter( + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + converted_federal_type=BranchChoices.EXECUTIVE, + ) + + return qs # Return full queryset if the user doesn't have the restriction + + 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).""" @@ -4374,6 +4525,17 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): ordering = ["agency"] resource_classes = [FederalAgencyResource] + def get_queryset(self, request): + """Restrict queryset based on user permissions.""" + qs = super().get_queryset(request) + + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + return qs.filter( + federal_type=BranchChoices.EXECUTIVE, + ) + + return qs # Return full queryset if the user doesn't have the restriction class UserGroupAdmin(AuditedAdmin): """Overwrite the generated UserGroup admin class""" @@ -4456,6 +4618,37 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): extra_context = {"domain_requests": domain_requests, "domains": domains} return super().change_view(request, object_id, form_url, extra_context) + def get_annotated_queryset(self, queryset): + return queryset.annotate( + converted_federal_type=Case( + # When portfolio is present, use its value instead + When( + Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), + then=F("portfolio__federal_agency__federal_type"), + ), + # Otherwise, return the natively assigned value + default=Value(""), + ), + ) + + def get_queryset(self, request): + """Custom get_queryset to filter by portfolio if portfolio is in the + request params.""" + qs = super().get_queryset(request) + # Check if a 'portfolio' parameter is passed in the request + portfolio_id = request.GET.get("portfolio") + if portfolio_id: + # Further filter the queryset by the portfolio + qs = qs.filter(portfolio=portfolio_id) + # Check if user is in OMB analysts group + if request.user.groups.filter(name="omb_analysts_group").exists(): + annotated_qs = self.get_annotated_queryset(qs) + return annotated_qs.filter( + portfolio__organization_type=DomainRequest.OrganizationChoices.FEDERAL, + converted_federal_type=BranchChoices.EXECUTIVE, + ) + return qs + class AllowedEmailAdmin(ListHeaderAdmin): class Meta: From 16bcae0dc281cad5ab03c917d01e57150933eb53 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 4 Mar 2025 20:48:22 -0500 Subject: [PATCH 05/64] added admin object and group specific permissions for view, add, change and or delete --- src/registrar/admin.py | 152 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d58ded2f9..387712bbb 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1270,6 +1270,33 @@ class SeniorOfficialAdmin(ListHeaderAdmin): return qs # Return full queryset if the user doesn't have the restriction + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + + def has_change_permission(self, request, obj=None): + """Restrict update permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + """Restrict delete permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE + return super().has_delete_permisssion(request, obj) + class WebsiteResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -1595,6 +1622,16 @@ class DomainInvitationAdmin(BaseInvitationAdmin): return qs # Return full queryset if the user doesn't have the restriction + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.domain.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ + obj.domain.domain_info.federal_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + # Select domain invitations to change -> Domain invitations def changelist_view(self, request, extra_context=None): if extra_context is None: @@ -3177,7 +3214,27 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): conv_federal_type=BranchChoices.EXECUTIVE, ) return qs - + + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ + obj.converted_federal_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + + def has_change_permission(self, request, obj=None): + """Restrict update permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ + obj.converted_federal_type == BranchChoices.EXECUTIVE + return super().has_change_permission(request, obj) + def get_search_results(self, request, queryset, search_term): # Call the parent's method to apply default search logic base_queryset, use_distinct = super().get_search_results(request, queryset, search_term) @@ -4025,6 +4082,16 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): ) return qs + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ + obj.domain_info.converted_federal_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + class DraftDomainResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -4464,6 +4531,32 @@ class PortfolioAdmin(ListHeaderAdmin): return qs # Return full queryset if the user doesn't have the restriction + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.federal_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + + def has_change_permission(self, request, obj=None): + """Restrict update permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.federal_type == BranchChoices.EXECUTIVE + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + """Restrict delete permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.federal_type == BranchChoices.EXECUTIVE + return super().has_delete_permisssion(request, obj) def change_view(self, request, object_id, form_url="", extra_context=None): """Add related suborganizations and domain groups. @@ -4537,6 +4630,36 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return qs # Return full queryset if the user doesn't have the restriction + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.domain.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ + obj.domain.domain_info.federal_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + + def has_change_permission(self, request, obj=None): + """Restrict update permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ + obj.converted_federal_type == BranchChoices.EXECUTIVE + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + """Restrict delete permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.federal_type == BranchChoices.EXECUTIVE + return super().has_delete_permisssion(request, obj) + + class UserGroupAdmin(AuditedAdmin): """Overwrite the generated UserGroup admin class""" @@ -4648,6 +4771,33 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): converted_federal_type=BranchChoices.EXECUTIVE, ) return qs + + def has_view_permission(self, request, obj=None): + """Restrict view permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE + return super().has_view_permission(request, obj) + + def has_change_permission(self, request, obj=None): + """Restrict update permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE + return super().has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + """Restrict delete permissions based on group membership and model attributes.""" + if request.user.has_perm("registrar.full_access_permission"): + return True + if obj: + if request.user.groups.filter(name="omb_analysts_group").exists(): + return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE + return super().has_delete_permisssion(request, obj) class AllowedEmailAdmin(ListHeaderAdmin): From 996d8eaccfe12e9f8ca74868880251e8a8f4756d Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 5 Mar 2025 18:30:17 -0500 Subject: [PATCH 06/64] changes to domain request admin form --- src/registrar/admin.py | 70 +++++++++++++++++-- .../admin/includes/contact_detail_list.html | 6 +- .../admin/includes/detail_table_fieldset.html | 16 ++++- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 387712bbb..e16463fb8 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -323,9 +323,10 @@ class DomainRequestAdminForm(forms.ModelForm): # only set the available transitions if the user is not restricted # from editing the domain request; otherwise, the form will be # readonly and the status field will not have a widget - if not domain_request.creator.is_restricted(): + if not domain_request.creator.is_restricted() and "status" in self.fields: self.fields["status"].widget.choices = available_transitions + def get_custom_field_transitions(self, instance, field): """Custom implementation of get_available_FIELD_transitions in the FSM. Allows us to still display fields filtered out by a condition.""" @@ -1295,7 +1296,7 @@ class SeniorOfficialAdmin(ListHeaderAdmin): if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE - return super().has_delete_permisssion(request, obj) + return super().has_delete_permission(request, obj) class WebsiteResource(resources.ModelResource): @@ -2749,6 +2750,53 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): "cisa_representative_email", ] + # Read only that we'll leverage for OMB Analysts + omb_analyst_readonly_fields = [ + "federal_agency", + "creator", + "about_your_organization", + "requested_domain", + "approved_domain", + "alternative_domains", + "purpose", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + "cisa_representative_first_name", + "cisa_representative_last_name", + "cisa_representative_email", + "status", + "investigator", + "notes", + "senior_official", + "organization_type", + "organization_name", + "state_territory", + "address_line1", + "address_line2", + "city", + "zipcode", + "urbanization", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + "is_election_board", + "organization_type", + "federal_type", + "federal_agency", + "tribe_name", + "federally_recognized_tribe", + "state_recognized_tribe", + "about_your_organization", + ] + autocomplete_fields = [ "approved_domain", "requested_domain", @@ -2990,6 +3038,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if request.user.has_perm("registrar.full_access_permission"): return readonly_fields + # Return restrictive Read-only fields for OMB analysts + if request.user.groups.filter(name="omb_analysts_group").exists(): + readonly_fields.extend([field for field in self.omb_analyst_readonly_fields]) + return readonly_fields # Return restrictive Read-only fields for analysts and # users who might not belong to groups readonly_fields.extend([field for field in self.analyst_readonly_fields]) @@ -3254,6 +3306,14 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return combined_queryset, use_distinct + def get_form(self, request, obj=None, **kwargs): + """Pass the 'is_omb_analyst' attribute to the form.""" + form = super().get_form(request, obj, **kwargs) + + # Store attribute in the form for template access + form.show_contact_as_plain_text = request.user.groups.filter(name="omb_analysts_group").exists() + + return form class TransitionDomainAdmin(ListHeaderAdmin): """Custom transition domain admin class.""" @@ -4556,7 +4616,7 @@ class PortfolioAdmin(ListHeaderAdmin): if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): return obj.federal_type == BranchChoices.EXECUTIVE - return super().has_delete_permisssion(request, obj) + return super().has_delete_permission(request, obj) def change_view(self, request, object_id, form_url="", extra_context=None): """Add related suborganizations and domain groups. @@ -4657,7 +4717,7 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): return obj.federal_type == BranchChoices.EXECUTIVE - return super().has_delete_permisssion(request, obj) + return super().has_delete_permission(request, obj) class UserGroupAdmin(AuditedAdmin): @@ -4797,7 +4857,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE - return super().has_delete_permisssion(request, obj) + return super().has_delete_permission(request, obj) class AllowedEmailAdmin(ListHeaderAdmin): 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 0baabac17..b3cdeb875 100644 --- a/src/registrar/templates/django/admin/includes/contact_detail_list.html +++ b/src/registrar/templates/django/admin/includes/contact_detail_list.html @@ -6,7 +6,11 @@ {% if show_formatted_name %} {% if user.get_formatted_name %} - {{ user.get_formatted_name }} + {% if adminform.form.show_contact_as_plain_text %} + {{ user.get_formatted_name }} + {% else %} + {{ user.get_formatted_name }} + {% endif %} {% else %} None {% 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 a074e8a7c..c800a7c84 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -69,7 +69,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% elif field.field.name == "portfolio_senior_official" %}
{% if original_object.portfolio.senior_official %} - {{ field.contents }} + {% if adminform.form.show_contact_as_plain_text %} + {{ field.contents|striptags }} + {% else %} + {{ field.contents }} + {% endif %} {% else %} No senior official found.
{% endif %} @@ -78,7 +82,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% if all_contacts.count > 2 %}
{% for contact in all_contacts %} - {{ contact.get_formatted_name }}{% if not forloop.last %}, {% endif %} + {% if adminform.form.show_contact_as_plain_text %} + {{ contact.get_formatted_name }}{% if not forloop.last %}, {% endif %} + {% else %} + {{ contact.get_formatted_name }}{% if not forloop.last %}, {% endif %} + {% endif %} {% endfor %}
{% else %} @@ -153,6 +161,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)

No additional members found.

{% endif %}
+ {% elif field.field.name == "creator" and adminform.form.show_contact_as_plain_text %} +
{{ field.contents|striptags }}
+ {% elif field.field.name == "senior_official" and adminform.form.show_contact_as_plain_text %} +
{{ field.contents|striptags }}
{% else %}
{{ field.contents }}
{% endif %} From 70970fbcc56266ed97e64e1df619b1a61a9a4c20 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 6 Mar 2025 08:55:31 -0500 Subject: [PATCH 07/64] portfolio admin updates --- src/registrar/admin.py | 130 +++++++++++++++--- src/registrar/models/user_group.py | 2 +- .../django/admin/domain_change_form.html | 6 +- .../portfolio/portfolio_admins_table.html | 6 +- .../portfolio/portfolio_fieldset.html | 3 + 5 files changed, 124 insertions(+), 23 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e16463fb8..f556db16d 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -326,7 +326,6 @@ class DomainRequestAdminForm(forms.ModelForm): if not domain_request.creator.is_restricted() and "status" in self.fields: self.fields["status"].widget.choices = available_transitions - def get_custom_field_transitions(self, instance, field): """Custom implementation of get_available_FIELD_transitions in the FSM. Allows us to still display fields filtered out by a condition.""" @@ -3345,6 +3344,16 @@ class DomainInformationInline(admin.StackedInline): template = "django/admin/includes/domain_info_inline_stacked.html" model = models.DomainInformation + def __init__(self, *args, **kwargs): + """Initialize the admin class and define a default value for is_omb_analyst.""" + super().__init__(*args, **kwargs) + self.is_omb_analyst = False # Default value in case it's accessed before being set + + def get_queryset(self, request): + """Ensure self.is_omb_analyst is set early.""" + self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists() + return super().get_queryset(request) + # Define methods to display fields from the related portfolio def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None @@ -3432,12 +3441,16 @@ class DomainInformationInline(admin.StackedInline): if not domain_managers: return "No domain managers found." - domain_manager_details = "" + domain_manager_details = "
UIDNameEmail
" + if not self.is_omb_analyst: + domain_manager_details += "" + domain_manager_details += "" for domain_manager in domain_managers: full_name = domain_manager.get_formatted_name() change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk]) domain_manager_details += "" - domain_manager_details += f'" domain_manager_details += f"" domain_manager_details += "" @@ -3469,7 +3482,8 @@ class DomainInformationInline(admin.StackedInline): superuser_perm = request.user.has_perm("registrar.full_access_permission") analyst_perm = request.user.has_perm("registrar.analyst_access_permission") - if analyst_perm and not superuser_perm: + omb_analyst_perm = request.user.groups.filter(name="omb_analysts_group").exists() + if (analyst_perm or omb_analyst_perm) and not superuser_perm: return True return super().has_change_permission(request, obj) @@ -3542,6 +3556,17 @@ class DomainInformationInline(admin.StackedInline): modified_fieldsets.append(fieldsets_to_move) return modified_fieldsets + + def get_form(self, request, obj=None, **kwargs): + """Pass the 'is_omb_analyst' attribute to the form.""" + form = super().get_form(request, obj, **kwargs) + + # Store attribute in the form for template access + self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists() + form.show_contact_as_plain_text = self.is_omb_analyst + form.is_omb_analyst = self.is_omb_analyst + + return form class DomainResource(FsmModelResource): @@ -4152,7 +4177,17 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): obj.domain_info.converted_federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) + def get_form(self, request, obj=None, **kwargs): + """Pass the 'is_omb_analyst' attribute to the form.""" + form = super().get_form(request, obj, **kwargs) + # Store attribute in the form for template access + is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists() + form.show_contact_as_plain_text = is_omb_analyst + form.is_omb_analyst = is_omb_analyst + + return form + class DraftDomainResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -4336,6 +4371,11 @@ class PortfolioAdmin(ListHeaderAdmin): _meta = Meta() + def __init__(self, *args, **kwargs): + """Initialize the admin class and define a default value for is_omb_analyst.""" + super().__init__(*args, **kwargs) + self.is_omb_analyst = False # Default value in case it's accessed before being set + change_form_template = "django/admin/portfolio_change_form.html" fieldsets = [ # created_on is the created_at field @@ -4417,6 +4457,19 @@ class PortfolioAdmin(ListHeaderAdmin): # rather than strip it out of our logic. analyst_readonly_fields = [] # type: ignore + omb_analyst_readonly_fields = [ + "notes", + "organization_type", + "organization_name", + "federal_agency", + "state_territory", + "address_line1", + "address_line2", + "city", + "zipcode", + "urbanization", + ] + def get_admin_users(self, obj): # Filter UserPortfolioPermission objects related to the portfolio admin_permissions = self.get_user_portfolio_permission_admins(obj) @@ -4502,6 +4555,8 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns the number of administrators for this portfolio""" admin_count = len(self.get_user_portfolio_permission_admins(obj)) if admin_count > 0: + if self.is_omb_analyst: + return format_html(f"{admin_count} administrators") url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" # Create a clickable link with the count return format_html(f'{admin_count} administrators') @@ -4513,6 +4568,8 @@ class PortfolioAdmin(ListHeaderAdmin): """Returns the number of members for this portfolio""" member_count = len(self.get_user_portfolio_permission_non_admins(obj)) if member_count > 0: + if self.is_omb_analyst: + return format_html(f"{member_count} members") url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" # Create a clickable link with the count return format_html(f'{member_count} members') @@ -4558,7 +4615,10 @@ class PortfolioAdmin(ListHeaderAdmin): if request.user.has_perm("registrar.full_access_permission"): return readonly_fields - + # Return restrictive Read-only fields for OMB analysts + if request.user.groups.filter(name="omb_analysts_group").exists(): + readonly_fields.extend([field for field in self.omb_analyst_readonly_fields]) + return readonly_fields # Return restrictive Read-only fields for analysts and # users who might not belong to groups readonly_fields.extend([field for field in self.analyst_readonly_fields]) @@ -4583,6 +4643,7 @@ class PortfolioAdmin(ListHeaderAdmin): # Check if user is in OMB analysts group if request.user.groups.filter(name="omb_analysts_group").exists(): + self.is_omb_analyst = True annotated_qs = self.get_annotated_queryset(qs) return annotated_qs.filter( organization_type=DomainRequest.OrganizationChoices.FEDERAL, @@ -4609,15 +4670,6 @@ class PortfolioAdmin(ListHeaderAdmin): return obj.federal_type == BranchChoices.EXECUTIVE return super().has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - """Restrict delete permissions based on group membership and model attributes.""" - if request.user.has_perm("registrar.full_access_permission"): - return True - if obj: - if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.federal_type == BranchChoices.EXECUTIVE - return super().has_delete_permission(request, obj) - 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).""" @@ -4662,6 +4714,17 @@ class PortfolioAdmin(ListHeaderAdmin): super().save_model(request, obj, form, change) + def get_form(self, request, obj=None, **kwargs): + """Pass the 'is_omb_analyst' attribute to the form.""" + form = super().get_form(request, obj, **kwargs) + + # Store attribute in the form for template access + self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists() + form.show_contact_as_plain_text = self.is_omb_analyst + form.is_omb_analyst = self.is_omb_analyst + + return form + class FederalAgencyResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -4678,6 +4741,20 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): ordering = ["agency"] resource_classes = [FederalAgencyResource] + # Readonly fields for analysts and superusers + readonly_fields = [] + + # Read only that we'll leverage for CISA Analysts + analyst_readonly_fields = [] + + # Read only that we'll leverage for OMB Analysts + omb_analyst_readonly_fields = [ + "agency", + "federal_type", + "acronym", + "is_fceb", + ] + def get_queryset(self, request): """Restrict queryset based on user permissions.""" qs = super().get_queryset(request) @@ -4696,8 +4773,7 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.domain.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ - obj.domain.domain_info.federal_type == BranchChoices.EXECUTIVE + return obj.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) def has_change_permission(self, request, obj=None): @@ -4706,8 +4782,7 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ - obj.converted_federal_type == BranchChoices.EXECUTIVE + return obj.federal_type == BranchChoices.EXECUTIVE return super().has_change_permission(request, obj) def has_delete_permission(self, request, obj=None): @@ -4718,7 +4793,24 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if request.user.groups.filter(name="omb_analysts_group").exists(): return obj.federal_type == BranchChoices.EXECUTIVE return super().has_delete_permission(request, obj) - + + def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have 2 conditions that determine which fields are read-only: + admin user permissions and the domain request creator's status, so + we'll use the baseline readonly_fields and extend it as needed. + """ + readonly_fields = list(self.readonly_fields) + if request.user.has_perm("registrar.full_access_permission"): + return readonly_fields + # Return restrictive Read-only fields for OMB analysts + if request.user.groups.filter(name="omb_analysts_group").exists(): + readonly_fields.extend([field for field in self.omb_analyst_readonly_fields]) + return readonly_fields + # Return restrictive Read-only fields for analysts and + # users who might not belong to groups + readonly_fields.extend([field for field in self.analyst_readonly_fields]) + return readonly_fields class UserGroupAdmin(AuditedAdmin): """Overwrite the generated UserGroup admin class""" diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index b3bddee66..031fe8e06 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -176,7 +176,7 @@ class UserGroup(Group): { "app_label": "registrar", "model": "portfolio", - "permissions": ["change_portfolio", "delete_portfolio"], + "permissions": ["change_portfolio"], }, { "app_label": "registrar", diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index 7aa0034b9..9f34feae6 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -11,13 +11,15 @@ {% block field_sets %}
+ {% if not adminform.form.is_omb_analyst %} {# Dja has margin styles defined on inputs as is. Lets work with it, rather than fight it. #} + {% endif %}
- {% if original.state != original.State.DELETED %} + {% if original.state != original.State.DELETED and not adminform.form.is_omb_analyst %} Extend expiration date @@ -33,7 +35,7 @@ {% if original.state == original.State.READY or original.state == original.State.ON_HOLD %} | {% endif %} - {% if original.state != original.State.DELETED %} + {% if original.state != original.State.DELETED and not adminform.form.is_omb_analyst %} Remove from registry 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 index 7add74323..574c05738 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html @@ -16,7 +16,11 @@ {% for admin in admins %} {% url 'admin:registrar_userportfoliopermission_change' admin.pk as url %}
- + {% if adminform.form.is_omb_analyst %} + + {% else %} + + {% endif %}
UIDNameEmail
{escape(domain_manager.username)}' + if not self.is_omb_analyst: + domain_manager_details += f'{escape(domain_manager.username)}' domain_manager_details += f"{escape(full_name)}{escape(domain_manager.email)}
{{ admin.user.get_formatted_name}}{{ admin.user.get_formatted_name }}{{ admin.user.get_formatted_name}}{{ admin.user.title }} {% if admin.user.email %} diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html index 87b56cb60..54ac502d1 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_fieldset.html @@ -30,6 +30,9 @@ No senior official found. Create one now. {% endif %} + + {% elif field.field.name == "creator" and adminform.form.show_contact_as_plain_text %} +
{{ field.contents|striptags }}
{% else %}
{{ field.contents }}
{% endif %} From 2c56ea480f6b0e3e3b98a6285fec671e9550b43d Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Thu, 6 Mar 2025 09:15:48 -0800 Subject: [PATCH 08/64] Make requested content changes --- src/registrar/templates/domain_base.html | 2 +- src/registrar/templates/domain_detail.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 58038d0a4..3451157b5 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -58,7 +58,7 @@ {% if request.path|endswith:"renewal"%}

Renew {{domain.name}}

{%else%} -

Domain Overview

+

Domain overview

{% endif%} {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index eba0eaf85..a0d477249 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -99,7 +99,7 @@ {% if domain.dnssecdata is not None %} {% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %} {% else %} - {% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %} + {% include "includes/summary_item.html" with title='DNSSEC' value='Not enabled' edit_link=url editable=is_editable %} {% endif %} {% if portfolio %} From 76bb219a2a8325d7d8c798c338828a0834fde7ea Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 6 Mar 2025 13:55:00 -0500 Subject: [PATCH 09/64] suborgs and senior officials --- src/registrar/admin.py | 84 +++++++++++++++++++++++------- src/registrar/models/user_group.py | 4 +- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index f556db16d..1f5dae5f1 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1257,6 +1257,40 @@ class SeniorOfficialAdmin(ListHeaderAdmin): ), ) + readonly_fields = [] + + # Even though this is empty, I will leave it as a stub for easy changes in the future + # rather than strip it out of our logic. + analyst_readonly_fields = [] # type: ignore + + omb_analyst_readonly_fields = [ + "first_name", + "last_name", + "title", + "phone", + "email", + "federal_agency", + ] + + def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have conditions that determine which fields are read-only: + admin user permissions and analyst (cisa or omb) status, so + we'll use the baseline readonly_fields and extend it as needed. + """ + readonly_fields = list(self.readonly_fields) + + if request.user.has_perm("registrar.full_access_permission"): + return readonly_fields + # Return restrictive Read-only fields for OMB analysts + if request.user.groups.filter(name="omb_analysts_group").exists(): + readonly_fields.extend([field for field in self.omb_analyst_readonly_fields]) + return readonly_fields + # Return restrictive Read-only fields for analysts and + # users who might not belong to groups + readonly_fields.extend([field for field in self.analyst_readonly_fields]) + return readonly_fields + def get_queryset(self, request): """Restrict queryset based on user permissions.""" qs = super().get_queryset(request) @@ -1288,15 +1322,6 @@ class SeniorOfficialAdmin(ListHeaderAdmin): return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE return super().has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - """Restrict delete permissions based on group membership and model attributes.""" - if request.user.has_perm("registrar.full_access_permission"): - return True - if obj: - if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE - return super().has_delete_permission(request, obj) - class WebsiteResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -4876,6 +4901,38 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): change_form_template = "django/admin/suborg_change_form.html" + readonly_fields = [] + + # Even though this is empty, I will leave it as a stub for easy changes in the future + # rather than strip it out of our logic. + analyst_readonly_fields = [] # type: ignore + + omb_analyst_readonly_fields = [ + "name", + "portfolio", + "city", + "state_territory", + ] + + def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have conditions that determine which fields are read-only: + admin user permissions and analyst (cisa or omb) status, so + we'll use the baseline readonly_fields and extend it as needed. + """ + readonly_fields = list(self.readonly_fields) + + if request.user.has_perm("registrar.full_access_permission"): + return readonly_fields + # Return restrictive Read-only fields for OMB analysts + if request.user.groups.filter(name="omb_analysts_group").exists(): + readonly_fields.extend([field for field in self.omb_analyst_readonly_fields]) + return readonly_fields + # Return restrictive Read-only fields for analysts and + # users who might not belong to groups + readonly_fields.extend([field for field in self.analyst_readonly_fields]) + return readonly_fields + def change_view(self, request, object_id, form_url="", extra_context=None): """Add suborg's related domains and requests to context""" obj = self.get_object(request, object_id) @@ -4942,15 +4999,6 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE return super().has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - """Restrict delete permissions based on group membership and model attributes.""" - if request.user.has_perm("registrar.full_access_permission"): - return True - if obj: - if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE - return super().has_delete_permission(request, obj) - class AllowedEmailAdmin(ListHeaderAdmin): class Meta: diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index 031fe8e06..781dbb64c 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -181,12 +181,12 @@ class UserGroup(Group): { "app_label": "registrar", "model": "suborganization", - "permissions": ["change_suborganization", "delete_suborganization"], + "permissions": ["change_suborganization"], }, { "app_label": "registrar", "model": "seniorofficial", - "permissions": ["change_seniorofficial", "delete_seniorofficial"], + "permissions": ["change_seniorofficial"], }, ] From 7d2a37970fc9e67bc518dc8153ffd9a79cbcfafd Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 6 Mar 2025 14:00:21 -0500 Subject: [PATCH 10/64] lint --- src/registrar/admin.py | 93 +++++++++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 1f5dae5f1..303d82f05 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -78,10 +78,14 @@ logger = logging.getLogger(__name__) class ImportExportRegistrarModelAdmin(ImportExportModelAdmin): def has_import_permission(self, request): - return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm("registrar.full_access_permission") + return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm( + "registrar.full_access_permission" + ) def has_export_permission(self, request): - return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm("registrar.full_access_permission") + return request.user.has_perm("registrar.analyst_access_permission") or request.user.has_perm( + "registrar.full_access_permission" + ) class FsmModelResource(resources.ModelResource): @@ -1256,7 +1260,7 @@ class SeniorOfficialAdmin(ListHeaderAdmin): default=Value(""), ), ) - + readonly_fields = [] # Even though this is empty, I will leave it as a stub for easy changes in the future @@ -1290,7 +1294,7 @@ class SeniorOfficialAdmin(ListHeaderAdmin): # users who might not belong to groups readonly_fields.extend([field for field in self.analyst_readonly_fields]) return readonly_fields - + def get_queryset(self, request): """Restrict queryset based on user permissions.""" qs = super().get_queryset(request) @@ -1303,7 +1307,7 @@ class SeniorOfficialAdmin(ListHeaderAdmin): ) return qs # Return full queryset if the user doesn't have the restriction - + def has_view_permission(self, request, obj=None): """Restrict view permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): @@ -1312,7 +1316,7 @@ class SeniorOfficialAdmin(ListHeaderAdmin): if request.user.groups.filter(name="omb_analysts_group").exists(): return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) - + def has_change_permission(self, request, obj=None): """Restrict update permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): @@ -1618,21 +1622,25 @@ class DomainInvitationAdmin(BaseInvitationAdmin): return queryset.annotate( converted_generic_org_type=Case( # When portfolio is present, use its value instead - When(domain__domain_info__portfolio__isnull=False, then=F("domain__domain_info__portfolio__organization_type")), + When( + domain__domain_info__portfolio__isnull=False, + then=F("domain__domain_info__portfolio__organization_type"), + ), # Otherwise, return the natively assigned value default=F("domain__domain_info__generic_org_type"), ), converted_federal_type=Case( # When portfolio is present, use its value instead When( - Q(domain__domain_info__portfolio__isnull=False) & Q(domain__domain_info__portfolio__federal_agency__isnull=False), + Q(domain__domain_info__portfolio__isnull=False) + & Q(domain__domain_info__portfolio__federal_agency__isnull=False), then=F("domain__domain_info__portfolio__federal_agency__federal_type"), ), # Otherwise, return the natively assigned value default=F("domain__domain_info__federal_agency__federal_type"), ), ) - + def get_queryset(self, request): """Restrict queryset based on user permissions.""" qs = super().get_queryset(request) @@ -1646,17 +1654,19 @@ class DomainInvitationAdmin(BaseInvitationAdmin): ) return qs # Return full queryset if the user doesn't have the restriction - + def has_view_permission(self, request, obj=None): """Restrict view permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.domain.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ - obj.domain.domain_info.federal_type == BranchChoices.EXECUTIVE + return ( + obj.domain.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + and obj.domain.domain_info.federal_type == BranchChoices.EXECUTIVE + ) return super().has_view_permission(request, obj) - + # Select domain invitations to change -> Domain invitations def changelist_view(self, request, extra_context=None): if extra_context is None: @@ -3290,27 +3300,31 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): conv_federal_type=BranchChoices.EXECUTIVE, ) return qs - + def has_view_permission(self, request, obj=None): """Restrict view permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ - obj.converted_federal_type == BranchChoices.EXECUTIVE + return ( + obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + and obj.converted_federal_type == BranchChoices.EXECUTIVE + ) return super().has_view_permission(request, obj) - + def has_change_permission(self, request, obj=None): """Restrict update permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ - obj.converted_federal_type == BranchChoices.EXECUTIVE + return ( + obj.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + and obj.converted_federal_type == BranchChoices.EXECUTIVE + ) return super().has_change_permission(request, obj) - + def get_search_results(self, request, queryset, search_term): # Call the parent's method to apply default search logic base_queryset, use_distinct = super().get_search_results(request, queryset, search_term) @@ -3339,6 +3353,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return form + class TransitionDomainAdmin(ListHeaderAdmin): """Custom transition domain admin class.""" @@ -3378,7 +3393,7 @@ class DomainInformationInline(admin.StackedInline): """Ensure self.is_omb_analyst is set early.""" self.is_omb_analyst = request.user.groups.filter(name="omb_analysts_group").exists() return super().get_queryset(request) - + # Define methods to display fields from the related portfolio def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None @@ -3581,7 +3596,7 @@ class DomainInformationInline(admin.StackedInline): modified_fieldsets.append(fieldsets_to_move) return modified_fieldsets - + def get_form(self, request, obj=None, **kwargs): """Pass the 'is_omb_analyst' attribute to the form.""" form = super().get_form(request, obj, **kwargs) @@ -4198,10 +4213,12 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL and \ - obj.domain_info.converted_federal_type == BranchChoices.EXECUTIVE + return ( + obj.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL + and obj.domain_info.converted_federal_type == BranchChoices.EXECUTIVE + ) return super().has_view_permission(request, obj) - + def get_form(self, request, obj=None, **kwargs): """Pass the 'is_omb_analyst' attribute to the form.""" form = super().get_form(request, obj, **kwargs) @@ -4212,7 +4229,8 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): form.is_omb_analyst = is_omb_analyst return form - + + class DraftDomainResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -4661,7 +4679,7 @@ class PortfolioAdmin(ListHeaderAdmin): default=Value(""), ), ) - + def get_queryset(self, request): """Restrict queryset based on user permissions.""" qs = super().get_queryset(request) @@ -4676,7 +4694,7 @@ class PortfolioAdmin(ListHeaderAdmin): ) return qs # Return full queryset if the user doesn't have the restriction - + def has_view_permission(self, request, obj=None): """Restrict view permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): @@ -4685,14 +4703,14 @@ class PortfolioAdmin(ListHeaderAdmin): if request.user.groups.filter(name="omb_analysts_group").exists(): return obj.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) - + def has_change_permission(self, request, obj=None): """Restrict update permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.federal_type == BranchChoices.EXECUTIVE + return obj.federal_type == BranchChoices.EXECUTIVE return super().has_change_permission(request, obj) def change_view(self, request, object_id, form_url="", extra_context=None): @@ -4770,7 +4788,7 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): readonly_fields = [] # Read only that we'll leverage for CISA Analysts - analyst_readonly_fields = [] + analyst_readonly_fields = [] # type: ignore # Read only that we'll leverage for OMB Analysts omb_analyst_readonly_fields = [ @@ -4800,14 +4818,14 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if request.user.groups.filter(name="omb_analysts_group").exists(): return obj.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) - + def has_change_permission(self, request, obj=None): """Restrict update permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.federal_type == BranchChoices.EXECUTIVE + return obj.federal_type == BranchChoices.EXECUTIVE return super().has_change_permission(request, obj) def has_delete_permission(self, request, obj=None): @@ -4835,7 +4853,8 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): # Return restrictive Read-only fields for analysts and # users who might not belong to groups readonly_fields.extend([field for field in self.analyst_readonly_fields]) - return readonly_fields + return readonly_fields + class UserGroupAdmin(AuditedAdmin): """Overwrite the generated UserGroup admin class""" @@ -4980,7 +4999,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): converted_federal_type=BranchChoices.EXECUTIVE, ) return qs - + def has_view_permission(self, request, obj=None): """Restrict view permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): @@ -4989,14 +5008,14 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if request.user.groups.filter(name="omb_analysts_group").exists(): return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) - + def has_change_permission(self, request, obj=None): """Restrict update permissions based on group membership and model attributes.""" if request.user.has_perm("registrar.full_access_permission"): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE + return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE return super().has_change_permission(request, obj) From 97be3dc56d8b1cbae2d6f7de094ec5ccf2a329e5 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 6 Mar 2025 15:00:41 -0500 Subject: [PATCH 11/64] additional migration --- .../migrations/0143_alter_user_options.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/registrar/migrations/0143_alter_user_options.py diff --git a/src/registrar/migrations/0143_alter_user_options.py b/src/registrar/migrations/0143_alter_user_options.py new file mode 100644 index 000000000..58e5cf3d5 --- /dev/null +++ b/src/registrar/migrations/0143_alter_user_options.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.17 on 2025-03-06 20:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0142_create_groups_v18"), + ] + + operations = [ + migrations.AlterModelOptions( + name="user", + options={ + "permissions": [ + ("analyst_access_permission", "Analyst Access Permission"), + ("omb_analyst_access_permission", "OMB Analyst Access Permission"), + ("full_access_permission", "Full Access Permission"), + ] + }, + ), + ] From fe1e64828b7fefc2146254af3b588addaa4ca597 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 6 Mar 2025 19:18:22 -0500 Subject: [PATCH 12/64] fix placing holds on domains --- src/registrar/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 71884b4a7..e3ef6f0ad 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -4327,7 +4327,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): # but cannot access this page when it is a request of type POST. if request.user.has_perm("registrar.full_access_permission") or request.user.has_perm( "registrar.analyst_access_permission" - ): + ) or request.user.has_perm("registrar.omb_analyst_access_permission"): return True return super().has_change_permission(request, obj) From 183fd2443dc4abb9dbc02f20907d5d5827f97be2 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 6 Mar 2025 21:52:25 -0500 Subject: [PATCH 13/64] Update documentation on uswds updates --- docs/developer/README.md | 6 +++--- src/package-lock.json | 25 ++++++++++++++++++++----- src/package.json | 1 + 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/developer/README.md b/docs/developer/README.md index 46194bd70..c7b63a89f 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -308,12 +308,12 @@ We use the [CSS Block Element Modifier (BEM)](https://getbem.com/naming/) naming ### Upgrading USWDS and other JavaScript packages 1. Version numbers can be manually controlled in `package.json`. Edit that, if desired. -2. Now run `docker-compose run node npm update`. -3. Then run `docker-compose up` to recompile and recopy the assets, or run `docker-compose updateUswds` if your docker is already up. -4. Make note of the dotgov changes in uswds-edited.js. +2. Now run `npx gulp updateUswds`. Refer to [official docs](https://designsystem.digital.gov/documentation/getting-started/developers/phase-two-compile/) to see what this is doing. +4. Make note of the dotgov changes in uswds-edited.js (Ctrl-F DOTGOV for modifications to USWDS compiled code). 5. Copy over the newly compiled code from uswds.js into uswds-edited.js. 6. Put back the dotgov changes you made note of into uswds-edited.js. 7. Examine the results in the running application (remember to empty your cache!) and commit `package.json` and `package-lock.json` if all is well. +8. read the [release notes](https://github.com/uswds/uswds/releases) for the new versions installed, note 'Breaking' and 'Markup change' and make adjustments to the code base as needed. ## Finite State Machines diff --git a/src/package-lock.json b/src/package-lock.json index 0f2a8c38b..7fa78f1db 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -18,6 +18,7 @@ "@babel/preset-env": "^7.26.0", "@uswds/compile": "1.2.1", "babel-loader": "^9.2.1", + "sass-embedded-darwin-x64": "^1.85.1", "sass-loader": "^12.6.0", "webpack": "^5.96.1", "webpack-stream": "^7.0.0" @@ -6353,15 +6354,13 @@ } }, "node_modules/sass-embedded-darwin-x64": { - "version": "1.83.0", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.83.0.tgz", - "integrity": "sha512-ERQ7Tvp1kFOW3ux4VDFIxb7tkYXHYc+zJpcrbs0hzcIO5ilIRU2tIOK1OrNwrFO6Qxyf7AUuBwYKLAtIU/Nz7g==", + "version": "1.85.1", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.85.1.tgz", + "integrity": "sha512-J4UFHUiyI9Z+mwYMwz11Ky9TYr3hY1fCxeQddjNGL/+ovldtb0yAIHvoVM0BGprQDm5JqhtUk8KyJ3RMJqpaAA==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", - "optional": true, "os": [ "darwin" ], @@ -6590,6 +6589,22 @@ "node": ">=14.0.0" } }, + "node_modules/sass-embedded/node_modules/sass-embedded-darwin-x64": { + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.83.0.tgz", + "integrity": "sha512-ERQ7Tvp1kFOW3ux4VDFIxb7tkYXHYc+zJpcrbs0hzcIO5ilIRU2tIOK1OrNwrFO6Qxyf7AUuBwYKLAtIU/Nz7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sass-embedded/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", diff --git a/src/package.json b/src/package.json index 9c50c93e1..468bfd7e3 100644 --- a/src/package.json +++ b/src/package.json @@ -19,6 +19,7 @@ "@babel/preset-env": "^7.26.0", "@uswds/compile": "1.2.1", "babel-loader": "^9.2.1", + "sass-embedded-darwin-x64": "^1.85.1", "sass-loader": "^12.6.0", "webpack": "^5.96.1", "webpack-stream": "^7.0.0" From 343d8dc962f4a371018b5652ff50dc5fa5cd9018 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 6 Mar 2025 22:00:37 -0500 Subject: [PATCH 14/64] fixed javascript and other issues on domain request admin --- src/registrar/admin.py | 4 + .../js/getgov-admin/domain-request-form.js | 233 +++++++++++------- .../helpers-portfolio-dynamic-fields.js | 4 +- 3 files changed, 149 insertions(+), 92 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e3ef6f0ad..d9b9509e1 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2976,6 +2976,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): "federally_recognized_tribe", "state_recognized_tribe", "about_your_organization", + "rejection_reason", + "rejection_reason_email", + "action_needed_reason", + "action_needed_reason_email", ] autocomplete_fields = [ diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index db6467875..8823cb46c 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -106,7 +106,9 @@ export function initApprovedDomain() { } const statusToCheck = "approved"; + const readonlyStatusToCheck = "Approved"; const statusSelect = document.getElementById("id_status"); + const statusField = document.querySelector("field-status"); const sessionVariableName = "showApprovedDomain"; let approvedDomainFormGroup = document.querySelector(".field-approved_domain"); @@ -120,18 +122,30 @@ export function initApprovedDomain() { // Handle showing/hiding the related fields on page load. function initializeFormGroups() { - let isStatus = statusSelect.value == statusToCheck; + let isStatus = false; + if (statusSelect) { + isStatus = statusSelect.value == statusToCheck; + } else { + // statusSelect does not exist, indicating readonly + if (statusField) { + let readonlyDiv = statusField.querySelector("div.readonly"); + let readonlyStatusText = readonlyDiv.textContent.trim(); + isStatus = readonlyStatusText == readonlyStatusToCheck; + } + } // Initial handling of these groups. updateFormGroupVisibility(isStatus); - // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage - statusSelect.addEventListener('change', () => { - // Show the approved if the status is what we expect. - isStatus = statusSelect.value == statusToCheck; - updateFormGroupVisibility(isStatus); - addOrRemoveSessionBoolean(sessionVariableName, isStatus); - }); + if (statusSelect) { + // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage + statusSelect.addEventListener('change', () => { + // Show the approved if the status is what we expect. + isStatus = statusSelect.value == statusToCheck; + updateFormGroupVisibility(isStatus); + addOrRemoveSessionBoolean(sessionVariableName, isStatus); + }); + } // Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the @@ -322,6 +336,7 @@ class CustomizableEmailBase { * @property {HTMLElement} modalConfirm - The confirm button in the modal. * @property {string} apiUrl - The API URL for fetching email content. * @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup. + * @property {string} readonlyStatusToCheck - The status to check against when readonly. Used for show/hide on textAreaFormGroup/dropdownFormGroup. * @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup. * @property {string} apiErrorMessage - The error message that the ajax call returns. */ @@ -338,6 +353,7 @@ class CustomizableEmailBase { this.textAreaFormGroup = config.textAreaFormGroup; this.dropdownFormGroup = config.dropdownFormGroup; this.statusToCheck = config.statusToCheck; + this.readonlyStatusToCheck = config.readonlyStatusToCheck; this.sessionVariableName = config.sessionVariableName; // Non-configurable variables @@ -363,19 +379,31 @@ class CustomizableEmailBase { // Handle showing/hiding the related fields on page load. initializeFormGroups() { - let isStatus = this.statusSelect.value == this.statusToCheck; + let isStatus = false; + if (this.statusSelect) { + this.statusSelect.value == this.statusToCheck; + } else { + // statusSelect does not exist, indicating readonly + if (this.dropdownFormGroup) { + let readonlyDiv = this.dropdownFormGroup.querySelector("div.readonly"); + let readonlyStatusText = readonlyDiv.textContent.trim(); + isStatus = readonlyStatusText == this.readonlyStatusToCheck; + } + } // Initial handling of these groups. this.updateFormGroupVisibility(isStatus); - // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage - this.statusSelect.addEventListener('change', () => { - // Show the action needed field if the status is what we expect. - // Then track if its shown or hidden in our session cache. - isStatus = this.statusSelect.value == this.statusToCheck; - this.updateFormGroupVisibility(isStatus); - addOrRemoveSessionBoolean(this.sessionVariableName, isStatus); - }); + if (this.statusSelect) { + // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage + this.statusSelect.addEventListener('change', () => { + // Show the action needed field if the status is what we expect. + // Then track if its shown or hidden in our session cache. + isStatus = this.statusSelect.value == this.statusToCheck; + this.updateFormGroupVisibility(isStatus); + addOrRemoveSessionBoolean(this.sessionVariableName, isStatus); + }); + } // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the @@ -403,58 +431,64 @@ class CustomizableEmailBase { } initializeDropdown() { - this.dropdown.addEventListener("change", () => { - let reason = this.dropdown.value; - if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) { - let searchParams = new URLSearchParams( - { - "reason": reason, - "domain_request_id": this.domainRequestId, - } - ); - // Replace the email content - fetch(`${this.apiUrl}?${searchParams.toString()}`) - .then(response => { - return response.json().then(data => data); - }) - .then(data => { - if (data.error) { - console.error("Error in AJAX call: " + data.error); - }else { - this.textarea.value = data.email; - } - this.updateUserInterface(reason); - }) - .catch(error => { - console.error(this.apiErrorMessage, error) - }); - } - }); + if (this.dropdown) { + this.dropdown.addEventListener("change", () => { + let reason = this.dropdown.value; + if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) { + let searchParams = new URLSearchParams( + { + "reason": reason, + "domain_request_id": this.domainRequestId, + } + ); + // Replace the email content + fetch(`${this.apiUrl}?${searchParams.toString()}`) + .then(response => { + return response.json().then(data => data); + }) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + }else { + this.textarea.value = data.email; + } + this.updateUserInterface(reason); + }) + .catch(error => { + console.error(this.apiErrorMessage, error) + }); + } + }); + } } initializeModalConfirm() { - this.modalConfirm.addEventListener("click", () => { - this.textarea.removeAttribute('readonly'); - this.textarea.focus(); - hideElement(this.directEditButton); - hideElement(this.modalTrigger); - }); + if (this.modalConfirm) { + this.modalConfirm.addEventListener("click", () => { + this.textarea.removeAttribute('readonly'); + this.textarea.focus(); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); + }); + } } initializeDirectEditButton() { - this.directEditButton.addEventListener("click", () => { - this.textarea.removeAttribute('readonly'); - this.textarea.focus(); - hideElement(this.directEditButton); - hideElement(this.modalTrigger); - }); + if (this.directEditButton) { + this.directEditButton.addEventListener("click", () => { + this.textarea.removeAttribute('readonly'); + this.textarea.focus(); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); + }); + } } isEmailAlreadySent() { return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, ''); } - updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) { + updateUserInterface(reason, excluded_reasons=["other"]) { if (!reason) { // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text this.showPlaceholderNoReason(); @@ -468,23 +502,25 @@ class CustomizableEmailBase { // Helper function that makes overriding the readonly textarea easy showReadonlyTextarea() { - // A triggering selection is selected, all hands on board: - this.textarea.setAttribute('readonly', true); - showElement(this.textarea); - hideElement(this.textareaPlaceholder); + if (this.textarea && this.textareaPlaceholder) { + // A triggering selection is selected, all hands on board: + this.textarea.setAttribute('readonly', true); + showElement(this.textarea); + hideElement(this.textareaPlaceholder); - if (this.isEmailAlreadySentConst) { - hideElement(this.directEditButton); - showElement(this.modalTrigger); + if (this.isEmailAlreadySentConst) { + hideElement(this.directEditButton); + showElement(this.modalTrigger); + } else { + showElement(this.directEditButton); + hideElement(this.modalTrigger); + } + + if (this.isEmailAlreadySent()) { + this.formLabel.innerHTML = "Email sent to creator:"; } else { - showElement(this.directEditButton); - hideElement(this.modalTrigger); - } - - if (this.isEmailAlreadySent()) { - this.formLabel.innerHTML = "Email sent to creator:"; - } else { - this.formLabel.innerHTML = "Email:"; + this.formLabel.innerHTML = "Email:"; + } } } @@ -516,9 +552,10 @@ class customActionNeededEmail extends CustomizableEmailBase { lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"), modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"), apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null, - textAreaFormGroup: document.querySelector('.field-action_needed_reason'), - dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'), + textAreaFormGroup: document.querySelector('.field-action_needed_reason_email'), + dropdownFormGroup: document.querySelector('.field-action_needed_reason'), statusToCheck: "action needed", + readonlyStatusToCheck: "Action needed", sessionVariableName: "showActionNeededReason", apiErrorMessage: "Error when attempting to grab action needed email: " } @@ -529,7 +566,15 @@ class customActionNeededEmail extends CustomizableEmailBase { // Hide/show the email fields depending on the current status this.initializeFormGroups(); // Setup the textarea, edit button, helper text - this.updateUserInterface(); + let reason = null; + if (this.dropdown) { + reason = this.dropdown.value; + } else if (this.dropdownFormGroup && this.dropdownFormGroup.querySelector("div.readonly")) { + if (this.dropdownFormGroup.querySelector("div.readonly").textContent) { + reason = this.dropdownFormGroup.querySelector("div.readonly").textContent.trim() + } + } + this.updateUserInterface(reason); this.initializeDropdown(); this.initializeModalConfirm(); this.initializeDirectEditButton(); @@ -560,12 +605,12 @@ export function initActionNeededEmail() { // Initialize UI const customEmail = new customActionNeededEmail(); - // Check that every variable was setup correctly - const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); - if (nullItems.length > 0) { - console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`) - return; - } + // // Check that every variable was setup correctly + // const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); + // if (nullItems.length > 0) { + // console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`) + // return; + // } customEmail.loadActionNeededEmail() }); } @@ -581,6 +626,7 @@ class customRejectedEmail extends CustomizableEmailBase { textAreaFormGroup: document.querySelector('.field-rejection_reason'), dropdownFormGroup: document.querySelector('.field-rejection_reason_email'), statusToCheck: "rejected", + readonlyStatusToCheck: "Rejected", sessionVariableName: "showRejectionReason", errorMessage: "Error when attempting to grab rejected email: " }; @@ -589,7 +635,15 @@ class customRejectedEmail extends CustomizableEmailBase { loadRejectedEmail() { this.initializeFormGroups(); - this.updateUserInterface(); + let reason = null; + if (this.dropdown) { + reason = this.dropdown.value; + } else if (this.dropdownFormGroup && this.dropdownFormGroup.querySelector("div.readonly")) { + if (this.dropdownFormGroup.querySelector("div.readonly").textContent) { + reason = this.dropdownFormGroup.querySelector("div.readonly").textContent.trim() + } + } + this.updateUserInterface(reason); this.initializeDropdown(); this.initializeModalConfirm(); this.initializeDirectEditButton(); @@ -600,7 +654,7 @@ class customRejectedEmail extends CustomizableEmailBase { this.showPlaceholder("Email:", "Select a rejection reason to see email"); } - updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) { + updateUserInterface(reason, excluded_reasons=[]) { super.updateUserInterface(reason, excluded_reasons); } } @@ -619,12 +673,12 @@ export function initRejectedEmail() { // Initialize UI const customEmail = new customRejectedEmail(); - // Check that every variable was setup correctly - const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); - if (nullItems.length > 0) { - console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`) - return; - } + // // Check that every variable was setup correctly + // const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); + // if (nullItems.length > 0) { + // console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`) + // return; + // } customEmail.loadRejectedEmail() }); } @@ -648,7 +702,6 @@ function handleSuborgFieldsAndButtons() { // Ensure that every variable is present before proceeding if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) { - console.warn("handleSuborganizationSelection() => Could not find required fields.") return; } diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js index 9a60e1684..54d0e073b 100644 --- a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js +++ b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js @@ -404,7 +404,7 @@ export function handlePortfolioSelection( updateSubOrganizationDropdown(portfolio_id); // Show fields relevant to a selected portfolio - showElement(suborganizationField); + if (suborganizationField) showElement(suborganizationField); hideElement(seniorOfficialField); showElement(portfolioSeniorOfficialField); @@ -427,7 +427,7 @@ export function handlePortfolioSelection( // No portfolio is selected - reverse visibility of fields // Hide suborganization field as no portfolio is selected - hideElement(suborganizationField); + if (suborganizationField) hideElement(suborganizationField); // Show fields that are relevant when no portfolio is selected showElement(seniorOfficialField); From 210164da3a1c18c1c26588cb27da8e230089d6b5 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 6 Mar 2025 22:03:50 -0500 Subject: [PATCH 15/64] cleanup --- docs/developer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer/README.md b/docs/developer/README.md index c7b63a89f..eab77f645 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -305,7 +305,7 @@ You can also compile the **Sass** at any time using `npx gulp compile`. Similarl We use the [CSS Block Element Modifier (BEM)](https://getbem.com/naming/) naming convention for our custom classes. This is in line with how USWDS [approaches](https://designsystem.digital.gov/whats-new/updates/2019/04/08/introducing-uswds-2-0/) their CSS class architecture and helps keep our code cohesive and readable. -### Upgrading USWDS and other JavaScript packages +### Upgrading USWDS 1. Version numbers can be manually controlled in `package.json`. Edit that, if desired. 2. Now run `npx gulp updateUswds`. Refer to [official docs](https://designsystem.digital.gov/documentation/getting-started/developers/phase-two-compile/) to see what this is doing. From 9e12699009a9d8f6aae9bb5979585973a8795bd1 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 6 Mar 2025 22:07:02 -0500 Subject: [PATCH 16/64] cleanup --- docs/developer/README.md | 2 +- src/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developer/README.md b/docs/developer/README.md index eab77f645..63a4e08c6 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -313,7 +313,7 @@ We use the [CSS Block Element Modifier (BEM)](https://getbem.com/naming/) naming 5. Copy over the newly compiled code from uswds.js into uswds-edited.js. 6. Put back the dotgov changes you made note of into uswds-edited.js. 7. Examine the results in the running application (remember to empty your cache!) and commit `package.json` and `package-lock.json` if all is well. -8. read the [release notes](https://github.com/uswds/uswds/releases) for the new versions installed, note 'Breaking' and 'Markup change' and make adjustments to the code base as needed. +8. Read the [release notes](https://github.com/uswds/uswds/releases) for the new versions installed, note 'Breaking' and 'Markup change' and make adjustments to the code base as needed. ## Finite State Machines diff --git a/src/package.json b/src/package.json index 468bfd7e3..ed5cefa09 100644 --- a/src/package.json +++ b/src/package.json @@ -19,7 +19,7 @@ "@babel/preset-env": "^7.26.0", "@uswds/compile": "1.2.1", "babel-loader": "^9.2.1", - "sass-embedded-darwin-x64": "^1.85.1", + "sass-loader": "^12.6.0", "webpack": "^5.96.1", "webpack-stream": "^7.0.0" From 4f1171c00b0a3664ffee0a1fa7d81e39f7a67dd7 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 6 Mar 2025 22:12:35 -0500 Subject: [PATCH 17/64] add sass-embed-darwin-x64 to package.json --- src/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/package.json b/src/package.json index ed5cefa09..468bfd7e3 100644 --- a/src/package.json +++ b/src/package.json @@ -19,7 +19,7 @@ "@babel/preset-env": "^7.26.0", "@uswds/compile": "1.2.1", "babel-loader": "^9.2.1", - + "sass-embedded-darwin-x64": "^1.85.1", "sass-loader": "^12.6.0", "webpack": "^5.96.1", "webpack-stream": "^7.0.0" From ed7626d5299875035facde33ed5f6e39cfcd77e2 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 6 Mar 2025 22:28:46 -0500 Subject: [PATCH 18/64] Remove sass-embed-darwin and update other packages --- src/package-lock.json | 317 ++++++++++++++---------------------------- src/package.json | 1 - 2 files changed, 107 insertions(+), 211 deletions(-) diff --git a/src/package-lock.json b/src/package-lock.json index 7fa78f1db..f46ecd3e8 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -18,7 +18,6 @@ "@babel/preset-env": "^7.26.0", "@uswds/compile": "1.2.1", "babel-loader": "^9.2.1", - "sass-embedded-darwin-x64": "^1.85.1", "sass-loader": "^12.6.0", "webpack": "^5.96.1", "webpack-stream": "^7.0.0" @@ -64,23 +63,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.8.tgz", - "integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.8", + "@babel/generator": "^7.26.9", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.7", - "@babel/parser": "^7.26.8", - "@babel/template": "^7.26.8", - "@babel/traverse": "^7.26.8", - "@babel/types": "^7.26.8", - "@types/gensync": "^1.0.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -96,14 +94,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.8.tgz", - "integrity": "sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.8", - "@babel/types": "^7.26.8", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -143,18 +141,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", - "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz", + "integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-replace-supers": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.25.9", + "@babel/traverse": "^7.26.9", "semver": "^6.3.1" }, "engines": { @@ -364,27 +362,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", - "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", + "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.8.tgz", - "integrity": "sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.8" + "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" @@ -810,13 +808,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", - "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", + "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { @@ -1377,9 +1375,9 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.8.tgz", - "integrity": "sha512-um7Sy+2THd697S4zJEfv/U5MHGJzkN2xhtsR3T/SWRbVSic62nbISh51VVfU9JiO/L/Z97QczHTaFVkOU8IzNg==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", + "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1412,7 +1410,7 @@ "@babel/plugin-transform-dynamic-import": "^7.25.9", "@babel/plugin-transform-exponentiation-operator": "^7.26.3", "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.26.9", "@babel/plugin-transform-function-name": "^7.25.9", "@babel/plugin-transform-json-strings": "^7.25.9", "@babel/plugin-transform-literals": "^7.25.9", @@ -1476,9 +1474,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", - "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", "dev": true, "license": "MIT", "dependencies": { @@ -1489,32 +1487,32 @@ } }, "node_modules/@babel/template": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.8.tgz", - "integrity": "sha512-iNKaX3ZebKIsCvJ+0jd6embf+Aulaa3vNBqZ41kM7iTWjx5qzWKXGHiJUW3+nTpQ18SG11hdF8OAzKrpXkb96Q==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.8", - "@babel/types": "^7.26.8" + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.8.tgz", - "integrity": "sha512-nic9tRkjYH0oB2dzr/JoGIm+4Q6SuYeLEiIiZDwBscRMYFJ+tMAz98fuel9ZnbXViA2I0HVSSRRK8DW5fjXStA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.8", - "@babel/parser": "^7.26.8", - "@babel/template": "^7.26.8", - "@babel/types": "^7.26.8", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1523,9 +1521,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.8.tgz", - "integrity": "sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", "dev": true, "license": "MIT", "dependencies": { @@ -2000,13 +1998,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/gensync": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz", - "integrity": "sha512-C3YYeRQWp2fmq9OryX+FoDy8nXS6scQ7dPptD8LnFDAUNcKWJjXQKDNJD3HVm+kOUsXhTOkpi69vI4EuAr95bA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2015,9 +2006,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", - "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", + "version": "22.13.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", + "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2256,9 +2247,9 @@ "license": "Apache-2.0" }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", "bin": { @@ -2869,9 +2860,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001699", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz", - "integrity": "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==", + "version": "1.0.30001702", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz", + "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==", "dev": true, "funding": [ { @@ -3143,13 +3134,13 @@ } }, "node_modules/core-js-compat": { - "version": "3.40.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz", - "integrity": "sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==", + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", + "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.24.3" + "browserslist": "^4.24.4" }, "funding": { "type": "opencollective", @@ -3372,9 +3363,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.97", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.97.tgz", - "integrity": "sha512-HKLtaH02augM7ZOdYRuO19rWDeY+QSJ1VxnXFa/XDFLf07HvM90pALIJFgrO+UVaajI3+aJMMpojoUTLZyQ7JQ==", + "version": "1.5.113", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz", + "integrity": "sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==", "dev": true, "license": "ISC" }, @@ -3662,13 +3653,6 @@ "node": ">=8.6.0" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-levenshtein": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", @@ -3707,9 +3691,9 @@ } }, "node_modules/fastq": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", - "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, "license": "ISC", "dependencies": { @@ -5719,16 +5703,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/puppeteer": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-9.1.1.tgz", @@ -6114,9 +6088,9 @@ } }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", "engines": { @@ -6165,9 +6139,9 @@ } }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6187,9 +6161,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.84.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.84.0.tgz", - "integrity": "sha512-XDAbhEPJRxi7H0SxrnOpiXFQoUJHwkR2u3Zc4el+fK/Tt5Hpzw5kkQ59qVDfvdaUq6gCrEZIbySFBM2T9DNKHg==", + "version": "1.85.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz", + "integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==", "license": "MIT", "dependencies": { "chokidar": "^4.0.0", @@ -6354,13 +6328,15 @@ } }, "node_modules/sass-embedded-darwin-x64": { - "version": "1.85.1", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.85.1.tgz", - "integrity": "sha512-J4UFHUiyI9Z+mwYMwz11Ky9TYr3hY1fCxeQddjNGL/+ovldtb0yAIHvoVM0BGprQDm5JqhtUk8KyJ3RMJqpaAA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.83.0.tgz", + "integrity": "sha512-ERQ7Tvp1kFOW3ux4VDFIxb7tkYXHYc+zJpcrbs0hzcIO5ilIRU2tIOK1OrNwrFO6Qxyf7AUuBwYKLAtIU/Nz7g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", + "optional": true, "os": [ "darwin" ], @@ -6589,22 +6565,6 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded/node_modules/sass-embedded-darwin-x64": { - "version": "1.83.0", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.83.0.tgz", - "integrity": "sha512-ERQ7Tvp1kFOW3ux4VDFIxb7tkYXHYc+zJpcrbs0hzcIO5ilIRU2tIOK1OrNwrFO6Qxyf7AUuBwYKLAtIU/Nz7g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/sass-embedded/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -6676,9 +6636,9 @@ } }, "node_modules/sass/node_modules/readdirp": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.1.tgz", - "integrity": "sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -6993,9 +6953,9 @@ } }, "node_modules/terser": { - "version": "5.38.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.2.tgz", - "integrity": "sha512-w8CXxxbFA5zfNsR/i8HZq5bvn18AK0O9jj7hyo1YqkovLxEFa0uP0LCVGZRqiRaKRFxXhELBp8SteeAjEnfeJg==", + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", + "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7012,9 +6972,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz", - "integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "dev": true, "license": "MIT", "dependencies": { @@ -7244,9 +7204,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", - "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -7274,16 +7234,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -7537,9 +7487,9 @@ "license": "BSD-2-Clause" }, "node_modules/webpack": { - "version": "5.97.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", - "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", + "version": "5.98.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", + "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, "license": "MIT", "dependencies": { @@ -7561,9 +7511,9 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", + "schema-utils": "^4.3.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", + "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, @@ -7632,59 +7582,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -7857,9 +7754,9 @@ } }, "node_modules/yocto-queue": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", - "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.0.tgz", + "integrity": "sha512-KHBC7z61OJeaMGnF3wqNZj+GGNXOyypZviiKpQeiHirG5Ib1ImwcLBH70rbMSkKfSmUNBsdf2PwaEJtKvgmkNw==", "dev": true, "license": "MIT", "engines": { diff --git a/src/package.json b/src/package.json index 468bfd7e3..9c50c93e1 100644 --- a/src/package.json +++ b/src/package.json @@ -19,7 +19,6 @@ "@babel/preset-env": "^7.26.0", "@uswds/compile": "1.2.1", "babel-loader": "^9.2.1", - "sass-embedded-darwin-x64": "^1.85.1", "sass-loader": "^12.6.0", "webpack": "^5.96.1", "webpack-stream": "^7.0.0" From 04007712cd2ff4149d6d1532bf03999423716ceb Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Thu, 6 Mar 2025 22:30:20 -0500 Subject: [PATCH 19/64] Cleanup --- docs/developer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer/README.md b/docs/developer/README.md index 63a4e08c6..442a13634 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -305,7 +305,7 @@ You can also compile the **Sass** at any time using `npx gulp compile`. Similarl We use the [CSS Block Element Modifier (BEM)](https://getbem.com/naming/) naming convention for our custom classes. This is in line with how USWDS [approaches](https://designsystem.digital.gov/whats-new/updates/2019/04/08/introducing-uswds-2-0/) their CSS class architecture and helps keep our code cohesive and readable. -### Upgrading USWDS +### Updating USWDS 1. Version numbers can be manually controlled in `package.json`. Edit that, if desired. 2. Now run `npx gulp updateUswds`. Refer to [official docs](https://designsystem.digital.gov/documentation/getting-started/developers/phase-two-compile/) to see what this is doing. From 6ef3f50a7bcccd0157d81fa2566c517bf6467050 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 6 Mar 2025 22:46:12 -0500 Subject: [PATCH 20/64] fix portfolio javascript --- .../assets/src/js/getgov-admin/portfolio-form.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index 74729c2b2..2e437c520 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -285,9 +285,11 @@ function handlePortfolioFields(){ handleStateTerritoryChange(); }); } - organizationTypeDropdown.addEventListener("change", function() { - handleOrganizationTypeChange(); - }); + if (organizationTypeDropdown) { + organizationTypeDropdown.addEventListener("change", function() { + handleOrganizationTypeChange(); + }); + } } // Run initial setup functions From 952f1c35cc052dda6ccf21a15dcc2635efc44bed Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 7 Mar 2025 07:04:39 -0500 Subject: [PATCH 21/64] fixed place and remove hold --- src/registrar/admin.py | 8 +++++--- .../templates/django/admin/domain_change_form.html | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d9b9509e1..020dc2540 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -4329,9 +4329,11 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): # Fixes a bug wherein users which are only is_staff # can access 'change' when GET, # but cannot access this page when it is a request of type POST. - if request.user.has_perm("registrar.full_access_permission") or request.user.has_perm( - "registrar.analyst_access_permission" - ) or request.user.has_perm("registrar.omb_analyst_access_permission"): + if ( + request.user.has_perm("registrar.full_access_permission") + or request.user.has_perm("registrar.analyst_access_permission") + or request.user.has_perm("registrar.omb_analyst_access_permission") + ): return True return super().has_change_permission(request, obj) diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index 9f34feae6..2e6f57237 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -33,8 +33,10 @@ {% endif %} {% if original.state == original.State.READY or original.state == original.State.ON_HOLD %} + {% if not adminform.form.is_omb_analyst %} | {% endif %} + {% endif %} {% if original.state != original.State.DELETED and not adminform.form.is_omb_analyst %} Remove from registry From 7994484fed1d15e308a5ce16b0bcd6fe0b1be43c Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 7 Mar 2025 19:36:24 -0500 Subject: [PATCH 22/64] DomainInvitationAdmin tests --- src/registrar/admin.py | 2 +- src/registrar/tests/common.py | 19 +++++ src/registrar/tests/test_admin.py | 125 ++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 020dc2540..c72c5271a 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1820,7 +1820,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin): if request.user.groups.filter(name="omb_analysts_group").exists(): return ( obj.domain.domain_info.converted_generic_org_type == DomainRequest.OrganizationChoices.FEDERAL - and obj.domain.domain_info.federal_type == BranchChoices.EXECUTIVE + and obj.domain.domain_info.converted_federal_type == BranchChoices.EXECUTIVE ) return super().has_view_permission(request, obj) diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index bb65ef6b1..7b6068c6a 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1009,6 +1009,25 @@ def create_user(**kwargs): user.groups.set([group]) return user +def create_omb_analyst_user(**kwargs): + """Creates a analyst user with is_staff=True and the group cisa_analysts_group""" + User = get_user_model() + p = "userpass" + user = User.objects.create_user( + username=kwargs.get("username", "ombanalystuser"), + email=kwargs.get("email", "ombanalyst@example.com"), + first_name=kwargs.get("first_name", "first"), + last_name=kwargs.get("last_name", "last"), + is_staff=kwargs.get("is_staff", True), + title=kwargs.get("title", "title"), + password=kwargs.get("password", p), + phone=kwargs.get("phone", "8003111234"), + ) + # Retrieve the group or create it if it doesn't exist + group, _ = UserGroup.objects.get_or_create(name="omb_analysts_group") + # Add the user to the group + user.groups.set([group]) + return user def create_test_user(): username = "test_user" diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 1de6b1be3..3f2edbafb 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -3,6 +3,7 @@ from django.utils import timezone from django.test import TestCase, RequestFactory, Client from django.contrib.admin.sites import AdminSite from registrar import models +from registrar.utility.constants import BranchChoices from registrar.utility.email import EmailSendingError from registrar.utility.errors import MissingEmailError from waffle.testutils import override_flag @@ -57,6 +58,7 @@ from .common import ( MockDbForSharedTests, AuditedAdminMockData, completed_domain_request, + create_omb_analyst_user, create_test_user, generic_domain_object, less_console_noise, @@ -136,18 +138,21 @@ class TestDomainInvitationAdmin(WebTest): csrf_checks = False @classmethod - def setUpClass(self): + def setUpClass(cls): super().setUpClass() - self.site = AdminSite() - self.factory = RequestFactory() - self.superuser = create_superuser() + cls.site = AdminSite() + cls.factory = RequestFactory() def setUp(self): super().setUp() + self.superuser = create_superuser() + self.cisa_analyst = create_user() + self.omb_analyst = create_omb_analyst_user() self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite()) self.domain = Domain.objects.create(name="example.com") + self.fed_agency = FederalAgency.objects.create(agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE) self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser) - DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser) + self.domain_info = DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser) """Create a client object""" self.client = Client(HTTP_HOST="localhost:8080") self.client.force_login(self.superuser) @@ -159,10 +164,120 @@ class TestDomainInvitationAdmin(WebTest): DomainInvitation.objects.all().delete() DomainInformation.objects.all().delete() Portfolio.objects.all().delete() + self.fed_agency.delete() Domain.objects.all().delete() Contact.objects.all().delete() User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure regular analysts can view domain invitations.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.cisa_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + + @less_console_noise_decorator + def test_omb_analyst_view_non_feb_domain(self): + """Ensure OMB analysts cannot view non-federal domains.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist")) + self.assertNotContains(response, invitation.email) + + @less_console_noise_decorator + def test_omb_analyst_view_feb_domain(self): + """Ensure OMB analysts can view federal executive branch domains.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL + self.portfolio.federal_agency = self.fed_agency + self.portfolio.save() + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist")) + self.assertContains(response, invitation.email) + + @less_console_noise_decorator + def test_superuser_view(self): + """Ensure superusers can view domain invitations.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + + @less_console_noise_decorator + def test_analyst_change(self): + """Ensure regular analysts can view domain invitations but not update.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.cisa_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + # test whether fields are readonly or editable + self.assertNotContains(response, "id_domain") + self.assertNotContains(response, "id_email") + + @less_console_noise_decorator + def test_omb_analyst_change_non_feb_domain(self): + """Ensure OMB analysts cannot change non-federal domains.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id])) + self.assertEqual(response.status_code, 302) + + @less_console_noise_decorator + def test_omb_analyst_change_feb_domain(self): + """Ensure OMB analysts can view federal executive branch domains.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + # update domain + self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL + self.portfolio.federal_agency = self.fed_agency + self.portfolio.save() + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + # test whether fields are readonly or editable + self.assertNotContains(response, "id_domain") + self.assertNotContains(response, "id_email") + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure superusers can change domain invitations.""" + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + response = self.client.get(reverse("admin:registrar_domaininvitation_change", args=[invitation.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, invitation.email) + # test whether fields are readonly or editable + self.assertContains(response, "id_domain") + self.assertContains(response, "id_email") + + @less_console_noise_decorator + def test_omb_analyst_filter_feb_domain(self): + """Ensure OMB analysts can apply filters and only federal executive branch domains show.""" + # create invitation on domain that is not FEB + invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist"), {"status": DomainInvitation.DomainInvitationStatus.INVITED}) + self.assertNotContains(response, invitation.email) + # update domain + self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL + self.portfolio.federal_agency = self.fed_agency + self.portfolio.save() + response = self.client.get(reverse("admin:registrar_domaininvitation_changelist"), {"status": DomainInvitation.DomainInvitationStatus.INVITED}) + self.assertContains(response, invitation.email) + + # test_analyst_view + # test_omb_analyst_view_non_feb_domain + # test_omb_analyst_view_feb_domain + # test_superuser_view + # test_analyst_change + # test_omb_analyst_change_non_feb_domain + # test_omb_analyst_change_feb_domain + # test_superuser + # test_filter_feb + + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" From 394147d657bca5371a9be53d04791ab1a2e8d638 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 7 Mar 2025 19:51:40 -0500 Subject: [PATCH 23/64] DomainInvitationAdmin tests --- src/registrar/tests/common.py | 2 ++ src/registrar/tests/test_admin.py | 34 +++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 7b6068c6a..622bd5b22 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -1009,6 +1009,7 @@ def create_user(**kwargs): user.groups.set([group]) return user + def create_omb_analyst_user(**kwargs): """Creates a analyst user with is_staff=True and the group cisa_analysts_group""" User = get_user_model() @@ -1029,6 +1030,7 @@ def create_omb_analyst_user(**kwargs): user.groups.set([group]) return user + def create_test_user(): username = "test_user" first_name = "First" diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 3f2edbafb..a21bfd4f2 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -150,9 +150,13 @@ class TestDomainInvitationAdmin(WebTest): self.omb_analyst = create_omb_analyst_user() self.admin = ListHeaderAdmin(model=DomainInvitationAdmin, admin_site=AdminSite()) self.domain = Domain.objects.create(name="example.com") - self.fed_agency = FederalAgency.objects.create(agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE) + self.fed_agency = FederalAgency.objects.create( + agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser) - self.domain_info = DomainInformation.objects.create(domain=self.domain, portfolio=self.portfolio, creator=self.superuser) + self.domain_info = DomainInformation.objects.create( + domain=self.domain, portfolio=self.portfolio, creator=self.superuser + ) """Create a client object""" self.client = Client(HTTP_HOST="localhost:8080") self.client.force_login(self.superuser) @@ -197,7 +201,7 @@ class TestDomainInvitationAdmin(WebTest): response = self.client.get(reverse("admin:registrar_domaininvitation_changelist")) self.assertContains(response, invitation.email) - @less_console_noise_decorator + @less_console_noise_decorator def test_superuser_view(self): """Ensure superusers can view domain invitations.""" invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) @@ -216,6 +220,9 @@ class TestDomainInvitationAdmin(WebTest): # test whether fields are readonly or editable self.assertNotContains(response, "id_domain") self.assertNotContains(response, "id_email") + self.assertContains(response, "closelink") + self.assertNotContains(response, "Save") + self.assertNotContains(response, "Delete") @less_console_noise_decorator def test_omb_analyst_change_non_feb_domain(self): @@ -229,7 +236,7 @@ class TestDomainInvitationAdmin(WebTest): def test_omb_analyst_change_feb_domain(self): """Ensure OMB analysts can view federal executive branch domains.""" invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) - # update domain + # update domain self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL self.portfolio.federal_agency = self.fed_agency self.portfolio.save() @@ -240,6 +247,9 @@ class TestDomainInvitationAdmin(WebTest): # test whether fields are readonly or editable self.assertNotContains(response, "id_domain") self.assertNotContains(response, "id_email") + self.assertContains(response, "closelink") + self.assertNotContains(response, "Save") + self.assertNotContains(response, "Delete") @less_console_noise_decorator def test_superuser_change(self): @@ -251,6 +261,9 @@ class TestDomainInvitationAdmin(WebTest): # test whether fields are readonly or editable self.assertContains(response, "id_domain") self.assertContains(response, "id_email") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") @less_console_noise_decorator def test_omb_analyst_filter_feb_domain(self): @@ -258,13 +271,19 @@ class TestDomainInvitationAdmin(WebTest): # create invitation on domain that is not FEB invitation = DomainInvitation.objects.create(email="test@example.com", domain=self.domain) self.client.force_login(self.omb_analyst) - response = self.client.get(reverse("admin:registrar_domaininvitation_changelist"), {"status": DomainInvitation.DomainInvitationStatus.INVITED}) + response = self.client.get( + reverse("admin:registrar_domaininvitation_changelist"), + {"status": DomainInvitation.DomainInvitationStatus.INVITED}, + ) self.assertNotContains(response, invitation.email) - # update domain + # update domain self.portfolio.organization_type = DomainRequest.OrganizationChoices.FEDERAL self.portfolio.federal_agency = self.fed_agency self.portfolio.save() - response = self.client.get(reverse("admin:registrar_domaininvitation_changelist"), {"status": DomainInvitation.DomainInvitationStatus.INVITED}) + response = self.client.get( + reverse("admin:registrar_domaininvitation_changelist"), + {"status": DomainInvitation.DomainInvitationStatus.INVITED}, + ) self.assertContains(response, invitation.email) # test_analyst_view @@ -277,7 +296,6 @@ class TestDomainInvitationAdmin(WebTest): # test_superuser # test_filter_feb - @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" From fb4e278ba5e3cbaafc5bc5f348f5c7ececc59f19 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 7 Mar 2025 20:14:44 -0500 Subject: [PATCH 24/64] UserPortfolioPermissionAdmin tests --- src/registrar/tests/test_admin.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index a21bfd4f2..e17c311ec 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -286,16 +286,6 @@ class TestDomainInvitationAdmin(WebTest): ) self.assertContains(response, invitation.email) - # test_analyst_view - # test_omb_analyst_view_non_feb_domain - # test_omb_analyst_view_feb_domain - # test_superuser_view - # test_analyst_change - # test_omb_analyst_change_non_feb_domain - # test_omb_analyst_change_feb_domain - # test_superuser - # test_filter_feb - @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -1272,6 +1262,7 @@ class TestUserPortfolioPermissionAdmin(TestCase): self.client = Client(HTTP_HOST="localhost:8080") self.superuser = create_superuser() self.testuser = create_test_user() + self.omb_analyst = create_omb_analyst_user() self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) def tearDown(self): @@ -1281,6 +1272,26 @@ class TestUserPortfolioPermissionAdmin(TestCase): User.objects.all().delete() UserPortfolioPermission.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view user portfolio permissions list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_userportfoliopermission_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts cannot change user portfolio permission.""" + self.client.force_login(self.omb_analyst) + user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + response = self.client.get( + "/admin/registrar/userportfoliopermission/{}/change/".format(user_portfolio_permission.pk), + follow=True, + ) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_change_form_description(self): """Tests if this model has a model description on the change form view""" From a2091c54958486d0fa56b3f3b2c412a7b12e1c25 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 7 Mar 2025 20:21:12 -0500 Subject: [PATCH 25/64] PortfolioInvitationAdmin tests --- src/registrar/tests/test_admin.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index e17c311ec..156e8566a 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1348,6 +1348,7 @@ class TestPortfolioInvitationAdmin(TestCase): def setUp(self): """Create a client object""" self.client = Client(HTTP_HOST="localhost:8080") + self.omb_analyst = create_omb_analyst_user() self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) def tearDown(self): @@ -1361,6 +1362,26 @@ class TestPortfolioInvitationAdmin(TestCase): def tearDownClass(self): User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view portfolio invitations list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_portfolioinvitation_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts cannot change portfolio invitation.""" + self.client.force_login(self.omb_analyst) + invitation, _ = PortfolioInvitation.objects.get_or_create( + email=self.superuser.email, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + response = self.client.get( + "/admin/registrar/portfolioinvitation/{}/change/".format(invitation.pk), + follow=True, + ) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" From 3bf8333f3dd87546644b9a844ab976771257e01a Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 8 Mar 2025 07:20:49 -0500 Subject: [PATCH 26/64] TestDomainInformationAdmin tests --- src/registrar/tests/test_admin.py | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 156e8566a..f9b9b1980 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1956,6 +1956,7 @@ class TestHostAdmin(TestCase): cls.factory = RequestFactory() cls.admin = MyHostAdmin(model=Host, admin_site=cls.site) cls.superuser = create_superuser() + cls.omb_analyst = create_omb_analyst_user() def setUp(self): """Setup environment for a mock admin user""" @@ -1971,6 +1972,13 @@ class TestHostAdmin(TestCase): def tearDownClass(cls): User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view hosts list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_host_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -2035,6 +2043,7 @@ class TestDomainInformationAdmin(TestCase): cls.admin = DomainInformationAdmin(model=DomainInformation, admin_site=cls.site) cls.superuser = create_superuser() cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() cls.mock_data_generator = AuditedAdminMockData() cls.test_helper = GenericTestHelper( factory=cls.factory, @@ -2046,12 +2055,24 @@ class TestDomainInformationAdmin(TestCase): def setUp(self): self.client = Client(HTTP_HOST="localhost:8080") + self.nonfeddomain = Domain.objects.create(name="nonfeddomain.com") + self.feddomain = Domain.objects.create(name="feddomain.com") + self.fed_agency = FederalAgency.objects.create( + agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) + self.portfolio = Portfolio.objects.create(organization_name="new portfolio", creator=self.superuser) + self.domain_info = DomainInformation.objects.create( + domain=self.feddomain, portfolio=self.portfolio, creator=self.superuser + ) def tearDown(self): """Delete all Users, Domains, and UserDomainRoles""" DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() Domain.objects.all().delete() + DomainInformation.objects.all().delete() + Portfolio.objects.all().delete() + self.fed_agency.delete() Contact.objects.all().delete() @classmethod @@ -2059,6 +2080,56 @@ class TestDomainInformationAdmin(TestCase): User.objects.all().delete() SeniorOfficial.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure regular analysts cannot view domain information list.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_domaininformation_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view domain information list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domaininformation_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_superuser_view(self): + """Ensure superusers can view domain information list.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_domaininformation_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feddomain.name) + + @less_console_noise_decorator + def test_analyst_change(self): + """Ensure regular analysts cannot view/edit domain information directly.""" + self.client.force_login(self.staffuser) + response = self.client.get( + reverse("admin:registrar_domaininformation_change", args=[self.feddomain.domain_info.id]) + ) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts cannot view/edit domain information directly.""" + self.client.force_login(self.omb_analyst) + response = self.client.get( + reverse("admin:registrar_domaininformation_change", args=[self.feddomain.domain_info.id]) + ) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure superusers can view/change domain information directly.""" + self.client.force_login(self.superuser) + response = self.client.get( + reverse("admin:registrar_domaininformation_change", args=[self.feddomain.domain_info.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feddomain.name) + @less_console_noise_decorator def test_domain_information_senior_official_is_alphabetically_sorted(self): """Tests if the senior offical dropdown is alphanetically sorted in the django admin display""" From b8636b3473ebbdf5d842a0589e1ec05a60bfac2e Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 8 Mar 2025 07:32:49 -0500 Subject: [PATCH 27/64] TestUserDomainRoleAdmin tests --- src/registrar/tests/test_admin.py | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index f9b9b1980..ccb54747a 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1956,6 +1956,7 @@ class TestHostAdmin(TestCase): cls.factory = RequestFactory() cls.admin = MyHostAdmin(model=Host, admin_site=cls.site) cls.superuser = create_superuser() + cls.staffuser = create_user() cls.omb_analyst = create_omb_analyst_user() def setUp(self): @@ -1972,6 +1973,13 @@ class TestHostAdmin(TestCase): def tearDownClass(cls): User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure analysts cannot view hosts list.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_host_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_omb_analyst_view(self): """Ensure OMB analysts cannot view hosts list.""" @@ -2494,6 +2502,8 @@ class TestUserDomainRoleAdmin(WebTest): cls.factory = RequestFactory() cls.admin = UserDomainRoleAdmin(model=UserDomainRole, admin_site=cls.site) cls.superuser = create_superuser() + cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() cls.test_helper = GenericTestHelper( factory=cls.factory, user=cls.superuser, @@ -2521,6 +2531,31 @@ class TestUserDomainRoleAdmin(WebTest): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure analysts cannot view user domain roles list.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_userdomainrole_changelist")) + self.assertEqual(response.status_code, 200) + + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view user domain roles list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_userdomainrole_changelist")) + self.assertEqual(response.status_code, 403) + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts cannot view/edit user domain roles list.""" + domain, _ = Domain.objects.get_or_create(name="anyrandomdomain.com") + user_domain_role, _ = UserDomainRole.objects.get_or_create( + user=self.superuser, domain=domain, role=[UserDomainRole.Roles.MANAGER] + ) + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_userdomainrole_change", args=[user_domain_role.id])) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" From 8ef58f6053e38f7facdf0cc94e817c793fc23a69 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 8 Mar 2025 07:41:38 -0500 Subject: [PATCH 28/64] TestNyUserAdmin and ContactAdmin tests --- src/registrar/tests/test_admin.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index ccb54747a..214160c14 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -2851,6 +2851,7 @@ class TestMyUserAdmin(MockDbForSharedTests, WebTest): cls.admin = MyUserAdmin(model=get_user_model(), admin_site=admin_site) cls.superuser = create_superuser() cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() cls.test_helper = GenericTestHelper(admin=cls.admin) def setUp(self): @@ -2867,6 +2868,13 @@ class TestMyUserAdmin(MockDbForSharedTests, WebTest): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view users list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_user_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" @@ -3492,6 +3500,7 @@ class TestContactAdmin(TestCase): cls.admin = ContactAdmin(model=Contact, admin_site=None) cls.superuser = create_superuser() cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() def setUp(self): super().setUp() @@ -3507,6 +3516,13 @@ class TestContactAdmin(TestCase): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view contact list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_contact_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" From 67ed777900446205e1e90ec2f7245f694c8148b2 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 8 Mar 2025 07:53:51 -0500 Subject: [PATCH 29/64] DraftDomain Website and VerifiedByStaff Admin tests --- src/registrar/tests/test_admin.py | 45 +++++++++++++++++++------------ 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 214160c14..f8358a966 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -3569,6 +3569,7 @@ class TestVerifiedByStaffAdmin(TestCase): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.omb_analyst = create_omb_analyst_user() cls.admin = VerifiedByStaffAdmin(model=VerifiedByStaff, admin_site=cls.site) cls.factory = RequestFactory() cls.test_helper = GenericTestHelper(admin=cls.admin) @@ -3586,18 +3587,20 @@ class TestVerifiedByStaffAdmin(TestCase): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view verified by staff list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_verifiedbystaff_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/verifiedbystaff/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_verifiedbystaff_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) - # Test for a description snippet self.assertContains( response, "This table contains users who have been allowed to bypass " "identity proofing through Login.gov" @@ -3652,6 +3655,7 @@ class TestWebsiteAdmin(TestCase): super().setUp() self.site = AdminSite() self.superuser = create_superuser() + self.omb_analyst = create_omb_analyst_user() self.admin = WebsiteAdmin(model=Website, admin_site=self.site) self.factory = RequestFactory() self.client = Client(HTTP_HOST="localhost:8080") @@ -3662,15 +3666,18 @@ class TestWebsiteAdmin(TestCase): Website.objects.all().delete() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view website list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_website_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/website/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_website_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) @@ -3679,13 +3686,14 @@ class TestWebsiteAdmin(TestCase): self.assertContains(response, "Show more") -class TestDraftDomain(TestCase): +class TestDraftDomainAdmin(TestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.omb_analyst = create_omb_analyst_user() cls.admin = DraftDomainAdmin(model=DraftDomain, admin_site=cls.site) cls.factory = RequestFactory() cls.test_helper = GenericTestHelper(admin=cls.admin) @@ -3703,15 +3711,18 @@ class TestDraftDomain(TestCase): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view draft domain list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_draftdomain_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/draftdomain/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_draftdomain_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) From 17b7c83b4555481949849b889a78259aed9ab4a8 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 8 Mar 2025 08:44:37 -0500 Subject: [PATCH 30/64] FederalAgency Admin tests --- src/registrar/tests/test_admin.py | 104 +++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index f8358a966..8a79ccba5 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -3733,13 +3733,21 @@ class TestDraftDomainAdmin(TestCase): self.assertContains(response, "Show more") -class TestFederalAgency(TestCase): +class TestFederalAgencyAdmin(TestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() + cls.non_feb_agency = FederalAgency.objects.create( + agency="Fake judicial agency", federal_type=BranchChoices.JUDICIAL + ) + cls.feb_agency = FederalAgency.objects.create( + agency="Fake executive agency", federal_type=BranchChoices.EXECUTIVE + ) cls.admin = FederalAgencyAdmin(model=FederalAgency, admin_site=cls.site) cls.factory = RequestFactory() cls.test_helper = GenericTestHelper(admin=cls.admin) @@ -3752,6 +3760,100 @@ class TestFederalAgency(TestCase): super().tearDownClass() User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure regular analysts can view federal agencies.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_federalagency_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.non_feb_agency.agency) + self.assertContains(response, self.feb_agency.agency) + + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts can view FEB agencies but not other branches.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_federalagency_changelist")) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.non_feb_agency.agency) + self.assertContains(response, self.feb_agency.agency) + + @less_console_noise_decorator + def test_superuser_view(self): + """Ensure superusers can view domain invitations.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_federalagency_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.non_feb_agency.agency) + self.assertContains(response, self.feb_agency.agency) + + @less_console_noise_decorator + def test_analyst_change(self): + """Ensure regular analysts can view/edit federal agencies list.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.non_feb_agency.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.feb_agency.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_agency.agency) + # test whether fields are readonly or editable + self.assertContains(response, "id_agency") + self.assertContains(response, "id_federal_type") + self.assertContains(response, "id_acronym") + self.assertContains(response, "id_is_fceb") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts can change FEB agencies but not others.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.non_feb_agency.id])) + self.assertEqual(response.status_code, 302) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.feb_agency.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_agency.agency) + # test whether fields are readonly or editable + self.assertNotContains(response, "id_agency") + self.assertNotContains(response, "id_federal_type") + self.assertNotContains(response, "id_acronym") + self.assertNotContains(response, "id_is_fceb") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure superusers can change all federal agencies.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.non_feb_agency.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_federalagency_change", args=[self.feb_agency.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_agency.agency) + # test whether fields are readonly or editable + self.assertContains(response, "id_agency") + self.assertContains(response, "id_federal_type") + self.assertContains(response, "id_acronym") + self.assertContains(response, "id_is_fceb") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_filter_feb_agencies(self): + """Ensure OMB analysts can apply filters and only federal agencies show.""" + self.client.force_login(self.omb_analyst) + # in setup, created two agencies: Fake judicial agency and Fake executive agency + # only executive agency should show up with the search for 'fake' + response = self.client.get( + reverse("admin:registrar_federalagency_changelist"), + data = {"q": "fake"}, + ) + self.assertNotContains(response, self.non_feb_agency.agency) + self.assertContains(response, self.feb_agency.agency) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" From 8aff93c2f2009a9d342ed542eb63e7430db6d719 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 8 Mar 2025 08:52:57 -0500 Subject: [PATCH 31/64] TransitionDomain UserGroup PublicContact Admin tests --- src/registrar/tests/test_admin.py | 50 +++++++++++++++++++------------ 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 8a79ccba5..ced19b5b7 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -3849,7 +3849,7 @@ class TestFederalAgencyAdmin(TestCase): # only executive agency should show up with the search for 'fake' response = self.client.get( reverse("admin:registrar_federalagency_changelist"), - data = {"q": "fake"}, + data={"q": "fake"}, ) self.assertNotContains(response, self.non_feb_agency.agency) self.assertContains(response, self.feb_agency.agency) @@ -3871,11 +3871,12 @@ class TestFederalAgencyAdmin(TestCase): self.assertContains(response, "Show more") -class TestPublicContact(TestCase): +class TestPublicContactAdmin(TestCase): def setUp(self): super().setUp() self.site = AdminSite() self.superuser = create_superuser() + self.omb_analyst = create_omb_analyst_user() self.admin = PublicContactAdmin(model=PublicContact, admin_site=self.site) self.factory = RequestFactory() self.client = Client(HTTP_HOST="localhost:8080") @@ -3886,16 +3887,19 @@ class TestPublicContact(TestCase): PublicContact.objects.all().delete() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view public contact list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_publiccontact_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" p = "adminpass" self.client.login(username="superuser", password=p) - response = self.client.get( - "/admin/registrar/publiccontact/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_publiccontact_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) @@ -3904,11 +3908,12 @@ class TestPublicContact(TestCase): self.assertContains(response, "Show more") -class TestTransitionDomain(TestCase): +class TestTransitionDomainAdmin(TestCase): def setUp(self): super().setUp() self.site = AdminSite() self.superuser = create_superuser() + self.omb_analyst = create_omb_analyst_user() self.admin = TransitionDomainAdmin(model=TransitionDomain, admin_site=self.site) self.factory = RequestFactory() self.client = Client(HTTP_HOST="localhost:8080") @@ -3919,15 +3924,18 @@ class TestTransitionDomain(TestCase): PublicContact.objects.all().delete() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view transition domain list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_transitiondomain_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/transitiondomain/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_transitiondomain_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) @@ -3936,11 +3944,12 @@ class TestTransitionDomain(TestCase): self.assertContains(response, "Show more") -class TestUserGroup(TestCase): +class TestUserGroupAdmin(TestCase): def setUp(self): super().setUp() self.site = AdminSite() self.superuser = create_superuser() + self.omb_analyst = create_omb_analyst_user() self.admin = UserGroupAdmin(model=UserGroup, admin_site=self.site) self.factory = RequestFactory() self.client = Client(HTTP_HOST="localhost:8080") @@ -3950,15 +3959,18 @@ class TestUserGroup(TestCase): super().tearDown() User.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts cannot view user group list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_usergroup_changelist")) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_has_model_description(self): """Tests if this model has a model description on the table view""" self.client.force_login(self.superuser) - response = self.client.get( - "/admin/registrar/usergroup/", - follow=True, - ) - + response = self.client.get(reverse("admin:registrar_usergroup_changelist")) # Make sure that the page is loaded correctly self.assertEqual(response.status_code, 200) From 0708d54c48fa46f83ccfdf9acccdf99dc34203c3 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 8 Mar 2025 09:52:36 -0500 Subject: [PATCH 32/64] Portfolio Admin tests --- src/registrar/tests/test_admin.py | 123 +++++++++++++++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index ced19b5b7..abf151184 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -3987,12 +3987,23 @@ class TestPortfolioAdmin(TestCase): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.staffuser = create_user() + cls.omb_analyst = create_omb_analyst_user() cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site) cls.factory = RequestFactory() def setUp(self): self.client = Client(HTTP_HOST="localhost:8080") - self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser) + self.portfolio = Portfolio.objects.create(organization_name="Test portfolio", creator=self.superuser) + self.feb_agency = FederalAgency.objects.create( + agency="Test FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) + self.feb_portfolio = Portfolio.objects.create( + organization_name="Test FEB portfolio", + creator=self.superuser, + federal_agency=self.feb_agency, + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + ) def tearDown(self): Suborganization.objects.all().delete() @@ -4000,8 +4011,118 @@ class TestPortfolioAdmin(TestCase): DomainRequest.objects.all().delete() Domain.objects.all().delete() Portfolio.objects.all().delete() + self.feb_agency.delete() User.objects.all().delete() + @less_console_noise_decorator + def test_analyst_view(self): + """Ensure regular analysts can view portfolios.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_portfolio_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.portfolio.organization_name) + self.assertContains(response, self.feb_portfolio.organization_name) + + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts can view FEB portfolios but not others.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_portfolio_changelist")) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, self.portfolio.organization_name) + self.assertContains(response, self.feb_portfolio.organization_name) + + @less_console_noise_decorator + def test_superuser_view(self): + """Ensure superusers can view portfolios.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_portfolio_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.portfolio.organization_name) + self.assertContains(response, self.feb_portfolio.organization_name) + + @less_console_noise_decorator + def test_analyst_change(self): + """Ensure regular analysts can view/edit portfolios.""" + self.client.force_login(self.staffuser) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.portfolio.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.feb_portfolio.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_portfolio.organization_name) + # test whether fields are readonly or editable + self.assertContains(response, "id_organization_name") + self.assertContains(response, "id_notes") + self.assertContains(response, "id_organization_type") + self.assertContains(response, "id_state_territory") + self.assertContains(response, "id_address_line1") + self.assertContains(response, "id_address_line2") + self.assertContains(response, "id_city") + self.assertContains(response, "id_zipcode") + self.assertContains(response, "id_urbanization") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts can change FEB portfolios but not others.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.portfolio.id])) + self.assertEqual(response.status_code, 302) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.feb_portfolio.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_portfolio.organization_name) + # test whether fields are readonly or editable + self.assertNotContains(response, "id_organization_name") + self.assertNotContains(response, "id_notes") + self.assertNotContains(response, "id_organization_type") + self.assertNotContains(response, "id_state_territory") + self.assertNotContains(response, "id_address_line1") + self.assertNotContains(response, "id_address_line2") + self.assertNotContains(response, "id_city") + self.assertNotContains(response, "id_zipcode") + self.assertNotContains(response, "id_urbanization") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertNotContains(response, "Delete") + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure superusers can change all portfolios.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.portfolio.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_portfolio_change", args=[self.feb_portfolio.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.feb_portfolio.organization_name) + # test whether fields are readonly or editable + self.assertContains(response, "id_organization_name") + self.assertContains(response, "id_notes") + self.assertContains(response, "id_organization_type") + self.assertContains(response, "id_state_territory") + self.assertContains(response, "id_address_line1") + self.assertContains(response, "id_address_line2") + self.assertContains(response, "id_city") + self.assertContains(response, "id_zipcode") + self.assertContains(response, "id_urbanization") + self.assertNotContains(response, "closelink") + self.assertContains(response, "Save") + self.assertContains(response, "Delete") + + @less_console_noise_decorator + def test_omb_analyst_filter_feb_portfolios(self): + """Ensure OMB analysts can apply filters and only feb portfolios show.""" + self.client.force_login(self.omb_analyst) + # in setup, created two portfolios: Test portfolio and Test FEB portfolio + # only executive portfolio should show up with the search for 'portfolio' + response = self.client.get( + reverse("admin:registrar_portfolio_changelist"), + data={"q": "test"}, + ) + self.assertNotContains(response, self.portfolio.organization_name) + self.assertContains(response, self.feb_portfolio.organization_name) + @less_console_noise_decorator def test_created_on_display(self): """Tests the custom created on which is a reskin of the created_at field""" From b474e0eb6b6a2ca2fcb3abd99c17af2a5574504e Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sat, 8 Mar 2025 09:57:53 -0500 Subject: [PATCH 33/64] Transfer User tests --- src/registrar/tests/test_admin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index abf151184..e89a6352c 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -4310,6 +4310,7 @@ class TestTransferUser(WebTest): super().setUpClass() cls.site = AdminSite() cls.superuser = create_superuser() + cls.omb_analyst = create_omb_analyst_user() cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site) cls.factory = RequestFactory() @@ -4330,6 +4331,13 @@ class TestTransferUser(WebTest): Portfolio.objects.all().delete() UserDomainRole.objects.all().delete() + @less_console_noise_decorator + def test_omb_analyst(self): + """Ensure OMB analysts cannot view transfer_user.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("transfer_user", args=[self.user1.pk])) + self.assertEqual(response.status_code, 403) + @less_console_noise_decorator def test_transfer_user_shows_current_and_selected_user_information(self): """Assert we pull the current user info and display it on the transfer page""" From 6702dca37dc7e8861ad5596f601738666a9ec4e5 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sun, 9 Mar 2025 10:39:49 -0400 Subject: [PATCH 34/64] wip --- src/registrar/admin.py | 18 +++++++ src/registrar/tests/test_admin_domain.py | 64 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index c72c5271a..532b0b615 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -412,6 +412,17 @@ class DomainInformationAdminForm(forms.ModelForm): class DomainInformationInlineForm(forms.ModelForm): """This form utilizes the custom widget for its class's ManyToMany UIs.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # for OMB analysts, limit portfolio dropdown to FEB portfolios + user = self.request.user if hasattr(self, 'request') else None + if user and user.groups.filter(name="omb_analysts_group").exists(): + self.fields["portfolio"].queryset = models.Portfolio.objects.filter( + Q(organization_type=DomainRequest.OrganizationChoices.FEDERAL) & + Q(federal_agency__federal_type=BranchChoices.EXECUTIVE) + ) + class Meta: model = models.DomainInformation fields = "__all__" @@ -2980,6 +2991,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): "rejection_reason_email", "action_needed_reason", "action_needed_reason_email", + "portfolio", ] autocomplete_fields = [ @@ -3753,6 +3765,12 @@ class DomainInformationInline(admin.StackedInline): form.is_omb_analyst = self.is_omb_analyst return form + + def get_formset(self, request, obj=None, **kwargs): + """Attach request to the formset so that it can be available in the form""" + formset = super().get_formset(request, obj, **kwargs) + formset.form.request = request # Attach request to form + return formset class DomainResource(FsmModelResource): diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 969d043d7..2122c576f 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -17,14 +17,17 @@ from registrar.models import ( Host, Portfolio, ) +from registrar.models.federal_agency import FederalAgency from registrar.models.public_contact import PublicContact from registrar.models.user_domain_role import UserDomainRole +from registrar.utility.constants import BranchChoices from .common import ( MockSESClient, completed_domain_request, less_console_noise, create_superuser, create_user, + create_omb_analyst_user, create_ready_domain, MockEppLib, GenericTestHelper, @@ -49,6 +52,7 @@ class TestDomainAdminAsStaff(MockEppLib): def setUpClass(self): super().setUpClass() self.staffuser = create_user() + self.omb_analyst = create_omb_analyst_user() self.site = AdminSite() self.admin = DomainAdmin(model=Domain, admin_site=self.site) self.factory = RequestFactory() @@ -56,6 +60,24 @@ class TestDomainAdminAsStaff(MockEppLib): def setUp(self): self.client = Client(HTTP_HOST="localhost:8080") self.client.force_login(self.staffuser) + self.nonfebdomain = Domain.objects.create(name="nonfebexample.com") + self.febdomain = Domain.objects.create(name="febexample.com", state=Domain.State.READY) + self.fed_agency = FederalAgency.objects.create( + agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) + self.portfolio = Portfolio.objects.create( + organization_name="new portfolio", + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.fed_agency, + creator=self.staffuser, + ) + self.domain_info = DomainInformation.objects.create( + domain=self.febdomain, portfolio=self.portfolio, creator=self.staffuser + ) + self.nonfebportfolio = Portfolio.objects.create( + organization_name="non feb portfolio", + creator=self.staffuser, + ) super().setUp() def tearDown(self): @@ -65,12 +87,54 @@ class TestDomainAdminAsStaff(MockEppLib): Domain.objects.all().delete() DomainInformation.objects.all().delete() DomainRequest.objects.all().delete() + Portfolio.objects.all().delete() + self.fed_agency.delete() @classmethod def tearDownClass(self): User.objects.all().delete() super().tearDownClass() + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts can view domain list.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domain_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.febdomain.name) + self.assertNotContains(response, self.nonfebdomain.name) + self.assertNotContains(response, "Import") + self.assertNotContains(response, "Export") + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts can view/edit federal executive branch domains.""" + self.client.force_login(self.omb_analyst) + response = self.client.get(reverse("admin:registrar_domain_change", args=[self.nonfebdomain.id])) + self.assertEqual(response.status_code, 302) + response = self.client.get(reverse("admin:registrar_domain_change", args=[self.febdomain.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.febdomain.name) + # test portfolio dropdown + self.assertContains(response, self.portfolio.organization_name) + self.assertNotContains(response, self.nonfebportfolio.organization_name) + # test buttons + self.assertNotContains(response, "Manage domain") + self.assertNotContains(response, "Get registry status") + self.assertNotContains(response, "Extend expiration date") + self.assertNotContains(response, "Remove from registry") + self.assertContains(response, "Place hold") + self.assertContains(response, "Save") + self.assertNotContains(response, ">Delete<") + # test whether fields are readonly or editable + self.assertContains(response, "id_domain_info-0-portfolio") + self.assertContains(response, "id_domain_info-0-sub_organization") + self.assertNotContains(response, "id_domain_info-0-creator") + # self.assertNotContains(response, "id_email") + # self.assertContains(response, "closelink") + # self.assertNotContains(response, "Save") + # self.assertNotContains(response, "Delete") + @less_console_noise_decorator def test_staff_can_see_cisa_region_federal(self): """Tests if staff can see CISA Region: N/A""" From bb913a937248c77c3dc39e54a683db5863b26c3e Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sun, 9 Mar 2025 19:21:09 -0400 Subject: [PATCH 35/64] fixed some fields that should have been readonly in Domains and Domain Requests --- src/registrar/admin.py | 61 +++++++++++++--- src/registrar/tests/test_admin_domain.py | 91 ++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 16 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 532b0b615..59d46eb55 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -411,17 +411,6 @@ class DomainInformationAdminForm(forms.ModelForm): class DomainInformationInlineForm(forms.ModelForm): """This form utilizes the custom widget for its class's ManyToMany UIs.""" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # for OMB analysts, limit portfolio dropdown to FEB portfolios - user = self.request.user if hasattr(self, 'request') else None - if user and user.groups.filter(name="omb_analysts_group").exists(): - self.fields["portfolio"].queryset = models.Portfolio.objects.filter( - Q(organization_type=DomainRequest.OrganizationChoices.FEDERAL) & - Q(federal_agency__federal_type=BranchChoices.EXECUTIVE) - ) class Meta: model = models.DomainInformation @@ -2343,6 +2332,47 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): "is_policy_acknowledged", ] + # Read only that we'll leverage for OMB Analysts + omb_analyst_readonly_fields = [ + "federal_agency", + "creator", + "about_your_organization", + "anything_else", + "cisa_representative_first_name", + "cisa_representative_last_name", + "cisa_representative_email", + "domain_request", + "notes", + "senior_official", + "organization_type", + "organization_name", + "state_territory", + "address_line1", + "address_line2", + "city", + "zipcode", + "urbanization", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + "organization_type", + "federal_type", + "federal_agency", + "tribe_name", + "federally_recognized_tribe", + "state_recognized_tribe", + "about_your_organization", + "portfolio", + "sub_organization", + ] + # For each filter_horizontal, init in admin js initFilterHorizontalWidget # to activate the edit/delete/view buttons filter_horizontal = ("other_contacts",) @@ -2371,6 +2401,10 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if request.user.has_perm("registrar.full_access_permission"): return readonly_fields + # Return restrictive Read-only fields for OMB analysts + if request.user.groups.filter(name="omb_analysts_group").exists(): + readonly_fields.extend([field for field in self.omb_analyst_readonly_fields]) + return readonly_fields # Return restrictive Read-only fields for analysts and # users who might not belong to groups readonly_fields.extend([field for field in self.analyst_readonly_fields]) @@ -2992,6 +3026,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): "action_needed_reason", "action_needed_reason_email", "portfolio", + "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", ] autocomplete_fields = [ @@ -3619,6 +3657,7 @@ class DomainInformationInline(admin.StackedInline): fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets)) readonly_fields = copy.deepcopy(DomainInformationAdmin.readonly_fields) analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields) + omb_analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.omb_analyst_readonly_fields) autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields) def get_domain_managers(self, obj): diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 2122c576f..6a65f5f5e 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -51,6 +51,7 @@ class TestDomainAdminAsStaff(MockEppLib): @classmethod def setUpClass(self): super().setUpClass() + self.superuser = create_superuser() self.staffuser = create_user() self.omb_analyst = create_omb_analyst_user() self.site = AdminSite() @@ -127,13 +128,93 @@ class TestDomainAdminAsStaff(MockEppLib): self.assertContains(response, "Save") self.assertNotContains(response, ">Delete<") # test whether fields are readonly or editable + self.assertNotContains(response, "id_domain_info-0-portfolio") + self.assertNotContains(response, "id_domain_info-0-sub_organization") + self.assertNotContains(response, "id_domain_info-0-creator") + self.assertNotContains(response, "id_domain_info-0-federal_agency") + self.assertNotContains(response, "id_domain_info-0-about_your_organization") + self.assertNotContains(response, "id_domain_info-0-anything_else") + self.assertNotContains(response, "id_domain_info-0-cisa_representative_first_name") + self.assertNotContains(response, "id_domain_info-0-cisa_representative_last_name") + self.assertNotContains(response, "id_domain_info-0-cisa_representative_email") + self.assertNotContains(response, "id_domain_info-0-domain_request") + self.assertNotContains(response, "id_domain_info-0-notes") + self.assertNotContains(response, "id_domain_info-0-senior_official") + self.assertNotContains(response, "id_domain_info-0-organization_type") + self.assertNotContains(response, "id_domain_info-0-state_territory") + self.assertNotContains(response, "id_domain_info-0-address_line1") + self.assertNotContains(response, "id_domain_info-0-address_line2") + self.assertNotContains(response, "id_domain_info-0-city") + self.assertNotContains(response, "id_domain_info-0-zipcode") + self.assertNotContains(response, "id_domain_info-0-urbanization") + self.assertNotContains(response, "id_domain_info-0-portfolio_organization_type") + self.assertNotContains(response, "id_domain_info-0-portfolio_federal_type") + self.assertNotContains(response, "id_domain_info-0-portfolio_organization_name") + self.assertNotContains(response, "id_domain_info-0-portfolio_federal_agency") + self.assertNotContains(response, "id_domain_info-0-portfolio_state_territory") + self.assertNotContains(response, "id_domain_info-0-portfolio_address_line1") + self.assertNotContains(response, "id_domain_info-0-portfolio_address_line2") + self.assertNotContains(response, "id_domain_info-0-portfolio_city") + self.assertNotContains(response, "id_domain_info-0-portfolio_zipcode") + self.assertNotContains(response, "id_domain_info-0-portfolio_urbanization") + self.assertNotContains(response, "id_domain_info-0-organization_type") + self.assertNotContains(response, "id_domain_info-0-federal_type") + self.assertNotContains(response, "id_domain_info-0-federal_agency") + self.assertNotContains(response, "id_domain_info-0-tribe_name") + self.assertNotContains(response, "id_domain_info-0-federally_recognized_tribe") + self.assertNotContains(response, "id_domain_info-0-state_recognized_tribe") + self.assertNotContains(response, "id_domain_info-0-about_your_organization") + self.assertNotContains(response, "id_domain_info-0-portfolio") + self.assertNotContains(response, "id_domain_info-0-sub_organization") + + @less_console_noise_decorator + def test_superuser_change(self): + """Ensure super user can view/edit all domains.""" + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:registrar_domain_change", args=[self.nonfebdomain.id])) + self.assertEqual(response.status_code, 200) + response = self.client.get(reverse("admin:registrar_domain_change", args=[self.febdomain.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.febdomain.name) + # test portfolio dropdown + self.assertContains(response, self.portfolio.organization_name) + # test buttons + self.assertContains(response, "Manage domain") + self.assertContains(response, "Get registry status") + self.assertContains(response, "Extend expiration date") + self.assertContains(response, "Remove from registry") + self.assertContains(response, "Place hold") + self.assertContains(response, "Save") + self.assertContains(response, ">Delete<") + # test whether fields are readonly or editable + self.assertContains(response, "id_domain_info-0-portfolio") + self.assertContains(response, "id_domain_info-0-sub_organization") + self.assertContains(response, "id_domain_info-0-creator") + self.assertContains(response, "id_domain_info-0-federal_agency") + self.assertContains(response, "id_domain_info-0-about_your_organization") + self.assertContains(response, "id_domain_info-0-anything_else") + self.assertContains(response, "id_domain_info-0-cisa_representative_first_name") + self.assertContains(response, "id_domain_info-0-cisa_representative_last_name") + self.assertContains(response, "id_domain_info-0-cisa_representative_email") + self.assertContains(response, "id_domain_info-0-domain_request") + self.assertContains(response, "id_domain_info-0-notes") + self.assertContains(response, "id_domain_info-0-senior_official") + self.assertContains(response, "id_domain_info-0-organization_type") + self.assertContains(response, "id_domain_info-0-state_territory") + self.assertContains(response, "id_domain_info-0-address_line1") + self.assertContains(response, "id_domain_info-0-address_line2") + self.assertContains(response, "id_domain_info-0-city") + self.assertContains(response, "id_domain_info-0-zipcode") + self.assertContains(response, "id_domain_info-0-urbanization") + self.assertContains(response, "id_domain_info-0-organization_type") + self.assertContains(response, "id_domain_info-0-federal_type") + self.assertContains(response, "id_domain_info-0-federal_agency") + self.assertContains(response, "id_domain_info-0-tribe_name") + self.assertContains(response, "id_domain_info-0-federally_recognized_tribe") + self.assertContains(response, "id_domain_info-0-state_recognized_tribe") + self.assertContains(response, "id_domain_info-0-about_your_organization") self.assertContains(response, "id_domain_info-0-portfolio") self.assertContains(response, "id_domain_info-0-sub_organization") - self.assertNotContains(response, "id_domain_info-0-creator") - # self.assertNotContains(response, "id_email") - # self.assertContains(response, "closelink") - # self.assertNotContains(response, "Save") - # self.assertNotContains(response, "Delete") @less_console_noise_decorator def test_staff_can_see_cisa_region_federal(self): From 8aeeb3c9e35c8d98e6029caf18908ffb832433f7 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Sun, 9 Mar 2025 19:27:43 -0400 Subject: [PATCH 36/64] lint --- src/registrar/admin.py | 4 ++-- src/registrar/tests/test_admin_domain.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 59d46eb55..a5ab53071 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -411,7 +411,7 @@ class DomainInformationAdminForm(forms.ModelForm): class DomainInformationInlineForm(forms.ModelForm): """This form utilizes the custom widget for its class's ManyToMany UIs.""" - + class Meta: model = models.DomainInformation fields = "__all__" @@ -3804,7 +3804,7 @@ class DomainInformationInline(admin.StackedInline): form.is_omb_analyst = self.is_omb_analyst return form - + def get_formset(self, request, obj=None, **kwargs): """Attach request to the formset so that it can be available in the form""" formset = super().get_formset(request, obj, **kwargs) diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 6a65f5f5e..ab32f7c2a 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -142,9 +142,9 @@ class TestDomainAdminAsStaff(MockEppLib): self.assertNotContains(response, "id_domain_info-0-senior_official") self.assertNotContains(response, "id_domain_info-0-organization_type") self.assertNotContains(response, "id_domain_info-0-state_territory") - self.assertNotContains(response, "id_domain_info-0-address_line1") + self.assertNotContains(response, "id_domain_info-0-address_line1") self.assertNotContains(response, "id_domain_info-0-address_line2") - self.assertNotContains(response, "id_domain_info-0-city") + self.assertNotContains(response, "id_domain_info-0-city") self.assertNotContains(response, "id_domain_info-0-zipcode") self.assertNotContains(response, "id_domain_info-0-urbanization") self.assertNotContains(response, "id_domain_info-0-portfolio_organization_type") @@ -166,7 +166,7 @@ class TestDomainAdminAsStaff(MockEppLib): self.assertNotContains(response, "id_domain_info-0-about_your_organization") self.assertNotContains(response, "id_domain_info-0-portfolio") self.assertNotContains(response, "id_domain_info-0-sub_organization") - + @less_console_noise_decorator def test_superuser_change(self): """Ensure super user can view/edit all domains.""" @@ -201,9 +201,9 @@ class TestDomainAdminAsStaff(MockEppLib): self.assertContains(response, "id_domain_info-0-senior_official") self.assertContains(response, "id_domain_info-0-organization_type") self.assertContains(response, "id_domain_info-0-state_territory") - self.assertContains(response, "id_domain_info-0-address_line1") + self.assertContains(response, "id_domain_info-0-address_line1") self.assertContains(response, "id_domain_info-0-address_line2") - self.assertContains(response, "id_domain_info-0-city") + self.assertContains(response, "id_domain_info-0-city") self.assertContains(response, "id_domain_info-0-zipcode") self.assertContains(response, "id_domain_info-0-urbanization") self.assertContains(response, "id_domain_info-0-organization_type") @@ -215,7 +215,7 @@ class TestDomainAdminAsStaff(MockEppLib): self.assertContains(response, "id_domain_info-0-about_your_organization") self.assertContains(response, "id_domain_info-0-portfolio") self.assertContains(response, "id_domain_info-0-sub_organization") - + @less_console_noise_decorator def test_staff_can_see_cisa_region_federal(self): """Tests if staff can see CISA Region: N/A""" From 64de8302545dc6dbb2ea6ccab4384cb0d26e8e28 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 10 Mar 2025 06:02:15 -0400 Subject: [PATCH 37/64] update tests for admin domain requests --- src/registrar/tests/test_admin_request.py | 156 ++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 968de0d65..e1e6f45d3 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1,6 +1,8 @@ from datetime import datetime from django.forms import ValidationError from django.utils import timezone +from registrar.models.federal_agency import FederalAgency +from registrar.utility.constants import BranchChoices from waffle.testutils import override_flag import re from django.test import RequestFactory, Client, TestCase, override_settings @@ -37,6 +39,7 @@ from .common import ( less_console_noise, create_superuser, create_user, + create_omb_analyst_user, multiple_unalphabetical_domain_objects, MockEppLib, GenericTestHelper, @@ -68,6 +71,7 @@ class TestDomainRequestAdmin(MockEppLib): self.admin = DomainRequestAdmin(model=DomainRequest, admin_site=self.site) self.superuser = create_superuser() self.staffuser = create_user() + self.ombanalyst = create_omb_analyst_user() self.client = Client(HTTP_HOST="localhost:8080") self.test_helper = GenericTestHelper( factory=self.factory, @@ -80,6 +84,12 @@ class TestDomainRequestAdmin(MockEppLib): allowed_emails = [AllowedEmail(email="mayor@igorville.gov"), AllowedEmail(email="help@get.gov")] AllowedEmail.objects.bulk_create(allowed_emails) + def setUp(self): + super().setUp() + self.fed_agency = FederalAgency.objects.create( + agency="New FedExec Agency", federal_type=BranchChoices.EXECUTIVE + ) + def tearDown(self): super().tearDown() Host.objects.all().delete() @@ -92,6 +102,7 @@ class TestDomainRequestAdmin(MockEppLib): SeniorOfficial.objects.all().delete() Suborganization.objects.all().delete() Portfolio.objects.all().delete() + self.fed_agency.delete() self.mock_client.EMAILS_SENT.clear() @classmethod @@ -100,6 +111,71 @@ class TestDomainRequestAdmin(MockEppLib): User.objects.all().delete() AllowedEmail.objects.all().delete() + @override_flag("organization_feature", active=True) + @less_console_noise_decorator + def test_omb_analyst_view(self): + """Ensure OMB analysts can view domain request list.""" + febportfolio = Portfolio.objects.create( + organization_name="new portfolio", + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.fed_agency, + creator=self.ombanalyst, + ) + nonfebportfolio = Portfolio.objects.create( + organization_name="non feb portfolio", + creator=self.ombanalyst, + ) + nonfebdomainrequest = completed_domain_request( + name="test1234nonfeb.gov", + portfolio=nonfebportfolio, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + ) + febdomainrequest = completed_domain_request( + name="test1234feb.gov", + portfolio=febportfolio, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + ) + self.client.force_login(self.ombanalyst) + response = self.client.get(reverse("admin:registrar_domainrequest_changelist")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, febdomainrequest.requested_domain.name) + self.assertNotContains(response, nonfebdomainrequest.requested_domain.name) + self.assertNotContains(response, ">Import<") + self.assertNotContains(response, ">Export<") + + @less_console_noise_decorator + def test_omb_analyst_change(self): + """Ensure OMB analysts can view/edit federal executive branch domain requests.""" + self.client.force_login(self.ombanalyst) + febportfolio = Portfolio.objects.create( + organization_name="new portfolio", + organization_type=DomainRequest.OrganizationChoices.FEDERAL, + federal_agency=self.fed_agency, + creator=self.ombanalyst, + ) + nonfebportfolio = Portfolio.objects.create( + organization_name="non feb portfolio", + creator=self.ombanalyst, + ) + nonfebdomainrequest = completed_domain_request( + name="test1234nonfeb.gov", + portfolio=nonfebportfolio, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + ) + febdomainrequest = completed_domain_request( + name="test1234feb.gov", + portfolio=febportfolio, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + ) + response = self.client.get(reverse("admin:registrar_domainrequest_change", args=[nonfebdomainrequest.id])) + self.assertEqual(response.status_code, 302) + response = self.client.get(reverse("admin:registrar_domainrequest_change", args=[febdomainrequest.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, febdomainrequest.requested_domain.name) + # test buttons + self.assertContains(response, "Save") + self.assertNotContains(response, ">Delete<") + @override_flag("organization_feature", active=True) @less_console_noise_decorator def test_clean_validates_duplicate_suborganization(self): @@ -2065,6 +2141,86 @@ class TestDomainRequestAdmin(MockEppLib): self.assertEqual(readonly_fields, expected_fields) + def test_readonly_fields_for_omb_analyst(self): + with less_console_noise(): + request = self.factory.get("/") # Use the correct method and path + request.user = self.ombanalyst + + readonly_fields = self.admin.get_readonly_fields(request) + + expected_fields = [ + "portfolio_senior_official", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + "other_contacts", + "current_websites", + "alternative_domains", + "is_election_board", + "status_history", + "federal_agency", + "creator", + "about_your_organization", + "requested_domain", + "approved_domain", + "alternative_domains", + "purpose", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + "cisa_representative_first_name", + "cisa_representative_last_name", + "cisa_representative_email", + "status", + "investigator", + "notes", + "senior_official", + "organization_type", + "organization_name", + "state_territory", + "address_line1", + "address_line2", + "city", + "zipcode", + "urbanization", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + "is_election_board", + "organization_type", + "federal_type", + "federal_agency", + "tribe_name", + "federally_recognized_tribe", + "state_recognized_tribe", + "about_your_organization", + "rejection_reason", + "rejection_reason_email", + "action_needed_reason", + "action_needed_reason_email", + "portfolio", + "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", + ] + + self.assertEqual(readonly_fields, expected_fields) + def test_saving_when_restricted_creator(self): with less_console_noise(): # Create an instance of the model From d6746af538dbbd3c2bbba66688929223b16c6cd9 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 10 Mar 2025 06:09:15 -0400 Subject: [PATCH 38/64] fix test on Import --- src/registrar/tests/test_admin_domain.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index ab32f7c2a..aa6e799bd 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -104,8 +104,8 @@ class TestDomainAdminAsStaff(MockEppLib): self.assertEqual(response.status_code, 200) self.assertContains(response, self.febdomain.name) self.assertNotContains(response, self.nonfebdomain.name) - self.assertNotContains(response, "Import") - self.assertNotContains(response, "Export") + self.assertNotContains(response, ">Import<") + self.assertNotContains(response, ">Export<") @less_console_noise_decorator def test_omb_analyst_change(self): From 15c41cdce4ef35a8de0263d06e99c508647ef376 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 10 Mar 2025 06:18:55 -0400 Subject: [PATCH 39/64] removed federal agency delete for OMB analysts --- src/registrar/models/user_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index 781dbb64c..f2ffa50ed 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -171,7 +171,7 @@ class UserGroup(Group): { "app_label": "registrar", "model": "federalagency", - "permissions": ["change_federalagency", "delete_federalagency"], + "permissions": ["change_federalagency"], }, { "app_label": "registrar", From b5ab09db823a2aecea804f8b3485a6e57a886978 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 10 Mar 2025 06:24:10 -0400 Subject: [PATCH 40/64] removed federal agency delete for OMB analysts --- src/registrar/admin.py | 9 --------- src/registrar/tests/test_admin.py | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index a5ab53071..9bb9efe69 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -5025,15 +5025,6 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return obj.federal_type == BranchChoices.EXECUTIVE return super().has_change_permission(request, obj) - def has_delete_permission(self, request, obj=None): - """Restrict delete permissions based on group membership and model attributes.""" - if request.user.has_perm("registrar.full_access_permission"): - return True - if obj: - if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.federal_type == BranchChoices.EXECUTIVE - return super().has_delete_permission(request, obj) - def get_readonly_fields(self, request, obj=None): """Set the read-only state on form elements. We have 2 conditions that determine which fields are read-only: diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index e89a6352c..7e34c63c7 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -3821,7 +3821,7 @@ class TestFederalAgencyAdmin(TestCase): self.assertNotContains(response, "id_is_fceb") self.assertNotContains(response, "closelink") self.assertContains(response, "Save") - self.assertContains(response, "Delete") + self.assertNotContains(response, "Delete") @less_console_noise_decorator def test_superuser_change(self): From 55a101b7f6a99602d49aa004977f6e33a183d03c Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Mon, 10 Mar 2025 12:53:23 -0700 Subject: [PATCH 41/64] Add files with print statements --- src/registrar/forms/domain_request_wizard.py | 6 +++- src/registrar/models/domain.py | 17 ++++----- src/registrar/views/domain_request.py | 37 ++++++++++++++++++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index d7a02b124..0ca74dacc 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -83,14 +83,16 @@ class RequestingEntityForm(RegistrarForm): Overrides RegistrarForm method in order to set sub_organization to 'other' on GETs of the RequestingEntityForm.""" if obj is None: + print("!!!! FROM_DATABASE receive a NONE object") return {} # get the domain request as a dict, per usual method domain_request_dict = {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore - + print(f"**** FROM_DATABASE BEFORE modification: {domain_request_dict}") # set sub_organization to 'other' if is_requesting_new_suborganization is True if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization(): domain_request_dict["sub_organization"] = "other" + print(f"***** FROM_DATABASE: AFTER modification: {domain_request_dict}") return domain_request_dict def clean_sub_organization(self): @@ -163,6 +165,7 @@ class RequestingEntityForm(RegistrarForm): def clean(self): """Custom clean implementation to handle our desired logic flow for suborganization.""" cleaned_data = super().clean() + print(f"**** CLEAN: data before: {cleaned_data}") # Get the cleaned data suborganization = cleaned_data.get("sub_organization") @@ -190,6 +193,7 @@ class RequestingEntityForm(RegistrarForm): elif not self.data and getattr(self, "_original_suborganization", None) == "other": self.cleaned_data["sub_organization"] = self._original_suborganization + print(f"**** CLEAN: clean data after: {cleaned_data}") return cleaned_data diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index d3c0ed347..cb241db52 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -245,15 +245,16 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" - if not cls.string_could_be_domain(domain): - logger.warning("Not a valid domain: %s" % str(domain)) - # throw invalid domain error so that it can be caught in - # validate_and_handle_errors in domain_helper - raise errors.InvalidDomainError() + return True + # if not cls.string_could_be_domain(domain): + # logger.warning("Not a valid domain: %s" % str(domain)) + # # throw invalid domain error so that it can be caught in + # # validate_and_handle_errors in domain_helper + # raise errors.InvalidDomainError() - domain_name = domain.lower() - req = commands.CheckDomain([domain_name]) - return registry.send(req, cleaned=True).res_data[0].avail + # domain_name = domain.lower() + # req = commands.CheckDomain([domain_name]) + # return registry.send(req, cleaned=True).res_data[0].avail @classmethod def registered(cls, domain: str) -> bool: diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 1d17e3047..dda309fa8 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -21,6 +21,7 @@ from registrar.models.contact import Contact from registrar.models.user import User from registrar.views.utility import StepsHelper from registrar.utility.enums import Step, PortfolioDomainRequestStep +from registrar.models.utility.generic_helper import get_url_name logger = logging.getLogger(__name__) @@ -205,30 +206,39 @@ class DomainRequestWizard(TemplateView): else: raise ValueError("Invalid value for User") + print("****** LINE ABOVE ******") if self.has_pk(): try: self._domain_request = DomainRequest.objects.get( creator=creator, pk=self.kwargs.get("domain_request_pk"), ) + print(f"@@@@ Retrieved existing DomainRequest: {self._domain_request}") return self._domain_request except DomainRequest.DoesNotExist: logger.debug("DomainRequest id %s did not have a DomainRequest" % self.kwargs.get("domain_request_pk")) + print("****** LINE BELOW ******") # If a user is creating a request, we assume that perms are handled upstream if self.request.user.is_org_user(self.request): portfolio = self.request.session.get("portfolio") + print(f"@@@@ User is an org user. Portfolio retrieved: {portfolio}") self._domain_request = DomainRequest.objects.create( creator=self.request.user, portfolio=portfolio, ) - + print(f"@@@@ New DomainRequest created: {self._domain_request}") # Question for reviewers: we should probably be doing this right? if portfolio and not self._domain_request.generic_org_type: + print(f"@@@@ Set generic_org_type to {portfolio.organization_type}") self._domain_request.generic_org_type = portfolio.organization_type self._domain_request.save() + print(f"@@@@ Updated DomainRequest: {self._domain_request}") else: + # Should not see this statement wanyway bc we are creating w portfolio + print("XXXX User is not an org user - create domain request w/o portfolio") self._domain_request = DomainRequest.objects.create(creator=self.request.user) + print(f"XXXX New DomainRequest created for non-org user: {self._domain_request}") return self._domain_request @property @@ -255,8 +265,11 @@ class DomainRequestWizard(TemplateView): def done(self): """Called when the user clicks the submit button, if all forms are valid.""" + print("***** DONE: Submitting") self.domain_request.submit() # change the status to submitted + print("***** DONE: Saving") self.domain_request.save() + print(f"***** DONE Finished saving, domain request is {self.domain_request}") logger.debug("Domain Request object saved: %s", self.domain_request.id) return redirect(reverse(f"{self.URL_NAMESPACE}:finished")) @@ -439,6 +452,14 @@ class DomainRequestWizard(TemplateView): def get_context_data(self): """Define context for access on all wizard pages.""" + print("in get_context_data") + # current_url = self.request.get_full_path() + # print("current_url is", current_url) + # print("reverse", reverse(f"{self.URL_NAMESPACE}:finished")) + # if current_url == reverse(f"{self.URL_NAMESPACE}:finished"): + # return None + + # print("past the check") requested_domain_name = None if self.domain_request.requested_domain is not None: requested_domain_name = self.domain_request.requested_domain.name @@ -511,9 +532,11 @@ class DomainRequestWizard(TemplateView): forms = self.get_forms(use_post=True) if self.is_valid(forms): + print("YYYYY should come into here bc it's valid") # always save progress self.save(forms) else: + print("XXXXXX should not come into here to call get context data again bc it's valid") context = self.get_context_data() context["forms"] = forms return render(request, self.template_name, context) @@ -593,8 +616,18 @@ class RequestingEntity(DomainRequestWizard): "suborganization_state_territory": None, } ) - + print("!!!!! DomainRequest Instance:", self.domain_request) + print("!!!!! Cleaned Data for RequestingEntityForm:", requesting_entity_form.cleaned_data) + print("!!!!! Suborganization Data Before Submit: Requested:", cleaned_data.get("requested_suborganization")) + print("!!!!! City:", cleaned_data.get("suborganization_city")) + print("!!!!! State/Territory:", cleaned_data.get("suborganization_state_territory")) + print("$$$$$ DomainRequest before form save:", self.domain_request) + print("$$$$$ forms is", forms) + # So maybe bc it's saving 2 forms, one actually gets saved and the other becomes a draft? + # super().save(forms[0]) super().save(forms) + print("$$$$$ After super().save() called, domain request instance:", self.domain_request) + # super().save(forms) class PortfolioAdditionalDetails(DomainRequestWizard): From a233420af9e0c2678a52e7fbec75e65dfd50ccd4 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Mon, 10 Mar 2025 17:19:56 -0700 Subject: [PATCH 42/64] Add in documentation and updated code --- docs/developer/README.md | 12 +++++++ src/registrar/forms/domain_request_wizard.py | 5 --- src/registrar/models/domain.py | 18 +++++----- src/registrar/views/domain_request.py | 37 +------------------- 4 files changed, 22 insertions(+), 50 deletions(-) diff --git a/docs/developer/README.md b/docs/developer/README.md index 46194bd70..e89a7ad0e 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -207,6 +207,18 @@ Linters: docker-compose exec app ./manage.py lint ``` +### Get availability for domain requests to work locally + +If you're on local (localhost:8080) and want to submit a domain request, and keep getting the "We’re experiencing a system error. Please wait a few minutes and try again. If you continue to get this error, contact help@get.gov." error, you can get past the availability check by updating the available() function in registrar/models/domain.py to return True and comment everything else out - see below for reference! + +``` +@classmethod +def available(cls, domain: str) -> bool: + # Comment everything else out in the function + return True +``` + + ### Testing behind logged in pages To test behind logged in pages with external tools, like `pa11y-ci` or `OWASP Zap`, add diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 0ca74dacc..ba72078b0 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -83,16 +83,13 @@ class RequestingEntityForm(RegistrarForm): Overrides RegistrarForm method in order to set sub_organization to 'other' on GETs of the RequestingEntityForm.""" if obj is None: - print("!!!! FROM_DATABASE receive a NONE object") return {} # get the domain request as a dict, per usual method domain_request_dict = {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore - print(f"**** FROM_DATABASE BEFORE modification: {domain_request_dict}") # set sub_organization to 'other' if is_requesting_new_suborganization is True if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization(): domain_request_dict["sub_organization"] = "other" - print(f"***** FROM_DATABASE: AFTER modification: {domain_request_dict}") return domain_request_dict def clean_sub_organization(self): @@ -165,7 +162,6 @@ class RequestingEntityForm(RegistrarForm): def clean(self): """Custom clean implementation to handle our desired logic flow for suborganization.""" cleaned_data = super().clean() - print(f"**** CLEAN: data before: {cleaned_data}") # Get the cleaned data suborganization = cleaned_data.get("sub_organization") @@ -193,7 +189,6 @@ class RequestingEntityForm(RegistrarForm): elif not self.data and getattr(self, "_original_suborganization", None) == "other": self.cleaned_data["sub_organization"] = self._original_suborganization - print(f"**** CLEAN: clean data after: {cleaned_data}") return cleaned_data diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index cb241db52..01ed246ac 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -245,16 +245,16 @@ class Domain(TimeStampedModel, DomainHelper): is called in the validate function on the request/domain page throws- RegistryError or InvalidDomainError""" - return True - # if not cls.string_could_be_domain(domain): - # logger.warning("Not a valid domain: %s" % str(domain)) - # # throw invalid domain error so that it can be caught in - # # validate_and_handle_errors in domain_helper - # raise errors.InvalidDomainError() - # domain_name = domain.lower() - # req = commands.CheckDomain([domain_name]) - # return registry.send(req, cleaned=True).res_data[0].avail + if not cls.string_could_be_domain(domain): + logger.warning("Not a valid domain: %s" % str(domain)) + # throw invalid domain error so that it can be caught in + # validate_and_handle_errors in domain_helper + raise errors.InvalidDomainError() + + domain_name = domain.lower() + req = commands.CheckDomain([domain_name]) + return registry.send(req, cleaned=True).res_data[0].avail @classmethod def registered(cls, domain: str) -> bool: diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index dda309fa8..77457beea 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -206,39 +206,30 @@ class DomainRequestWizard(TemplateView): else: raise ValueError("Invalid value for User") - print("****** LINE ABOVE ******") if self.has_pk(): try: self._domain_request = DomainRequest.objects.get( creator=creator, pk=self.kwargs.get("domain_request_pk"), ) - print(f"@@@@ Retrieved existing DomainRequest: {self._domain_request}") return self._domain_request except DomainRequest.DoesNotExist: logger.debug("DomainRequest id %s did not have a DomainRequest" % self.kwargs.get("domain_request_pk")) - print("****** LINE BELOW ******") # If a user is creating a request, we assume that perms are handled upstream if self.request.user.is_org_user(self.request): portfolio = self.request.session.get("portfolio") - print(f"@@@@ User is an org user. Portfolio retrieved: {portfolio}") self._domain_request = DomainRequest.objects.create( creator=self.request.user, portfolio=portfolio, ) - print(f"@@@@ New DomainRequest created: {self._domain_request}") # Question for reviewers: we should probably be doing this right? if portfolio and not self._domain_request.generic_org_type: - print(f"@@@@ Set generic_org_type to {portfolio.organization_type}") self._domain_request.generic_org_type = portfolio.organization_type self._domain_request.save() - print(f"@@@@ Updated DomainRequest: {self._domain_request}") else: # Should not see this statement wanyway bc we are creating w portfolio - print("XXXX User is not an org user - create domain request w/o portfolio") self._domain_request = DomainRequest.objects.create(creator=self.request.user) - print(f"XXXX New DomainRequest created for non-org user: {self._domain_request}") return self._domain_request @property @@ -265,11 +256,8 @@ class DomainRequestWizard(TemplateView): def done(self): """Called when the user clicks the submit button, if all forms are valid.""" - print("***** DONE: Submitting") self.domain_request.submit() # change the status to submitted - print("***** DONE: Saving") self.domain_request.save() - print(f"***** DONE Finished saving, domain request is {self.domain_request}") logger.debug("Domain Request object saved: %s", self.domain_request.id) return redirect(reverse(f"{self.URL_NAMESPACE}:finished")) @@ -452,14 +440,6 @@ class DomainRequestWizard(TemplateView): def get_context_data(self): """Define context for access on all wizard pages.""" - print("in get_context_data") - # current_url = self.request.get_full_path() - # print("current_url is", current_url) - # print("reverse", reverse(f"{self.URL_NAMESPACE}:finished")) - # if current_url == reverse(f"{self.URL_NAMESPACE}:finished"): - # return None - - # print("past the check") requested_domain_name = None if self.domain_request.requested_domain is not None: requested_domain_name = self.domain_request.requested_domain.name @@ -532,11 +512,9 @@ class DomainRequestWizard(TemplateView): forms = self.get_forms(use_post=True) if self.is_valid(forms): - print("YYYYY should come into here bc it's valid") # always save progress self.save(forms) else: - print("XXXXXX should not come into here to call get context data again bc it's valid") context = self.get_context_data() context["forms"] = forms return render(request, self.template_name, context) @@ -616,18 +594,7 @@ class RequestingEntity(DomainRequestWizard): "suborganization_state_territory": None, } ) - print("!!!!! DomainRequest Instance:", self.domain_request) - print("!!!!! Cleaned Data for RequestingEntityForm:", requesting_entity_form.cleaned_data) - print("!!!!! Suborganization Data Before Submit: Requested:", cleaned_data.get("requested_suborganization")) - print("!!!!! City:", cleaned_data.get("suborganization_city")) - print("!!!!! State/Territory:", cleaned_data.get("suborganization_state_territory")) - print("$$$$$ DomainRequest before form save:", self.domain_request) - print("$$$$$ forms is", forms) - # So maybe bc it's saving 2 forms, one actually gets saved and the other becomes a draft? - # super().save(forms[0]) super().save(forms) - print("$$$$$ After super().save() called, domain request instance:", self.domain_request) - # super().save(forms) class PortfolioAdditionalDetails(DomainRequestWizard): @@ -849,11 +816,9 @@ class Finished(DomainRequestWizard): forms = [] # type: ignore def get(self, request, *args, **kwargs): - context = self.get_context_data() - context["domain_request_id"] = self.domain_request.id # clean up this wizard session, because we are done with it del self.storage - return render(self.request, self.template_name, context) + return render(self.request, self.template_name) @grant_access(IS_DOMAIN_REQUEST_CREATOR, HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT) From d5c5007b349d1776114fb0cf18d9b0f48426c099 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 11 Mar 2025 11:41:04 -0700 Subject: [PATCH 43/64] Remove comment --- docs/developer/README.md | 1 - src/registrar/views/domain_request.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/docs/developer/README.md b/docs/developer/README.md index e89a7ad0e..0629bb124 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -218,7 +218,6 @@ def available(cls, domain: str) -> bool: return True ``` - ### Testing behind logged in pages To test behind logged in pages with external tools, like `pa11y-ci` or `OWASP Zap`, add diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 77457beea..6780f48ef 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -21,7 +21,6 @@ from registrar.models.contact import Contact from registrar.models.user import User from registrar.views.utility import StepsHelper from registrar.utility.enums import Step, PortfolioDomainRequestStep -from registrar.models.utility.generic_helper import get_url_name logger = logging.getLogger(__name__) @@ -228,7 +227,6 @@ class DomainRequestWizard(TemplateView): self._domain_request.generic_org_type = portfolio.organization_type self._domain_request.save() else: - # Should not see this statement wanyway bc we are creating w portfolio self._domain_request = DomainRequest.objects.create(creator=self.request.user) return self._domain_request From 0bc6595a17321739e5748d6e828a904ff8b1b216 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 11 Mar 2025 15:16:48 -0700 Subject: [PATCH 44/64] Fix the manage url issue for action needed emails --- .../templates/emails/transition_domain_invitation.txt | 2 +- src/registrar/utility/admin_helpers.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/emails/transition_domain_invitation.txt b/src/registrar/templates/emails/transition_domain_invitation.txt index 14dd626dd..35947eb72 100644 --- a/src/registrar/templates/emails/transition_domain_invitation.txt +++ b/src/registrar/templates/emails/transition_domain_invitation.txt @@ -57,7 +57,7 @@ THANK YOU The .gov team .Gov blog -Domain management <{{ manage_url }}}> +Domain management <{{ manage_url }}> Get.gov The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 93a0a16b5..adbc182d0 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -1,4 +1,5 @@ from registrar.models.domain_request import DomainRequest +from django.conf import settings from django.template.loader import get_template from django.utils.html import format_html from django.urls import reverse @@ -35,8 +36,13 @@ def _get_default_email(domain_request, file_path, reason, excluded_reasons=None) return None recipient = domain_request.creator + env_base_url = settings.BASE_URL + # If NOT in prod, update instances of "manage.get.gov" links to point to + # current environment, ie "getgov-rh.app.cloud.gov" + manage_url = env_base_url if not settings.IS_PRODUCTION else "https://manage.get.gov" + # Return the context of the rendered views - context = {"domain_request": domain_request, "recipient": recipient, "reason": reason} + context = {"domain_request": domain_request, "recipient": recipient, "reason": reason, "manage_url": manage_url} email_body_text = get_template(file_path).render(context=context) email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None From 585569a006420b713f416c88a5402645b38ca514 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 12 Mar 2025 10:29:38 -0600 Subject: [PATCH 45/64] bug fix --- src/registrar/forms/portfolio.py | 10 +++- .../models/utility/portfolio_helper.py | 60 +++++++++++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index b83e718cb..2ee174050 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -22,6 +22,7 @@ from registrar.models.utility.portfolio_helper import ( get_domains_display, get_members_description_display, get_members_display, + get_portfolio_invitation_associations, ) logger = logging.getLogger(__name__) @@ -459,7 +460,14 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): if hasattr(e, "code"): field = "email" if "email" in self.fields else None if e.code == "has_existing_permissions": - self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") + existing_permissions, existing_invitations = ( + get_portfolio_invitation_associations(self.instance) + ) + + same_portfolio_for_permissions = existing_permissions.exclude(portfolio=self.instance.portfolio) + same_portfolio_for_invitations = existing_invitations.exclude(portfolio=self.instance.portfolio) + if same_portfolio_for_permissions.exists() or same_portfolio_for_invitations.exists(): + self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") override_error = True elif e.code == "has_existing_invitations": self.add_error( diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 009ea3c26..707dfcf54 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -286,8 +286,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): # == Validate the multiple_porfolios flag. == # if not flag_is_active_for_user(user_portfolio_permission.user, "multiple_portfolios"): - existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( - user=user_portfolio_permission.user + existing_permissions, existing_invitations = ( + get_user_portfolio_permission_associations(user_portfolio_permission) ) if existing_permissions.exists(): raise ValidationError( @@ -296,10 +296,6 @@ def validate_user_portfolio_permission(user_portfolio_permission): code="has_existing_permissions", ) - existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude( - Q(portfolio=user_portfolio_permission.portfolio) - | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) - ) if existing_invitations.exists(): raise ValidationError( "This user is already assigned to a portfolio invitation. " @@ -307,6 +303,29 @@ def validate_user_portfolio_permission(user_portfolio_permission): code="has_existing_invitations", ) +def get_user_portfolio_permission_associations(user_portfolio_permission): + """ + Retrieves the associations for a user portfolio invitation. + + Returns: + A tuple: + (existing_permissions, existing_invitations) + where: + - existing_permissions: UserPortfolioPermission objects excluding the current permission. + - existing_invitations: PortfolioInvitation objects for the user email excluding + the current invitation and those with status RETRIEVED. + """ + PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( + user=user_portfolio_permission.user + ) + existing_invitations = PortfolioInvitation.objects.filter(email__iexact=user_portfolio_permission.user.email).exclude( + Q(portfolio=user_portfolio_permission.portfolio) + | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + ) + return (existing_permissions, existing_invitations) + def validate_portfolio_invitation(portfolio_invitation): """ @@ -351,17 +370,14 @@ def validate_portfolio_invitation(portfolio_invitation): ) # == Validate the multiple_porfolios flag. == # - user = User.objects.filter(email=portfolio_invitation.email).first() + user = User.objects.filter(email__iexact=portfolio_invitation.email).first() # If user returns None, then we check for global assignment of multiple_portfolios. # Otherwise we just check on the user. if not flag_is_active_for_user(user, "multiple_portfolios"): - existing_permissions = UserPortfolioPermission.objects.filter(user=user) - - existing_invitations = PortfolioInvitation.objects.filter(email=portfolio_invitation.email).exclude( - Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + existing_permissions, existing_invitations = ( + get_portfolio_invitation_associations(portfolio_invitation) ) - if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " @@ -376,6 +392,26 @@ def validate_portfolio_invitation(portfolio_invitation): code="has_existing_invitations", ) +def get_portfolio_invitation_associations(portfolio_invitation): + """ + Retrieves the associations for a portfolio invitation. + + Returns: + A tuple: + (existing_permissions, existing_invitations) + where: + - existing_permissions: UserPortfolioPermission objects matching the email. + - existing_invitations: PortfolioInvitation objects for the email excluding + the current invitation and those with status RETRIEVED. + """ + PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + existing_permissions = UserPortfolioPermission.objects.filter(user__email__iexact=portfolio_invitation.email) + existing_invitations = PortfolioInvitation.objects.filter(email__iexact=portfolio_invitation.email).exclude( + Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + ) + return (existing_permissions, existing_invitations) + def cleanup_after_portfolio_member_deletion(portfolio, email, user=None): """ From beb4e722b377c3297ad5f0d142e4a98597dac07d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:44:57 -0600 Subject: [PATCH 46/64] Fix unrelated test failure --- src/registrar/forms/portfolio.py | 8 ++++---- .../models/utility/portfolio_helper.py | 18 ++++++++++-------- src/registrar/tests/test_views_portfolio.py | 8 +++++--- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 2ee174050..db1f58d88 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -460,14 +460,14 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): if hasattr(e, "code"): field = "email" if "email" in self.fields else None if e.code == "has_existing_permissions": - existing_permissions, existing_invitations = ( - get_portfolio_invitation_associations(self.instance) - ) + existing_permissions, existing_invitations = get_portfolio_invitation_associations(self.instance) same_portfolio_for_permissions = existing_permissions.exclude(portfolio=self.instance.portfolio) same_portfolio_for_invitations = existing_invitations.exclude(portfolio=self.instance.portfolio) if same_portfolio_for_permissions.exists() or same_portfolio_for_invitations.exists(): - self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") + self.add_error( + field, f"{self.instance.email} is already a member of another .gov organization." + ) override_error = True elif e.code == "has_existing_invitations": self.add_error( diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 707dfcf54..98be6cc87 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -286,8 +286,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): # == Validate the multiple_porfolios flag. == # if not flag_is_active_for_user(user_portfolio_permission.user, "multiple_portfolios"): - existing_permissions, existing_invitations = ( - get_user_portfolio_permission_associations(user_portfolio_permission) + existing_permissions, existing_invitations = get_user_portfolio_permission_associations( + user_portfolio_permission ) if existing_permissions.exists(): raise ValidationError( @@ -303,6 +303,7 @@ def validate_user_portfolio_permission(user_portfolio_permission): code="has_existing_invitations", ) + def get_user_portfolio_permission_associations(user_portfolio_permission): """ Retrieves the associations for a user portfolio invitation. @@ -312,7 +313,7 @@ def get_user_portfolio_permission_associations(user_portfolio_permission): (existing_permissions, existing_invitations) where: - existing_permissions: UserPortfolioPermission objects excluding the current permission. - - existing_invitations: PortfolioInvitation objects for the user email excluding + - existing_invitations: PortfolioInvitation objects for the user email excluding the current invitation and those with status RETRIEVED. """ PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") @@ -320,7 +321,9 @@ def get_user_portfolio_permission_associations(user_portfolio_permission): existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( user=user_portfolio_permission.user ) - existing_invitations = PortfolioInvitation.objects.filter(email__iexact=user_portfolio_permission.user.email).exclude( + existing_invitations = PortfolioInvitation.objects.filter( + email__iexact=user_portfolio_permission.user.email + ).exclude( Q(portfolio=user_portfolio_permission.portfolio) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) ) @@ -375,9 +378,7 @@ def validate_portfolio_invitation(portfolio_invitation): # If user returns None, then we check for global assignment of multiple_portfolios. # Otherwise we just check on the user. if not flag_is_active_for_user(user, "multiple_portfolios"): - existing_permissions, existing_invitations = ( - get_portfolio_invitation_associations(portfolio_invitation) - ) + existing_permissions, existing_invitations = get_portfolio_invitation_associations(portfolio_invitation) if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " @@ -392,6 +393,7 @@ def validate_portfolio_invitation(portfolio_invitation): code="has_existing_invitations", ) + def get_portfolio_invitation_associations(portfolio_invitation): """ Retrieves the associations for a portfolio invitation. @@ -401,7 +403,7 @@ def get_portfolio_invitation_associations(portfolio_invitation): (existing_permissions, existing_invitations) where: - existing_permissions: UserPortfolioPermission objects matching the email. - - existing_invitations: PortfolioInvitation objects for the email excluding + - existing_invitations: PortfolioInvitation objects for the email excluding the current invitation and those with status RETRIEVED. """ PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 2065c2d35..bb99e875f 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -3930,17 +3930,19 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest): response = self.client.post( reverse("new-member"), { - "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, - "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "email": self.user.email, }, + follow=True ) self.assertEqual(response.status_code, 200) + with open("debug_response.html", "w") as f: + f.write(response.content.decode('utf-8')) # Verify messages self.assertContains( response, - f"{self.user.email} is already a member of another .gov organization.", + "User is already a member of this portfolio.", ) # Validate Database has not changed From 98de052c2ecf748b58c54af1c5dd957a80df3063 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 13 Mar 2025 08:30:58 -0600 Subject: [PATCH 47/64] linting --- src/registrar/models/utility/portfolio_helper.py | 4 ---- src/registrar/tests/test_views_portfolio.py | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 98be6cc87..669985725 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -257,9 +257,6 @@ def validate_user_portfolio_permission(user_portfolio_permission): Raises: ValidationError: If any of the validation rules are violated. """ - PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") - UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") - has_portfolio = bool(user_portfolio_permission.portfolio_id) portfolio_permissions = set(user_portfolio_permission._get_portfolio_permissions()) @@ -346,7 +343,6 @@ def validate_portfolio_invitation(portfolio_invitation): Raises: ValidationError: If any of the validation rules are violated. """ - PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") User = get_user_model() diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index bb99e875f..13a7a3bc6 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -3933,11 +3933,11 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest): "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "email": self.user.email, }, - follow=True + follow=True, ) self.assertEqual(response.status_code, 200) with open("debug_response.html", "w") as f: - f.write(response.content.decode('utf-8')) + f.write(response.content.decode("utf-8")) # Verify messages self.assertContains( From 08d6b70cfe39e93e3be823613fe811c5d90effbe Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 13 Mar 2025 22:02:36 -0400 Subject: [PATCH 48/64] added comments and removed commented out code --- .../src/js/getgov-admin/domain-request-form.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index 8823cb46c..b1c6feb04 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -105,8 +105,8 @@ export function initApprovedDomain() { return; } - const statusToCheck = "approved"; - const readonlyStatusToCheck = "Approved"; + const statusToCheck = "approved"; // when checking against a select + const readonlyStatusToCheck = "Approved"; // when checking against a readonly div display value const statusSelect = document.getElementById("id_status"); const statusField = document.querySelector("field-status"); const sessionVariableName = "showApprovedDomain"; @@ -122,6 +122,8 @@ export function initApprovedDomain() { // Handle showing/hiding the related fields on page load. function initializeFormGroups() { + // Status is either in a select or in a readonly div. Both + // cases are handled below. let isStatus = false; if (statusSelect) { isStatus = statusSelect.value == statusToCheck; @@ -605,12 +607,6 @@ export function initActionNeededEmail() { // Initialize UI const customEmail = new customActionNeededEmail(); - // // Check that every variable was setup correctly - // const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); - // if (nullItems.length > 0) { - // console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`) - // return; - // } customEmail.loadActionNeededEmail() }); } From f5429b97b0e2954de640aefa8e7f5668680acdde Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 13 Mar 2025 22:04:42 -0400 Subject: [PATCH 49/64] removed commented out code --- .../assets/src/js/getgov-admin/domain-request-form.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index b1c6feb04..e7bf8f00e 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -669,12 +669,6 @@ export function initRejectedEmail() { // Initialize UI const customEmail = new customRejectedEmail(); - // // Check that every variable was setup correctly - // const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); - // if (nullItems.length > 0) { - // console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`) - // return; - // } customEmail.loadRejectedEmail() }); } From 9362de496761fccdd92dde7dca80e3eea5d4d773 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 14 Mar 2025 08:51:03 -0600 Subject: [PATCH 50/64] Fix bug and add unit test --- src/registrar/admin.py | 4 +-- src/registrar/tests/test_views_portfolio.py | 40 +++++++++++++++++++++ src/registrar/views/portfolios.py | 2 +- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 09d0eaa81..28f5abf57 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1846,7 +1846,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): requested_user = get_requested_user(requested_email) permission_exists = UserPortfolioPermission.objects.filter( - user__email=requested_email, portfolio=portfolio, user__email__isnull=False + user__email__iexact=requested_email, portfolio=portfolio, user__email__isnull=False ).exists() if not permission_exists: # if permission does not exist for a user with requested_email, send email @@ -1857,7 +1857,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): is_admin_invitation=is_admin_invitation, ): messages.warning( - self.request, "Could not send email notification to existing organization admins." + request, "Could not send email notification to existing organization admins." ) # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 13a7a3bc6..114c066b3 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -3952,6 +3952,46 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest): # assert that send_portfolio_invitation_email is not called mock_send_email.assert_not_called() + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_member_invite_for_existing_member_uppercase(self, mock_send_email): + """Tests the member invitation flow for existing portfolio member with a different case.""" + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + invite_count_before = PortfolioInvitation.objects.count() + + # Simulate submission of member invite for user who has already been invited + response = self.client.post( + reverse("new-member"), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + "email": self.user.email.upper(), + }, + follow=True, + ) + self.assertEqual(response.status_code, 200) + with open("debug_response.html", "w") as f: + f.write(response.content.decode("utf-8")) + + # Verify messages + self.assertContains( + response, + "User is already a member of this portfolio.", + ) + + # Validate Database has not changed + invite_count_after = PortfolioInvitation.objects.count() + self.assertEqual(invite_count_after, invite_count_before) + + # assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index c2ec44b9e..7fa421eaa 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -970,7 +970,7 @@ class PortfolioAddMemberView(DetailView, FormMixin): portfolio = form.cleaned_data["portfolio"] is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in form.cleaned_data["roles"] - requested_user = User.objects.filter(email=requested_email).first() + requested_user = User.objects.filter(email__iexact=requested_email).first() permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists() try: if not requested_user or not permission_exists: From 127eb38259e478032b2e0de5a36d5a37d3f637ee Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 14 Mar 2025 13:28:05 -0600 Subject: [PATCH 51/64] lint --- src/registrar/admin.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 28f5abf57..343624915 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1856,9 +1856,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): portfolio=portfolio, is_admin_invitation=is_admin_invitation, ): - messages.warning( - request, "Could not send email notification to existing organization admins." - ) + messages.warning(request, "Could not send email notification to existing organization admins.") # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: obj.retrieve() From e15499e4a60b410de62b7f6d7d981228daa5bfcf Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 17 Mar 2025 13:20:14 -0400 Subject: [PATCH 52/64] aligned logic on federal type across the application --- src/registrar/admin.py | 18 ++++++++-------- src/registrar/models/domain_information.py | 4 +++- src/registrar/models/domain_request.py | 4 +++- src/registrar/tests/test_reports.py | 24 +++++++++++----------- src/registrar/utility/csv_export.py | 8 ++++---- 5 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 9bb9efe69..ac069bb38 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1793,7 +1793,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin): & Q(domain__domain_info__portfolio__federal_agency__isnull=False), then=F("domain__domain_info__portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value + # Otherwise, return the federal agency's federal_type default=F("domain__domain_info__federal_agency__federal_type"), ), ) @@ -2435,7 +2435,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), then=F("portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value + # Otherwise, return the federal_type from federal agency default=F("federal_agency__federal_type"), ), ) @@ -2520,7 +2520,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): class FederalTypeFilter(admin.SimpleListFilter): """Custom Federal Type filter that accomodates portfolio feature. If we have a portfolio, use the portfolio's federal type. If not, use the - organization in the Domain Request object.""" + organization in the Domain Request object's federal agency.""" title = "federal type" parameter_name = "converted_federal_types" @@ -2561,7 +2561,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if self.value(): return queryset.filter( Q(portfolio__federal_agency__federal_type=self.value()) - | Q(portfolio__isnull=True, federal_type=self.value()) + | Q(portfolio__isnull=True, federal_agency__federal_type=self.value()) ) return queryset @@ -3474,7 +3474,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), then=F("portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value + # Otherwise, return federal type from federal agency default=F("federal_agency__federal_type"), ), ) @@ -3932,7 +3932,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if self.value(): return queryset.filter( Q(domain_info__portfolio__federal_type=self.value()) - | Q(domain_info__portfolio__isnull=True, domain_info__federal_type=self.value()) + | Q(domain_info__portfolio__isnull=True, domain_info__federal_agency__federal_type=self.value()) ) return queryset @@ -3959,7 +3959,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): Q(domain_info__portfolio__isnull=False) & Q(domain_info__portfolio__federal_agency__isnull=False), then=F("domain_info__portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value + # Otherwise, return federal type from federal agency default=F("domain_info__federal_agency__federal_type"), ), converted_organization_name=Case( @@ -4872,7 +4872,7 @@ class PortfolioAdmin(ListHeaderAdmin): Q(federal_agency__isnull=False), then=F("federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value + # Otherwise, return empty string default=Value(""), ), ) @@ -5164,7 +5164,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), then=F("portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value + # Otherwise, return empty string default=Value(""), ), ) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index aa933e282..3839e5290 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -449,7 +449,9 @@ class DomainInformation(TimeStampedModel): def converted_federal_type(self): if self.portfolio: return self.portfolio.federal_type - return self.federal_type + elif self.federal_agency: + return self.federal_agency.federal_type + return None @property def converted_senior_official(self): diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 1cca3742f..66519e9f0 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -1454,7 +1454,9 @@ class DomainRequest(TimeStampedModel): def converted_federal_type(self): if self.portfolio: return self.portfolio.federal_type - return self.federal_type + elif self.federal_agency: + return self.federal_agency.federal_type + return None @property def converted_address_line1(self): diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 9ec3bd0d3..236e810cf 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -72,7 +72,7 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), @@ -94,7 +94,7 @@ class CsvReportsTest(MockDbForSharedTests): fake_open = mock_open() expected_file_content = [ call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"), - call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), + call("cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\r\n"), call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"), call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"), @@ -261,9 +261,6 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive," "Portfolio 1 Federal Agency,Portfolio 1 Federal Agency,,, ,,(blank)," '"big_lebowski@dude.co, info@example.com, meoward@rocks.com",woofwardthethird@rocks.com\n' - "cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive," - "World War I Centennial Commission,,,, ,,(blank)," - "meoward@rocks.com,\n" "adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),," "squeaker@rocks.com\n" "bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" @@ -274,6 +271,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" "xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" "zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,(blank),,\n" + "cdomain11.gov,Ready,2024-04-02,(blank),Federal," + "World War I Centennial Commission,,,, ,,(blank)," + "meoward@rocks.com,\n" "zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,(blank),meoward@rocks.com,\n" ) @@ -498,7 +498,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" @@ -538,7 +538,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): # sorted alphabetially by domain name expected_content = ( "Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n" - "cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" + "cdomain11.gov,Federal,World War I Centennial Commission,,,,(blank)\n" "defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n" "adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n" "ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n" @@ -594,7 +594,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "State,Status,Expiration date, Deleted\n" "cdomain1.gov,Federal-Executive,Portfolio1FederalAgency,Portfolio1FederalAgency,Ready,(blank)\n" "adomain10.gov,Federal,ArmedForcesRetirementHome,Ready,(blank)\n" - "cdomain11.gov,Federal-Executive,WorldWarICentennialCommission,Ready,(blank)\n" + "cdomain11.gov,Federal,WorldWarICentennialCommission,Ready,(blank)\n" "zdomain12.gov,Interstate,Ready,(blank)\n" "zdomain9.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-01\n" "sdomain8.gov,Federal,ArmedForcesRetirementHome,Deleted,(blank),2024-04-02\n" @@ -642,7 +642,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "3,2,1,0,0,0,0,0,0,0\n" "\n" "Domain name,Domain type,Domain managers,Invited domain managers\n" - "cdomain11.gov,Federal - Executive,meoward@rocks.com,\n" + "cdomain11.gov,Federal,meoward@rocks.com,\n" 'cdomain1.gov,Federal - Executive,"big_lebowski@dude.co, info@example.com, meoward@rocks.com",' "woofwardthethird@rocks.com\n" "zdomain12.gov,Interstate,meoward@rocks.com,\n" @@ -716,7 +716,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): expected_content = ( "Domain request,Domain type,Federal type\n" "city3.gov,Federal,Executive\n" - "city4.gov,City,Executive\n" + "city4.gov,City,\n" "city6.gov,Federal,Executive\n" ) @@ -783,7 +783,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): "SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts," "CISA regional representative,Current websites,Investigator\n" # Content - "city5.gov,Approved,Federal,No,Executive,,Testorg,N/A,,NY,2,requested_suborg,SanFran,CA,,,,,1,0," + "city5.gov,Approved,Federal,No,,,Testorg,N/A,,NY,2,requested_suborg,SanFran,CA,,,,,1,0," "city1.gov,Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,There is more," "Testy Tester testy2@town.com,,city.com,\n" "city2.gov,In review,Federal,Yes,Executive,Portfolio 1 Federal Agency,Portfolio 1 Federal Agency," @@ -795,7 +795,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib): 'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, ' 'Testy Tester testy2@town.com",' 'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n' - "city4.gov,Submitted,City,No,Executive,,Testorg,Yes,,NY,2,,,,,,,,0,1,city1.gov,Testy," + "city4.gov,Submitted,City,No,,,Testorg,Yes,,NY,2,,,,,,,,0,1,city1.gov,Testy," "Tester,testy@town.com," "Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more," "Testy Tester testy2@town.com," diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index fad58b2e2..cde91baca 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -579,8 +579,8 @@ class DomainExport(BaseExport): Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), then=F("portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value - default=F("federal_type"), + # Otherwise, return the federal type from federal agency + default=F("federal_agency__federal_type"), output_field=CharField(), ), "converted_organization_name": Case( @@ -1654,8 +1654,8 @@ class DomainRequestExport(BaseExport): Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), then=F("portfolio__federal_agency__federal_type"), ), - # Otherwise, return the natively assigned value - default=F("federal_type"), + # Otherwise, return the federal type from federal agency + default=F("federal_agency__federal_type"), output_field=CharField(), ), "converted_organization_name": Case( From dcfa9e4804396eeac8f0e8a5fdf087f249b79a92 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 17 Mar 2025 13:42:25 -0400 Subject: [PATCH 53/64] fixed migrations --- .../{0142_create_groups_v18.py => 0143_create_groups_v18.py} | 2 +- .../{0143_alter_user_options.py => 0144_alter_user_options.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/registrar/migrations/{0142_create_groups_v18.py => 0143_create_groups_v18.py} (93%) rename src/registrar/migrations/{0143_alter_user_options.py => 0144_alter_user_options.py} (92%) diff --git a/src/registrar/migrations/0142_create_groups_v18.py b/src/registrar/migrations/0143_create_groups_v18.py similarity index 93% rename from src/registrar/migrations/0142_create_groups_v18.py rename to src/registrar/migrations/0143_create_groups_v18.py index 74d4c2f50..d0b7a6dbc 100644 --- a/src/registrar/migrations/0142_create_groups_v18.py +++ b/src/registrar/migrations/0143_create_groups_v18.py @@ -26,7 +26,7 @@ def create_groups(apps, schema_editor) -> Any: class Migration(migrations.Migration): dependencies = [ - ("registrar", "0141_alter_portfolioinvitation_additional_permissions_and_more"), + ("registrar", "0142_domainrequest_feb_naming_requirements_and_more"), ] operations = [ diff --git a/src/registrar/migrations/0143_alter_user_options.py b/src/registrar/migrations/0144_alter_user_options.py similarity index 92% rename from src/registrar/migrations/0143_alter_user_options.py rename to src/registrar/migrations/0144_alter_user_options.py index 58e5cf3d5..88b06afe8 100644 --- a/src/registrar/migrations/0143_alter_user_options.py +++ b/src/registrar/migrations/0144_alter_user_options.py @@ -6,7 +6,7 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ("registrar", "0142_create_groups_v18"), + ("registrar", "0143_create_groups_v18"), ] operations = [ From 9ff8e8e0fbe71675905796e4820dbf6be672ea6b Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 17 Mar 2025 14:56:50 -0400 Subject: [PATCH 54/64] removed save buttons on readonly tables --- src/registrar/models/user_group.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index f2ffa50ed..c70879943 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -171,22 +171,22 @@ class UserGroup(Group): { "app_label": "registrar", "model": "federalagency", - "permissions": ["change_federalagency"], + "permissions": ["view_federalagency"], }, { "app_label": "registrar", "model": "portfolio", - "permissions": ["change_portfolio"], + "permissions": ["view_portfolio"], }, { "app_label": "registrar", "model": "suborganization", - "permissions": ["change_suborganization"], + "permissions": ["view_suborganization"], }, { "app_label": "registrar", "model": "seniorofficial", - "permissions": ["change_seniorofficial"], + "permissions": ["view_seniorofficial"], }, ] From f275245f698dad4922a6ac3b6314b6f819b7bcee Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 17 Mar 2025 15:06:49 -0400 Subject: [PATCH 55/64] removed save buttons on readonly tables --- src/registrar/admin.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 72bf7beb6..dbf5aa0ae 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1473,15 +1473,6 @@ class SeniorOfficialAdmin(ListHeaderAdmin): return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) - def has_change_permission(self, request, obj=None): - """Restrict update permissions based on group membership and model attributes.""" - if request.user.has_perm("registrar.full_access_permission"): - return True - if obj: - if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.federal_agency and obj.federal_agency.federal_type == BranchChoices.EXECUTIVE - return super().has_change_permission(request, obj) - class WebsiteResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -4898,15 +4889,6 @@ class PortfolioAdmin(ListHeaderAdmin): return obj.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) - def has_change_permission(self, request, obj=None): - """Restrict update permissions based on group membership and model attributes.""" - if request.user.has_perm("registrar.full_access_permission"): - return True - if obj: - if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.federal_type == BranchChoices.EXECUTIVE - return super().has_change_permission(request, obj) - 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).""" @@ -5013,15 +4995,6 @@ class FederalAgencyAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return obj.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) - def has_change_permission(self, request, obj=None): - """Restrict update permissions based on group membership and model attributes.""" - if request.user.has_perm("registrar.full_access_permission"): - return True - if obj: - if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.federal_type == BranchChoices.EXECUTIVE - return super().has_change_permission(request, obj) - def get_readonly_fields(self, request, obj=None): """Set the read-only state on form elements. We have 2 conditions that determine which fields are read-only: @@ -5193,15 +5166,6 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) - def has_change_permission(self, request, obj=None): - """Restrict update permissions based on group membership and model attributes.""" - if request.user.has_perm("registrar.full_access_permission"): - return True - if obj: - if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE - return super().has_change_permission(request, obj) - class AllowedEmailAdmin(ListHeaderAdmin): class Meta: From 07b1010dd4b4534fc71f38096df47d2bfc86a4aa Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 17 Mar 2025 15:31:14 -0400 Subject: [PATCH 56/64] updated tests related to updated permissions --- src/registrar/admin.py | 9 +++++---- src/registrar/tests/test_admin.py | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index dbf5aa0ae..1df862255 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1457,10 +1457,11 @@ class SeniorOfficialAdmin(ListHeaderAdmin): # Check if user is in OMB analysts group if request.user.groups.filter(name="omb_analysts_group").exists(): - annotated_qs = self.get_annotated_queryset(qs) - return annotated_qs.filter( - converted_federal_type=BranchChoices.EXECUTIVE, - ) + # annotated_qs = self.get_annotated_queryset(qs) + # return annotated_qs.filter( + # converted_federal_type=BranchChoices.EXECUTIVE, + # ) + return qs.filter(federal_agency__federal_type=BranchChoices.EXECUTIVE) return qs # Return full queryset if the user doesn't have the restriction diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 7e34c63c7..8fd2744ec 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -3819,8 +3819,8 @@ class TestFederalAgencyAdmin(TestCase): self.assertNotContains(response, "id_federal_type") self.assertNotContains(response, "id_acronym") self.assertNotContains(response, "id_is_fceb") - self.assertNotContains(response, "closelink") - self.assertContains(response, "Save") + self.assertContains(response, "closelink") + self.assertNotContains(response, "Save") self.assertNotContains(response, "Delete") @less_console_noise_decorator @@ -4083,8 +4083,8 @@ class TestPortfolioAdmin(TestCase): self.assertNotContains(response, "id_city") self.assertNotContains(response, "id_zipcode") self.assertNotContains(response, "id_urbanization") - self.assertNotContains(response, "closelink") - self.assertContains(response, "Save") + self.assertContains(response, "closelink") + self.assertNotContains(response, "Save") self.assertNotContains(response, "Delete") @less_console_noise_decorator From b2fc4e4f42754270f42e1033bf504db81e0d8e1c Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Mon, 17 Mar 2025 19:36:20 -0400 Subject: [PATCH 57/64] Fix typo in docs --- docs/developer/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/developer/README.md b/docs/developer/README.md index 442a13634..9fc79dce8 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -309,11 +309,11 @@ We use the [CSS Block Element Modifier (BEM)](https://getbem.com/naming/) naming 1. Version numbers can be manually controlled in `package.json`. Edit that, if desired. 2. Now run `npx gulp updateUswds`. Refer to [official docs](https://designsystem.digital.gov/documentation/getting-started/developers/phase-two-compile/) to see what this is doing. -4. Make note of the dotgov changes in uswds-edited.js (Ctrl-F DOTGOV for modifications to USWDS compiled code). -5. Copy over the newly compiled code from uswds.js into uswds-edited.js. -6. Put back the dotgov changes you made note of into uswds-edited.js. -7. Examine the results in the running application (remember to empty your cache!) and commit `package.json` and `package-lock.json` if all is well. -8. Read the [release notes](https://github.com/uswds/uswds/releases) for the new versions installed, note 'Breaking' and 'Markup change' and make adjustments to the code base as needed. +3. Make note of the dotgov changes in uswds-edited.js (Ctrl-F DOTGOV for modifications to USWDS compiled code). +4. Copy over the newly compiled code from uswds.js into uswds-edited.js. +5. Put back the dotgov changes you made note of into uswds-edited.js. +6. Examine the results in the running application (remember to empty your cache!) and commit `package.json` and `package-lock.json` if all is well. +7. Read the [release notes](https://github.com/uswds/uswds/releases) for the new versions installed, note 'Breaking' and 'Markup change' and make adjustments to the code base as needed. ## Finite State Machines From 192a7bb8a40989a77f70929b85123ff6b415cac1 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 18 Mar 2025 06:50:49 -0400 Subject: [PATCH 58/64] removed unnecessary code from SeniorOfficialAdmin --- src/registrar/admin.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 1df862255..bb729f20e 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1404,19 +1404,6 @@ class SeniorOfficialAdmin(ListHeaderAdmin): # in autocomplete_fields for Senior Official ordering = ["first_name", "last_name"] - def get_annotated_queryset(self, queryset): - return queryset.annotate( - converted_federal_type=Case( - # When portfolio is present, use its value instead - When( - Q(federal_agency__isnull=False), - then=F("federal_agency__federal_type"), - ), - # Otherwise, return the natively assigned value - default=Value(""), - ), - ) - readonly_fields = [] # Even though this is empty, I will leave it as a stub for easy changes in the future @@ -1457,10 +1444,6 @@ class SeniorOfficialAdmin(ListHeaderAdmin): # Check if user is in OMB analysts group if request.user.groups.filter(name="omb_analysts_group").exists(): - # annotated_qs = self.get_annotated_queryset(qs) - # return annotated_qs.filter( - # converted_federal_type=BranchChoices.EXECUTIVE, - # ) return qs.filter(federal_agency__federal_type=BranchChoices.EXECUTIVE) return qs # Return full queryset if the user doesn't have the restriction From 5528390ee9a83332f1097e74feec3384afc04063 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 18 Mar 2025 07:45:42 -0400 Subject: [PATCH 59/64] cleaned up unnecessary code --- src/registrar/admin.py | 47 +++++------------------------------------- 1 file changed, 5 insertions(+), 42 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index bb729f20e..b2d9e9e97 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -4836,19 +4836,6 @@ class PortfolioAdmin(ListHeaderAdmin): readonly_fields.extend([field for field in self.analyst_readonly_fields]) return readonly_fields - def get_annotated_queryset(self, queryset): - return queryset.annotate( - converted_federal_type=Case( - # When portfolio is present, use its value instead - When( - Q(federal_agency__isnull=False), - then=F("federal_agency__federal_type"), - ), - # Otherwise, return empty string - default=Value(""), - ), - ) - def get_queryset(self, request): """Restrict queryset based on user permissions.""" qs = super().get_queryset(request) @@ -4856,11 +4843,7 @@ class PortfolioAdmin(ListHeaderAdmin): # Check if user is in OMB analysts group if request.user.groups.filter(name="omb_analysts_group").exists(): self.is_omb_analyst = True - annotated_qs = self.get_annotated_queryset(qs) - return annotated_qs.filter( - organization_type=DomainRequest.OrganizationChoices.FEDERAL, - converted_federal_type=BranchChoices.EXECUTIVE, - ) + return qs.filter(federal_agency__federal_type=BranchChoices.EXECUTIVE) return qs # Return full queryset if the user doesn't have the restriction @@ -5110,34 +5093,14 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): extra_context = {"domain_requests": domain_requests, "domains": domains} return super().change_view(request, object_id, form_url, extra_context) - def get_annotated_queryset(self, queryset): - return queryset.annotate( - converted_federal_type=Case( - # When portfolio is present, use its value instead - When( - Q(portfolio__isnull=False) & Q(portfolio__federal_agency__isnull=False), - then=F("portfolio__federal_agency__federal_type"), - ), - # Otherwise, return empty string - default=Value(""), - ), - ) - def get_queryset(self, request): - """Custom get_queryset to filter by portfolio if portfolio is in the - request params.""" + """Custom get_queryset to filter for OMB analysts.""" qs = super().get_queryset(request) - # Check if a 'portfolio' parameter is passed in the request - portfolio_id = request.GET.get("portfolio") - if portfolio_id: - # Further filter the queryset by the portfolio - qs = qs.filter(portfolio=portfolio_id) # Check if user is in OMB analysts group if request.user.groups.filter(name="omb_analysts_group").exists(): - annotated_qs = self.get_annotated_queryset(qs) - return annotated_qs.filter( + return qs.filter( portfolio__organization_type=DomainRequest.OrganizationChoices.FEDERAL, - converted_federal_type=BranchChoices.EXECUTIVE, + portfolio__federal_agency__federal_type=BranchChoices.EXECUTIVE, ) return qs @@ -5147,7 +5110,7 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.portfolio and obj.portfolio.federal_type == BranchChoices.EXECUTIVE + return obj.portfolio and obj.portfolio.federal_agency and obj.portfolio.federal_agency.federal_type == BranchChoices.EXECUTIVE return super().has_view_permission(request, obj) From 531d8c7e9411076271dca9879d96fb040059d51f Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 18 Mar 2025 09:02:14 -0400 Subject: [PATCH 60/64] removed unnecessary permission --- src/registrar/admin.py | 2 +- .../js/getgov-admin/domain-request-form.js | 10 ++++---- src/registrar/decorators.py | 2 +- .../migrations/0144_alter_user_options.py | 23 ------------------- src/registrar/models/user.py | 1 - src/registrar/models/user_group.py | 5 ---- 6 files changed, 8 insertions(+), 35 deletions(-) delete mode 100644 src/registrar/migrations/0144_alter_user_options.py diff --git a/src/registrar/admin.py b/src/registrar/admin.py index b2d9e9e97..c2be81066 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -4361,7 +4361,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): if ( request.user.has_perm("registrar.full_access_permission") or request.user.has_perm("registrar.analyst_access_permission") - or request.user.has_perm("registrar.omb_analyst_access_permission") + or request.user.groups.filter(name="omb_analysts_group").exists() ): return True return super().has_change_permission(request, obj) diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index e7bf8f00e..16acaf91c 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -465,23 +465,25 @@ class CustomizableEmailBase { } initializeModalConfirm() { + // When the modal confirm button is present, add a listener if (this.modalConfirm) { this.modalConfirm.addEventListener("click", () => { this.textarea.removeAttribute('readonly'); this.textarea.focus(); - hideElement(this.directEditButton); - hideElement(this.modalTrigger); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); }); } } initializeDirectEditButton() { + // When the direct edit button is present, add a listener if (this.directEditButton) { this.directEditButton.addEventListener("click", () => { this.textarea.removeAttribute('readonly'); this.textarea.focus(); - hideElement(this.directEditButton); - hideElement(this.modalTrigger); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); }); } } diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py index fcff02b32..d607935a2 100644 --- a/src/registrar/decorators.py +++ b/src/registrar/decorators.py @@ -112,7 +112,7 @@ def _user_has_permission(user, request, rules, **kwargs): permission_checks = [ (IS_STAFF, lambda: user.is_staff), (IS_CISA_ANALYST, lambda: user.has_perm("registrar.analyst_access_permission")), - (IS_OMB_ANALYST, lambda: user.has_perm("registrar.omb_analyst_access_permission")), + (IS_OMB_ANALYST, lambda: user.groups.filter(name="omb_analysts_group").exists()), (IS_FULL_ACCESS, lambda: user.has_perm("registrar.full_access_permission")), ( IS_DOMAIN_MANAGER, diff --git a/src/registrar/migrations/0144_alter_user_options.py b/src/registrar/migrations/0144_alter_user_options.py deleted file mode 100644 index 88b06afe8..000000000 --- a/src/registrar/migrations/0144_alter_user_options.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.17 on 2025-03-06 20:00 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("registrar", "0143_create_groups_v18"), - ] - - operations = [ - migrations.AlterModelOptions( - name="user", - options={ - "permissions": [ - ("analyst_access_permission", "Analyst Access Permission"), - ("omb_analyst_access_permission", "OMB Analyst Access Permission"), - ("full_access_permission", "Full Access Permission"), - ] - }, - ), - ] diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 7ee4fefdf..d5476ab9a 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -40,7 +40,6 @@ class User(AbstractUser): permissions = [ ("analyst_access_permission", "Analyst Access Permission"), - ("omb_analyst_access_permission", "OMB Analyst Access Permission"), ("full_access_permission", "Full Access Permission"), ] diff --git a/src/registrar/models/user_group.py b/src/registrar/models/user_group.py index c70879943..331e36605 100644 --- a/src/registrar/models/user_group.py +++ b/src/registrar/models/user_group.py @@ -158,11 +158,6 @@ class UserGroup(Group): "model": "domain", "permissions": ["view_domain"], }, - { - "app_label": "registrar", - "model": "user", - "permissions": ["omb_analyst_access_permission"], - }, { "app_label": "registrar", "model": "domaininvitation", From 153d92542b8a198b7ac65980c14e827b503a3ee2 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 18 Mar 2025 09:14:52 -0400 Subject: [PATCH 61/64] linted --- src/registrar/admin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index c2be81066..83c547269 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -5110,7 +5110,11 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportRegistrarModelAdmin): return True if obj: if request.user.groups.filter(name="omb_analysts_group").exists(): - return obj.portfolio and obj.portfolio.federal_agency and obj.portfolio.federal_agency.federal_type == BranchChoices.EXECUTIVE + return ( + obj.portfolio + and obj.portfolio.federal_agency + and obj.portfolio.federal_agency.federal_type == BranchChoices.EXECUTIVE + ) return super().has_view_permission(request, obj) From a37cef619bfb063513fd8f9ed8bffc3ed20f0ba6 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 18 Mar 2025 11:01:09 -0400 Subject: [PATCH 62/64] fixed dynamic javascript on domain change form when portfolio readonly --- .../helpers-portfolio-dynamic-fields.js | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js index 54d0e073b..3880e63fa 100644 --- a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js +++ b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js @@ -12,7 +12,9 @@ export function handlePortfolioSelection( suborgDropdownSelector="#id_sub_organization" ) { // These dropdown are select2 fields so they must be interacted with via jquery + // In the event that these fields are readonly, need a variable to reference their row const portfolioDropdown = django.jQuery(portfolioDropdownSelector); + const portfolioField = document.querySelector(".field-portfolio"); const suborganizationDropdown = django.jQuery(suborgDropdownSelector); const suborganizationField = document.querySelector(".field-sub_organization"); const requestedSuborganizationField = document.querySelector(".field-requested_suborganization"); @@ -394,14 +396,30 @@ export function handlePortfolioSelection( * - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used. */ function updatePortfolioFieldsDisplay() { - // Retrieve the selected portfolio ID - let portfolio_id = portfolioDropdown.val(); + let portfolio_id = null; + let portfolio_selected = false; + // portfolio will be either readonly or a dropdown, handle both cases + if (portfolioDropdown.length) { // need to test length since the query will always be defined, even if not in DOM + // Retrieve the selected portfolio ID + portfolio_id = portfolioDropdown.val(); + if (portfolio_id) { + portfolio_selected = true; + } + } else { + // get readonly field value + let portfolio = portfolioField.querySelector(".readonly").innerText; + if (portfolio != "-") { + portfolio_selected = true; + } + } - if (portfolio_id) { + if (portfolio_selected) { // A portfolio is selected - update suborganization dropdown and show/hide relevant fields - // Update suborganization dropdown for the selected portfolio - updateSubOrganizationDropdown(portfolio_id); + if (portfolio_id) { + // Update suborganization dropdown for the selected portfolio + updateSubOrganizationDropdown(portfolio_id); + } // Show fields relevant to a selected portfolio if (suborganizationField) showElement(suborganizationField); @@ -468,10 +486,22 @@ export function handlePortfolioSelection( * This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested. */ function updateSuborganizationFieldsDisplay() { - let portfolio_id = portfolioDropdown.val(); + let portfolio_selected = false; + // portfolio will be either readonly or a dropdown, handle both cases + if (portfolioDropdown.length) { // need to test length since the query will always be defined, even if not in DOM + // Retrieve the selected portfolio ID + if (portfolioDropdown.val()) { + portfolio_selected = true; + } + } else { + // get readonly field value + if (portfolioField.querySelector(".readonly").innerText != "-") { + portfolio_selected = true; + } + } let suborganization_id = suborganizationDropdown.val(); - if (portfolio_id && !suborganization_id) { + if (portfolio_selected && !suborganization_id) { // Show suborganization request fields if (requestedSuborganizationField) showElement(requestedSuborganizationField); if (suborganizationCity) showElement(suborganizationCity); From 4ac2250c084e6b851ceb073776bf9381c64d41ef Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 18 Mar 2025 11:03:51 -0400 Subject: [PATCH 63/64] fixed bug in status check in javascript on domain request form --- src/registrar/assets/src/js/getgov-admin/domain-request-form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index 16acaf91c..b9084494a 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -383,7 +383,7 @@ class CustomizableEmailBase { initializeFormGroups() { let isStatus = false; if (this.statusSelect) { - this.statusSelect.value == this.statusToCheck; + isStatus = this.statusSelect.value == this.statusToCheck; } else { // statusSelect does not exist, indicating readonly if (this.dropdownFormGroup) { From 4502497037aa36632b0eab735bef93904120a69b Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 18 Mar 2025 11:52:54 -0400 Subject: [PATCH 64/64] fixed javascript on readonly fields on portfolio form --- .../assets/src/js/getgov-admin/portfolio-form.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js index 2e437c520..7777dabdb 100644 --- a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -21,6 +21,8 @@ function handlePortfolioFields(){ const federalTypeField = document.querySelector(".field-federal_type"); const urbanizationField = document.querySelector(".field-urbanization"); const stateTerritoryDropdown = document.getElementById("id_state_territory"); + const stateTerritoryField = document.querySelector(".field-state_territory"); + const stateTerritoryReadonly = stateTerritoryField.querySelector(".readonly"); const seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; const seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; const federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; @@ -85,9 +87,9 @@ function handlePortfolioFields(){ * 2. else show org name, hide federal agency, hide federal type if applicable */ function handleOrganizationTypeChange() { - if (organizationTypeDropdown && organizationNameField) { - let selectedValue = organizationTypeDropdown.value; - if (selectedValue === "federal") { + if (organizationTypeField && organizationNameField) { + let selectedValue = organizationTypeDropdown ? organizationTypeDropdown.value : organizationTypeReadonly.innerText; + if (selectedValue === "federal" || selectedValue === "Federal") { hideElement(organizationNameField); showElement(federalAgencyField); if (federalTypeField) { @@ -207,8 +209,8 @@ function handlePortfolioFields(){ * Handle urbanization */ function handleStateTerritoryChange() { - let selectedValue = stateTerritoryDropdown.value; - if (selectedValue === "PR") { + let selectedValue = stateTerritoryDropdown ? stateTerritoryDropdown.value : stateTerritoryReadonly.innerText; + if (selectedValue === "PR" || selectedValue === "Puerto Rico (PR)") { showElement(urbanizationField) } else { hideElement(urbanizationField) @@ -265,7 +267,7 @@ function handlePortfolioFields(){ * Initializes necessary data and display configurations for the portfolio fields. */ function initializePortfolioSettings() { - if (urbanizationField && stateTerritoryDropdown) { + if (urbanizationField && stateTerritoryField) { handleStateTerritoryChange(); } handleOrganizationTypeChange();