From 4dd16ec37058486a6b9f9f729eb77c8f815f75a0 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 3 Mar 2025 16:53:28 -0500 Subject: [PATCH 001/285] 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 002/285] 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 003/285] 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 004/285] 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 005/285] 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 006/285] 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 007/285] 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 %} + + + {% endif %} + {% endfor %} + +
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 b022b13706de0dd85b11e897f2525614db1a79b8 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Thu, 6 Mar 2025 11:56:41 -0600 Subject: [PATCH 008/285] additional details forms --- src/registrar/assets/src/js/getgov/main.js | 3 +- src/registrar/forms/domain_request_wizard.py | 2 +- .../domainrequestwizard/additional_details.py | 85 +++++++++++++++++++ ..._domainrequest_feb_eop_contact_and_more.py | 30 +++++++ src/registrar/models/domain_request.py | 13 +++ ...lio_domain_request_additional_details.html | 60 +++++++++++-- src/registrar/tests/test_admin_request.py | 2 + src/registrar/tests/test_views_request.py | 38 ++++++++- src/registrar/views/domain_request.py | 42 ++++++++- 9 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 src/registrar/forms/domainrequestwizard/additional_details.py create mode 100644 src/registrar/migrations/0143_domainrequest_feb_eop_contact_and_more.py diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 139c8484a..724f8b9d0 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -25,6 +25,8 @@ nameserversFormListener(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); +hookupYesNoListener("portfolio_additional_details-working_with_eop", "eop-contact-container", null); +hookupYesNoListener("portfolio_additional_details-has_anything_else_text", 'anything-else-details-container', null); hookupYesNoListener("dotgov_domain-feb_naming_requirements", null, "domain-naming-requirements-details-container"); hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choice_callbacks); @@ -32,7 +34,6 @@ hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choic hookupYesNoListener("purpose-has_timeframe", "purpose-timeframe-details-container", null); hookupYesNoListener("purpose-is_interagency_initiative", "purpose-interagency-initaitive-details-container", null); - initializeUrbanizationToggle(); userProfileListener(); diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 7cbb159b4..8c89a35ec 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -599,7 +599,7 @@ class DotGovDomainForm(RegistrarForm): return_type=ValidationReturnType.FORM_VALIDATION_ERROR, ) return validated - + def is_valid(self): return super().is_valid() diff --git a/src/registrar/forms/domainrequestwizard/additional_details.py b/src/registrar/forms/domainrequestwizard/additional_details.py new file mode 100644 index 000000000..8ae0629d5 --- /dev/null +++ b/src/registrar/forms/domainrequestwizard/additional_details.py @@ -0,0 +1,85 @@ +from django import forms +from django.core.validators import MaxLengthValidator +from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm +from registrar.models.contact import Contact + + +class WorkingWithEOPYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm): + """ + Form for determining if the Federal Executive Branch (FEB) agency is working with the + Executive Office of the President (EOP) on the domain request. + """ + + field_name = "working_with_eop" + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form based on the domain_request's attributes. + """ + return self.domain_request.working_with_eop + + +class EOPContactForm(BaseDeletableRegistrarForm): + """ + Form for contact information of the representative of the + Executive Office of the President (EOP) that the Federal + Executive Branch (FEB) agency is working with. + """ + + field_name = "eop_contact" + + first_name = forms.CharField( + label="First name / given name", + error_messages={"required": "Enter the first name / given name of this contact."}, + required=True, + ) + last_name = forms.CharField( + label="Last name / family name", + error_messages={"required": "Enter the last name / family name of this contact."}, + required=True, + ) + email = forms.EmailField( + label="Email", + max_length=None, + error_messages={ + "required": ("Enter an email address in the required format, like name@example.com."), + "invalid": ("Enter an email address in the required format, like name@example.com."), + }, + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + required=True, + help_text="Enter an email address in the required format, like name@example.com.", + ) + + @classmethod + def from_database(cls, obj): + # if not obj.eop_contact: + # return {} + # return { + # "first_name": obj.feb_eop_contact.first_name, + # "last_name": obj.feb_eop_contact.last_name, + # "email": obj.feb_eop_contact.email, + # } + return {} + + def to_database(self, obj): + if not self.is_valid(): + return + obj.eop_contact = Contact.objects.create( + first_name=self.cleaned_data["first_name"], + last_name=self.cleaned_data["last_name"], + email=self.cleaned_data["email"], + ) + obj.save() + + +class FEBAnythingElseYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm): + """Yes/no toggle for the anything else question on additional details""" + + form_is_checked = property(lambda self: self.domain_request.has_anything_else_text) # type: ignore + field_name = "has_anything_else_text" diff --git a/src/registrar/migrations/0143_domainrequest_feb_eop_contact_and_more.py b/src/registrar/migrations/0143_domainrequest_feb_eop_contact_and_more.py new file mode 100644 index 000000000..7001e47d6 --- /dev/null +++ b/src/registrar/migrations/0143_domainrequest_feb_eop_contact_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.17 on 2025-03-05 15:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0142_domainrequest_feb_purpose_choice_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="domainrequest", + name="eop_contact", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="eop_contact", + to="registrar.contact", + ), + ), + migrations.AddField( + model_name="domainrequest", + name="working_with_eop", + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index b7aaff65d..b181094aa 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -523,6 +523,19 @@ class DomainRequest(TimeStampedModel): choices=FEBPurposeChoices.choices, ) + working_with_eop = models.BooleanField( + null=True, + blank=True, + ) + + eop_contact = models.ForeignKey( + "registrar.Contact", + null=True, + blank=True, + related_name="eop_contact", + on_delete=models.PROTECT, + ) + # This field is alternately used for generic domain purpose explanations # and for explanations of the specific purpose chosen with feb_purpose_choice # by a Federal Executive Branch agency. diff --git a/src/registrar/templates/portfolio_domain_request_additional_details.html b/src/registrar/templates/portfolio_domain_request_additional_details.html index 5bc529243..d7d53dd1a 100644 --- a/src/registrar/templates/portfolio_domain_request_additional_details.html +++ b/src/registrar/templates/portfolio_domain_request_additional_details.html @@ -6,16 +6,60 @@ {% endblock %} {% block form_fields %} + {% if requires_feb_questions %} + {{forms.2.management_form}} + {{forms.3.management_form}} + {{forms.4.management_form}} + {{forms.5.management_form}} +
+

Are you working with someone in the Executive Office of the President (EOP) on this request?

+ +

+ Select one. * +

+ {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.0.working_with_eop %} + {% endwith %} -
+ + +

Is there anything else you'd like us to know about your domain request?

+

+ Select one. * +

+ {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.2.has_anything_else_text %} + {% endwith %} + + +
+ {% else %} +

Is there anything else you’d like us to know about your domain request?

-
+
-
-

This question is optional.

- {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} - {% input_with_errors forms.0.anything_else %} - {% endwith %} -
+
+

This question is optional.

+ {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% input_with_errors forms.0.anything_else %} + {% endwith %} +
+ {% endif %} {% endblock %} diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 9320dd3d3..7bc150326 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1984,6 +1984,8 @@ class TestDomainRequestAdmin(MockEppLib): "feb_naming_requirements", "feb_naming_requirements_details", "feb_purpose_choice", + "working_with_eop", + "eop_contact", "purpose", "has_timeframe", "time_frame_details", diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 7b117f67d..7db89a9e8 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -2550,7 +2550,7 @@ class DomainRequestTests(TestWithUser, WebTest): # @less_console_noise_decorator @override_flag("organization_feature", active=True) - def test_domain_request_dotgov_domain_FEB_questions(self): + def test_domain_request_FEB_questions(self): """ Test that for a member of a federal executive branch portfolio with org feature on, the dotgov domain page contains additional questions for OMB. @@ -2612,13 +2612,14 @@ class DomainRequestTests(TestWithUser, WebTest): # separate out these tests for readability self.feb_dotgov_domain_tests(dotgov_page) - # Now proceed with the actual test domain_form = dotgov_page.forms[0] domain = "test.gov" domain_form["dotgov_domain-requested_domain"] = domain domain_form["dotgov_domain-feb_naming_requirements"] = "True" domain_form["dotgov_domain-feb_naming_requirements_details"] = "test" - with patch('registrar.forms.domain_request_wizard.DotGovDomainForm.clean_requested_domain', return_value=domain): # noqa + with patch( + "registrar.forms.domain_request_wizard.DotGovDomainForm.clean_requested_domain", return_value=domain + ): # noqa self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) domain_result = domain_form.submit() @@ -2628,6 +2629,20 @@ class DomainRequestTests(TestWithUser, WebTest): self.feb_purpose_page_tests(purpose_page) + purpose_form = purpose_page.forms[0] + purpose_form["purpose-feb_purpose_choice"] = "redirect" + purpose_form["purpose-purpose"] = "test" + purpose_form["purpose-has_timeframe"] = "True" + purpose_form["purpose-time_frame_details"] = "test" + purpose_form["purpose-is_interagency_initiative"] = "True" + purpose_form["purpose-interagency_initiative_details"] = "test" + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + purpose_result = purpose_form.submit() + + # ---- ADDITIONAL DETAILS PAGE ---- + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + additional_details_page = purpose_result.follow() + self.feb_additional_details_page_tests(additional_details_page) def feb_purpose_page_tests(self, purpose_page): self.assertContains(purpose_page, "What is the purpose of your requested domain?") @@ -2669,6 +2684,23 @@ class DomainRequestTests(TestWithUser, WebTest): # Check that the details form was included self.assertContains(dotgov_page, "feb_naming_requirements_details") + def feb_additional_details_page_tests(self, additional_details_page): + test_text = "Are you working with someone in the Executive Office of the President (EOP) on this request?" + self.assertContains(additional_details_page, test_text) + + # Make sure the EOP form is present + self.assertContains(additional_details_page, "working_with_eop") + + # Make sure the EOP contact form is present + self.assertContains(additional_details_page, "eop-contact-container") + self.assertContains(additional_details_page, "additional_details-first_name") + self.assertContains(additional_details_page, "additional_details-last_name") + self.assertContains(additional_details_page, "additional_details-email") + + # Make sure the additional details form is present + self.assertContains(additional_details_page, "additional_details-has_anything_else_text") + self.assertContains(additional_details_page, "additional_details-anything_else") + @less_console_noise_decorator def test_domain_request_formsets(self): """Users are able to add more than one of some fields.""" diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 55cd9a19e..1aeb52a20 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -15,7 +15,7 @@ from registrar.decorators import ( grant_access, ) from registrar.forms import domain_request_wizard as forms -from registrar.forms.domainrequestwizard import purpose +from registrar.forms.domainrequestwizard import (purpose, additional_details) from registrar.forms.utility.wizard_form_helper import request_step_list from registrar.models import DomainRequest from registrar.models.contact import Contact @@ -609,7 +609,45 @@ class RequestingEntity(DomainRequestWizard): class PortfolioAdditionalDetails(DomainRequestWizard): template_name = "portfolio_domain_request_additional_details.html" - forms = [forms.PortfolioAnythingElseForm] + forms = [ + additional_details.WorkingWithEOPYesNoForm, + additional_details.EOPContactForm, + additional_details.FEBAnythingElseYesNoForm, + forms.PortfolioAnythingElseForm, + ] + + def get_context_data(self): + context = super().get_context_data() + context["requires_feb_questions"] = self.requires_feb_questions() + return context + + def is_valid(self, forms: list) -> bool: + """ + Validates the forms for portfolio additional details. + + Expected order of forms_list: + 0: WorkingWithEOPYesNoForm + 1: EOPContactForm + 2: FEBAnythingElseYesNoForm + 3: PortfolioAnythingElseForm + """ + eop_forms_valid = True + if not forms[0].is_valid(): + # If the user isn't working with EOP, don't validate the EOP contact form + forms[1].mark_form_for_deletion() + eop_forms_valid = False + if forms[0].cleaned_data.get("working_with_eop"): + eop_forms_valid = forms[1].is_valid() + else: + forms[1].mark_form_for_deletion() + anything_else_forms_valid = True + if not forms[2].is_valid(): + forms[3].mark_form_for_deletion() + anything_else_forms_valid = False + if forms[2].cleaned_data.get("has_anything_else_text"): + forms[3].fields["anything_else"].required = True + anything_else_forms_valid = forms[3].is_valid() + return (eop_forms_valid and anything_else_forms_valid) # Non-portfolio pages From 76bb219a2a8325d7d8c798c338828a0834fde7ea Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 6 Mar 2025 13:55:00 -0500 Subject: [PATCH 009/285] 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 010/285] 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 011/285] 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 012/285] 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 013/285] 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 014/285] 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 015/285] 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 016/285] 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 017/285] 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 018/285] 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 019/285] 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 020/285] 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 021/285] 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 237e79e4938bcee8186e46a5f8db83e9a60cceeb Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 7 Mar 2025 09:22:27 -0800 Subject: [PATCH 022/285] Add portfolio org email templates --- .../portfolio_org_update_notification.txt | 34 +++++++++++++++++++ ...tfolio_org_update_notification_subject.txt | 1 + 2 files changed, 35 insertions(+) create mode 100644 src/registrar/templates/emails/portfolio_org_update_notification.txt create mode 100644 src/registrar/templates/emails/portfolio_org_update_notification_subject.txt diff --git a/src/registrar/templates/emails/portfolio_org_update_notification.txt b/src/registrar/templates/emails/portfolio_org_update_notification.txt new file mode 100644 index 000000000..80dfd2ecb --- /dev/null +++ b/src/registrar/templates/emails/portfolio_org_update_notification.txt @@ -0,0 +1,34 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi, {% if portfolio_admin and portfolio_admin.first_name %} + +An update was made to your .gov organization + +ORGANIZATION: {{ portfolio.organization_name }} +UPDATED BY: {{ editor_email }} +UPDATED ON: {{ date }} +INFORMATION UPDATED: put page where info was edited here + +You can view this update in the .gov registrar . + +---------------------------------------------------------------- + +WHY DID YOU RECEIVE THIS EMAIL? +You're listed as an admin for {{ $portfolio.organization_name }}, so you'll receive a +notification whenever changes are made to that .gov organization. + +If you have questions or concerns, reach out to the person who made the change or reply +to this email. + +THANK YOU +.Gov helps the public identify official, trusted information. Thank you for using a .gov +domain. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Learn about .gov + +The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency +(CISA) +{% endautoescape %} diff --git a/src/registrar/templates/emails/portfolio_org_update_notification_subject.txt b/src/registrar/templates/emails/portfolio_org_update_notification_subject.txt new file mode 100644 index 000000000..a30f72a54 --- /dev/null +++ b/src/registrar/templates/emails/portfolio_org_update_notification_subject.txt @@ -0,0 +1 @@ +An update was made to your .gov organization \ No newline at end of file From dd5d00ddcaf21536b466753fdd6b4aa5daf0c1b2 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 7 Mar 2025 15:49:07 -0800 Subject: [PATCH 023/285] Create portfolio org update email methods --- src/registrar/utility/email_invitations.py | 46 ++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py index 08ebb4d86..eaac4f06a 100644 --- a/src/registrar/utility/email_invitations.py +++ b/src/registrar/utility/email_invitations.py @@ -226,6 +226,52 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_i ) return all_admin_emails_sent +def send_portfolio_organization_update_email(editor, portfolio-portfolio): + """ + Sends an email notification to all portfolio admin when portfolio organization is updated. + + Raises exceptions for validation or email-sending issues. + + Args: + editor (User): The user editing the portfolio organization. + portfolio (Portfolio): The portfolio object whose organization information is changed. + + Returns: + Boolean indicating if all messages were sent successfully. + + Raises: + MissingEmailError: If the requestor has no email associated with their account. + EmailSendingError: If there is an error while sending the email. + """ + editor_email = _get_requestor_email(editor, portfolio=portfolio) + # Get each portfolio admin from list + user_portfolio_permissions = UserPortfolioPermission.objects.filter( + portfolio=portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ).exclude(user__email=editor_email) + for user_portfolio_permission in user_portfolio_permissions: + # Send email to each portfolio_admin + user = user_portfolio_permission.user + try: + send_templated_email( + "emails/portfolio_org_update_notification.txt", + "emails/portfolio_org_update_notification_subject.txt", + to_address=user.email, + context={ + "portfolio": portfolio, + "editor_email": editor_email, + "portfolio_admin": user, + "date": date.today(), + }, + ) + except EmailSendingError: + logger.warning( + "Could not send email organization admin notification to %s " "for portfolio: %s", + user.email, + portfolio.organization_name, + exc_info=True, + ) + all_emails_sent = False + return all_emails_sent def send_portfolio_member_permission_update_email(requestor, permissions: UserPortfolioPermission): """ From 7994484fed1d15e308a5ce16b0bcd6fe0b1be43c Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 7 Mar 2025 19:36:24 -0500 Subject: [PATCH 024/285] 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 8fb349f0b953c8e8357846ab65295607e1ad2b72 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:50:11 -0800 Subject: [PATCH 025/285] Save email template progress --- .../portfolio_org_update_notification.txt | 8 ++++---- src/registrar/utility/email_invitations.py | 10 +++++----- src/registrar/views/portfolios.py | 19 ++++++++++++++++++- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/registrar/templates/emails/portfolio_org_update_notification.txt b/src/registrar/templates/emails/portfolio_org_update_notification.txt index 80dfd2ecb..639f08fdf 100644 --- a/src/registrar/templates/emails/portfolio_org_update_notification.txt +++ b/src/registrar/templates/emails/portfolio_org_update_notification.txt @@ -1,10 +1,10 @@ {% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} -Hi, {% if portfolio_admin and portfolio_admin.first_name %} +Hi, {% if portfolio_admin and portfolio_admin.first_name %}{% endif %} An update was made to your .gov organization -ORGANIZATION: {{ portfolio.organization_name }} -UPDATED BY: {{ editor_email }} +ORGANIZATION: {{ portfolio }} +UPDATED BY: {{ editor.email }} UPDATED ON: {{ date }} INFORMATION UPDATED: put page where info was edited here @@ -13,7 +13,7 @@ You can view this update in the .gov registrar . ---------------------------------------------------------------- WHY DID YOU RECEIVE THIS EMAIL? -You're listed as an admin for {{ $portfolio.organization_name }}, so you'll receive a +You're listed as an admin for {{ portfolio }}, so you'll receive a notification whenever changes are made to that .gov organization. If you have questions or concerns, reach out to the person who made the change or reply diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py index eaac4f06a..df1ee1bd4 100644 --- a/src/registrar/utility/email_invitations.py +++ b/src/registrar/utility/email_invitations.py @@ -226,7 +226,7 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_i ) return all_admin_emails_sent -def send_portfolio_organization_update_email(editor, portfolio-portfolio): +def send_portfolio_organization_update_email(editor, portfolio): """ Sends an email notification to all portfolio admin when portfolio organization is updated. @@ -243,7 +243,7 @@ def send_portfolio_organization_update_email(editor, portfolio-portfolio): MissingEmailError: If the requestor has no email associated with their account. EmailSendingError: If there is an error while sending the email. """ - editor_email = _get_requestor_email(editor, portfolio=portfolio) + editor_email = editor.email # Get each portfolio admin from list user_portfolio_permissions = UserPortfolioPermission.objects.filter( portfolio=portfolio, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] @@ -258,7 +258,7 @@ def send_portfolio_organization_update_email(editor, portfolio-portfolio): to_address=user.email, context={ "portfolio": portfolio, - "editor_email": editor_email, + "editor": editor, "portfolio_admin": user, "date": date.today(), }, @@ -267,7 +267,7 @@ def send_portfolio_organization_update_email(editor, portfolio-portfolio): logger.warning( "Could not send email organization admin notification to %s " "for portfolio: %s", user.email, - portfolio.organization_name, + portfolio, exc_info=True, ) all_emails_sent = False @@ -444,7 +444,7 @@ def _send_portfolio_admin_addition_emails_to_portfolio_admins(email: str, reques logger.warning( "Could not send email organization admin notification to %s " "for portfolio: %s", user.email, - portfolio.organization_name, + portfolio, exc_info=True, ) all_emails_sent = False diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 1882cc11b..5d33adf17 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -35,6 +35,7 @@ from registrar.utility.email_invitations import ( send_portfolio_invitation_remove_email, send_portfolio_member_permission_remove_email, send_portfolio_member_permission_update_email, + send_portfolio_organization_update_email ) from registrar.utility.errors import MissingEmailError from registrar.utility.enums import DefaultUserValues @@ -840,7 +841,23 @@ class PortfolioOrganizationView(DetailView, FormMixin): self.object = self.get_object() form = self.get_form() if form.is_valid(): - return self.form_valid(form) + user=request.user + try: + if not send_portfolio_organization_update_email( + editor=user, portfolio=self.request.session.get("portfolio") + ): + messages.warning(self.request, f"Could not send email notification to {user.email}.") + return redirect(reverse("organization")) + except Exception as e: + messages.error( + request, + f"An unexpected error occurred: {str(e)}. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True) + return None + messages.success(self.request, "The portfolio organization information has been updated.") + return redirect(reverse("organization")) else: return self.form_invalid(form) From 394147d657bca5371a9be53d04791ab1a2e8d638 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 7 Mar 2025 19:51:40 -0500 Subject: [PATCH 026/285] 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 027/285] 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 028/285] 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 029/285] 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 030/285] 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 031/285] 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 032/285] 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 033/285] 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 034/285] 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 035/285] 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 036/285] 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 037/285] 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 038/285] 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 039/285] 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 040/285] 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 041/285] 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 042/285] 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 043/285] 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 7516e1af0b331178fa5814616282d87d34d12460 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:13:31 -0600 Subject: [PATCH 044/285] Change defaults --- src/registrar/models/domain.py | 2 +- src/registrar/models/public_contact.py | 19 +++++++++---------- src/registrar/utility/csv_export.py | 2 +- src/registrar/utility/enums.py | 9 +++++---- src/registrar/views/domain.py | 6 +++--- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index d3c0ed347..9a54adb0d 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1680,7 +1680,7 @@ class Domain(TimeStampedModel, DomainHelper): DF = epp.DiscloseField fields = {DF.EMAIL} - hidden_security_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value] + hidden_security_emails = [email for email in DefaultEmail] disclose = is_security and contact.email not in hidden_security_emails # Delete after testing on other devices logger.info("Updated domain contact %s to disclose: %s", contact.email, disclose) diff --git a/src/registrar/models/public_contact.py b/src/registrar/models/public_contact.py index 71ed07de5..6a0228a7b 100644 --- a/src/registrar/models/public_contact.py +++ b/src/registrar/models/public_contact.py @@ -92,13 +92,12 @@ class PublicContact(TimeStampedModel): return cls( contact_type=PublicContact.ContactTypeChoices.REGISTRANT, registry_id=get_id(), - name="CSD/CB – Attn: Cameron Dixon", + name="CSD/CB – Attn: .gov TLD", org="Cybersecurity and Infrastructure Security Agency", - street1="CISA – NGR STOP 0645", - street2="1110 N. Glebe Rd.", + street1="1110 N. Glebe Rd", city="Arlington", sp="VA", - pc="20598-0645", + pc="22201", cc="US", email=DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, voice="+1.8882820870", @@ -110,9 +109,9 @@ class PublicContact(TimeStampedModel): return cls( contact_type=PublicContact.ContactTypeChoices.ADMINISTRATIVE, registry_id=get_id(), - name="Program Manager", + name="CSD/CB – Attn: .gov TLD", org="Cybersecurity and Infrastructure Security Agency", - street1="4200 Wilson Blvd.", + street1="1110 N. Glebe Rd", city="Arlington", sp="VA", pc="22201", @@ -127,9 +126,9 @@ class PublicContact(TimeStampedModel): return cls( contact_type=PublicContact.ContactTypeChoices.TECHNICAL, registry_id=get_id(), - name="Registry Customer Service", + name="CSD/CB – Attn: .gov TLD", org="Cybersecurity and Infrastructure Security Agency", - street1="4200 Wilson Blvd.", + street1="1110 N. Glebe Rd", city="Arlington", sp="VA", pc="22201", @@ -144,9 +143,9 @@ class PublicContact(TimeStampedModel): return cls( contact_type=PublicContact.ContactTypeChoices.SECURITY, registry_id=get_id(), - name="Registry Customer Service", + name="CSD/CB – Attn: .gov TLD", org="Cybersecurity and Infrastructure Security Agency", - street1="4200 Wilson Blvd.", + street1="1110 N. Glebe Rd", city="Arlington", sp="VA", pc="22201", diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index fad58b2e2..5660cb41d 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -740,7 +740,7 @@ class DomainExport(BaseExport): domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}" security_contact_email = model.get("security_contact_email") - invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value} + invalid_emails = [email for email in DefaultEmail] if ( not security_contact_email or not isinstance(security_contact_email, str) diff --git a/src/registrar/utility/enums.py b/src/registrar/utility/enums.py index 47e6da47f..8b3aff7ad 100644 --- a/src/registrar/utility/enums.py +++ b/src/registrar/utility/enums.py @@ -29,16 +29,17 @@ class LogCode(Enum): DEFAULT = 5 -class DefaultEmail(Enum): +class DefaultEmail(StrEnum): """Stores the string values of default emails Overview of emails: - - PUBLIC_CONTACT_DEFAULT: "dotgov@cisa.dhs.gov" + - PUBLIC_CONTACT_DEFAULT: "help@get.gov" + - OLD_PUBLIC_CONTACT_DEFAULT: "dotgov@cisa.dhs.gov" - LEGACY_DEFAULT: "registrar@dotgov.gov" - - HELP_EMAIL: "help@get.gov" """ - PUBLIC_CONTACT_DEFAULT = "dotgov@cisa.dhs.gov" + PUBLIC_CONTACT_DEFAULT = "help@get.gov" + OLD_PUBLIC_CONTACT_DEFAULT = "dotgov@cisa.dhs.gov" LEGACY_DEFAULT = "registrar@dotgov.gov" diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 3a083393e..0b13dda69 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -398,7 +398,7 @@ class DomainView(DomainBaseView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - default_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value] + default_emails = [email for email in DefaultEmail] context["hidden_security_emails"] = default_emails @@ -456,7 +456,7 @@ class DomainRenewalView(DomainBaseView): context = super().get_context_data(**kwargs) - default_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value] + default_emails = [email for email in DefaultEmail] context["hidden_security_emails"] = default_emails @@ -1166,7 +1166,7 @@ class DomainSecurityEmailView(DomainFormBaseView): initial = super().get_initial() security_contact = self.object.security_contact - invalid_emails = [DefaultEmail.PUBLIC_CONTACT_DEFAULT.value, DefaultEmail.LEGACY_DEFAULT.value] + invalid_emails = [email for email in DefaultEmail] if security_contact is None or security_contact.email in invalid_emails: initial["security_email"] = None return initial From 55a101b7f6a99602d49aa004977f6e33a183d03c Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Mon, 10 Mar 2025 12:53:23 -0700 Subject: [PATCH 045/285] 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 918fc689c13afd8c51cd6923d666233540542f62 Mon Sep 17 00:00:00 2001 From: Erin Song <121973038+erinysong@users.noreply.github.com> Date: Mon, 10 Mar 2025 14:54:58 -0700 Subject: [PATCH 046/285] Send email when senior official updated --- .../portfolio_org_update_notification.txt | 6 ++--- src/registrar/utility/email_invitations.py | 4 ++- src/registrar/views/portfolios.py | 27 ++++++++++++++++++- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/registrar/templates/emails/portfolio_org_update_notification.txt b/src/registrar/templates/emails/portfolio_org_update_notification.txt index 639f08fdf..1b9dbf2fc 100644 --- a/src/registrar/templates/emails/portfolio_org_update_notification.txt +++ b/src/registrar/templates/emails/portfolio_org_update_notification.txt @@ -1,12 +1,12 @@ {% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} -Hi, {% if portfolio_admin and portfolio_admin.first_name %}{% endif %} +Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %} -An update was made to your .gov organization +An update was made to your .gov organization. ORGANIZATION: {{ portfolio }} UPDATED BY: {{ editor.email }} UPDATED ON: {{ date }} -INFORMATION UPDATED: put page where info was edited here +INFORMATION UPDATED: {{ updated_info }} You can view this update in the .gov registrar . diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py index df1ee1bd4..729da3f11 100644 --- a/src/registrar/utility/email_invitations.py +++ b/src/registrar/utility/email_invitations.py @@ -226,7 +226,7 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_i ) return all_admin_emails_sent -def send_portfolio_organization_update_email(editor, portfolio): +def send_portfolio_organization_update_email(editor, portfolio, updated_page): """ Sends an email notification to all portfolio admin when portfolio organization is updated. @@ -257,10 +257,12 @@ def send_portfolio_organization_update_email(editor, portfolio): "emails/portfolio_org_update_notification_subject.txt", to_address=user.email, context={ + "requested_user": user, "portfolio": portfolio, "editor": editor, "portfolio_admin": user, "date": date.today(), + "updated_info": "Organization" }, ) except EmailSendingError: diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 5d33adf17..14e6234b8 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -844,7 +844,7 @@ class PortfolioOrganizationView(DetailView, FormMixin): user=request.user try: if not send_portfolio_organization_update_email( - editor=user, portfolio=self.request.session.get("portfolio") + editor=user, portfolio=self.request.session.get("portfolio"), updated_page="Organization" ): messages.warning(self.request, f"Could not send email notification to {user.email}.") return redirect(reverse("organization")) @@ -909,6 +909,31 @@ class PortfolioSeniorOfficialView(DetailView, FormMixin): form = self.get_form() return self.render_to_response(self.get_context_data(form=form)) + def post(self, request, *args, **kwargs): + """Handle POST requests to process form submission.""" + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + user=request.user + try: + if not send_portfolio_organization_update_email( + editor=user, portfolio=self.request.session.get("portfolio"), updated_page="Senior Official" + ): + messages.warning(self.request, f"Could not send email notification to {user.email}.") + return redirect(reverse("senior-official")) + except Exception as e: + messages.error( + request, + f"An unexpected error occurred: {str(e)}. If the issue persists, " + f"please contact {DefaultUserValues.HELP_EMAIL}.", + ) + logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True) + return None + messages.success(self.request, "The portfolio organization information has been updated.") + return redirect(reverse("senior-official")) + else: + return self.form_invalid(form) + @grant_access(HAS_PORTFOLIO_MEMBERS_ANY_PERM) class PortfolioMembersView(View): From d6d68c401d47f9151fe3b458e658e0767b956e3b Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 10 Mar 2025 18:52:14 -0400 Subject: [PATCH 047/285] initial ds data redesign --- .../assets/src/js/getgov/domain-dsdata.js | 27 - .../assets/src/js/getgov/form-dsdata.js | 472 ++++++++++++++++++ .../assets/src/js/getgov/form-helpers.js | 5 + .../assets/src/js/getgov/formset-forms.js | 36 +- src/registrar/assets/src/js/getgov/main.js | 9 +- src/registrar/forms/domain.py | 2 +- src/registrar/templates/domain_dsdata.html | 416 +++++++++++---- src/registrar/views/domain.py | 32 +- 8 files changed, 804 insertions(+), 195 deletions(-) delete mode 100644 src/registrar/assets/src/js/getgov/domain-dsdata.js create mode 100644 src/registrar/assets/src/js/getgov/form-dsdata.js diff --git a/src/registrar/assets/src/js/getgov/domain-dsdata.js b/src/registrar/assets/src/js/getgov/domain-dsdata.js deleted file mode 100644 index 14132d812..000000000 --- a/src/registrar/assets/src/js/getgov/domain-dsdata.js +++ /dev/null @@ -1,27 +0,0 @@ -import { submitForm } from './form-helpers.js'; - -export function initDomainDSData() { - document.addEventListener('DOMContentLoaded', function() { - let domain_dsdata_page = document.getElementById("domain-dsdata"); - if (domain_dsdata_page) { - const override_button = document.getElementById("disable-override-click-button"); - const cancel_button = document.getElementById("btn-cancel-click-button"); - const cancel_close_button = document.getElementById("btn-cancel-click-close-button"); - if (override_button) { - override_button.addEventListener("click", function () { - submitForm("disable-override-click-form"); - }); - } - if (cancel_button) { - cancel_button.addEventListener("click", function () { - submitForm("btn-cancel-click-form"); - }); - } - if (cancel_close_button) { - cancel_close_button.addEventListener("click", function () { - submitForm("btn-cancel-click-form"); - }); - } - } - }); -} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/form-dsdata.js b/src/registrar/assets/src/js/getgov/form-dsdata.js new file mode 100644 index 000000000..e9be4135e --- /dev/null +++ b/src/registrar/assets/src/js/getgov/form-dsdata.js @@ -0,0 +1,472 @@ +import { showElement, hideElement, scrollToElement } from './helpers'; +import { removeErrorsFromElement, removeFormErrors } from './form-helpers'; + +export class DSDataForm { + constructor() { + this.addDSDataButton = document.getElementById('dsdata-add-button'); + this.addDSDataForm = document.querySelector('.add-dsdata-form'); + this.formChanged = false; + this.callback = null; + + // Bind event handlers to maintain 'this' context + this.handleAddFormClick = this.handleAddFormClick.bind(this); + this.handleEditClick = this.handleEditClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); + this.handleDeleteKebabClick = this.handleDeleteKebabClick.bind(this); + this.handleCancelClick = this.handleCancelClick.bind(this); + this.handleCancelAddFormClick = this.handleCancelAddFormClick.bind(this); + } + + /** + * Initialize the DSDataForm by setting up display and event listeners. + */ + init() { + this.initializeDSDataFormDisplay(); + this.initializeEventListeners(); + } + + + /** + * Determines the initial display state of the DS dara form, + * handling validation errors and setting visibility of elements accordingly. + */ + initializeDSDataFormDisplay() { + + // This check indicates that there is an Add DS Data form + // and that form has errors in it. In this case, show the form, and indicate that the form has + // changed. + if (this.addDSDataForm && this.addDSDataForm.querySelector('.usa-input--error')) { + showElement(this.addDSDataForm); + this.formChanged = true; + } + + // handle display of table view errors + // if error exists in an edit-row, make that row show, and readonly row hide + const formTable = document.getElementById('dsdata-table') + if (formTable) { + const editRows = formTable.querySelectorAll('.edit-row'); + editRows.forEach(editRow => { + if (editRow.querySelector('.usa-input--error')) { + const readOnlyRow = editRow.previousElementSibling; + this.formChanged = true; + showElement(editRow); + hideElement(readOnlyRow); + } + }) + } + + } + + /** + * Attaches event listeners to relevant UI elements for interaction handling. + */ + initializeEventListeners() { + this.addDSDataButton.addEventListener('click', this.handleAddFormClick); + + const editButtons = document.querySelectorAll('.dsdata-edit'); + editButtons.forEach(editButton => { + editButton.addEventListener('click', this.handleEditClick); + }); + + const cancelButtons = document.querySelectorAll('.dsdata-cancel'); + cancelButtons.forEach(cancelButton => { + cancelButton.addEventListener('click', this.handleCancelClick); + }); + + const cancelAddFormButtons = document.querySelectorAll('.dsdata-cancel-add-form'); + cancelAddFormButtons.forEach(cancelAddFormButton => { + cancelAddFormButton.addEventListener('click', this.handleCancelAddFormClick); + }); + + const deleteButtons = document.querySelectorAll('.dsdata-delete'); + deleteButtons.forEach(deleteButton => { + deleteButton.addEventListener('click', this.handleDeleteClick); + }); + + const deleteKebabButtons = document.querySelectorAll('.dsdata-delete-kebab'); + deleteKebabButtons.forEach(deleteKebabButton => { + deleteKebabButton.addEventListener('click', this.handleDeleteKebabClick); + }); + + const textInputs = document.querySelectorAll("input[type='text']"); + textInputs.forEach(input => { + input.addEventListener("input", () => { + this.formChanged = true; + }); + }); + + // Set event listeners on the submit buttons for the modals. Event listeners + // should execute the callback function, which has its logic updated prior + // to modal display + const unsaved_changes_modal = document.getElementById('unsaved-changes-modal'); + if (unsaved_changes_modal) { + const submitButton = document.getElementById('unsaved-changes-click-button'); + const closeButton = unsaved_changes_modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + this.executeCallback(); + }); + } + const delete_modal = document.getElementById('delete-modal'); + if (delete_modal) { + const submitButton = document.getElementById('delete-click-button'); + const closeButton = delete_modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + this.executeCallback(); + }); + } + const disable_dnssec_modal = document.getElementById('disable-dnssec-modal'); + if (disable_dnssec_modal) { + const submitButton = document.getElementById('disable-dnssec-click-button'); + const closeButton = disable_dnssec_modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + this.executeCallback(); + }); + } + + } + + /** + * Executes a stored callback function if defined, otherwise logs a warning. + */ + executeCallback() { + if (this.callback) { + this.callback(); + this.callback = null; + } else { + console.warn("No callback function set."); + } + } + + /** + * Handles clicking the 'Add DS data' button, showing the form if needed. + * @param {Event} event - Click event + */ + handleAddFormClick(event) { + this.callback = () => { + console.log("handleAddFormClick callback"); + // Check if any other edit row is currently visible and hide it + document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => { + this.resetEditRowAndFormAndCollapseEditRow(openEditRow); + }); + if (this.addDSDataForm) { + // Check if this.addDSDataForm is visible (i.e., does not have 'display-none') + if (!this.addDSDataForm.classList.contains('display-none')) { + this.resetAddDSDataForm(); + } + // show add ds data form + showElement(this.addDSDataForm); + } + }; + if (this.formChanged) { + //------- Show the unsaved changes confirmation modal + let modalTrigger = document.querySelector("#unsaved_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.executeCallback(); + } + } + + /** + * Handles clicking an 'Edit' button on a readonly row, which hides the readonly row + * and displays the edit row, after performing some checks and possibly displaying modal. + * @param {Event} event - Click event + */ + handleEditClick(event) { + let editButton = event.target; + let readOnlyRow = editButton.closest('tr'); // Find the closest row + let editRow = readOnlyRow.nextElementSibling; // Get the next row + if (!editRow || !readOnlyRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + this.callback = () => { + // Check if any other edit row is currently visible and hide it + document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => { + this.resetEditRowAndFormAndCollapseEditRow(openEditRow); + }); + // Check if this.addDSDataForm is visible (i.e., does not have 'display-none') + if (this.addDSDataForm && !this.addDSDataForm.classList.contains('display-none')) { + this.resetAddDSDataForm(); + } + // hide and show rows as appropriate + hideElement(readOnlyRow); + showElement(editRow); + }; + if (this.formChanged) { + //------- Show the unsaved changes confirmation modal + let modalTrigger = document.querySelector("#unsaved_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.executeCallback(); + } + } + + /** + * Handles clicking a 'Delete' button on an edit row, which hattempts to delete the DS record + * after displaying modal. + * @param {Event} event - Click event + */ + handleDeleteClick(event) { + let deleteButton = event.target; + let editRow = deleteButton.closest('tr'); + if (!editRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + this.deleteRow(editRow); + } + + /** + * Handles clicking a 'Delete' button on a readonly row in a kebab, which attempts to delete the DS record + * after displaying modal. + * @param {Event} event - Click event + */ + handleDeleteKebabClick(event) { + let deleteKebabButton = event.target; + let accordionDiv = deleteKebabButton.closest('div'); + // hide the accordion + accordionDiv.hidden = true; + let readOnlyRow = deleteKebabButton.closest('tr'); // Find the closest row + let editRow = readOnlyRow.nextElementSibling; // Get the next row + if (!editRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + this.deleteRow(editRow); + } + + /** + * Deletes a DS record row. If there is only one DS record, prompt the user + * that they will be disabling DNSSEC. Otherwise, prompt with delete confiration. + * If deletion proceeds, the input fields are cleared, and the form is submitted. + * @param {HTMLElement} editRow - The row corresponding to the DS record being deleted. + */ + deleteRow(editRow) { + // update the callback method + this.callback = () => { + hideElement(editRow); + let deleteInput = editRow.querySelector("input[name$='-DELETE']"); + if (deleteInput) { + deleteInput.checked = true; + } + document.querySelector("form").submit(); + }; + // Check if at least 2 DS data records exist before the delete row action is taken + const thirdDSData = document.getElementById('id_form-2-key_tag') + if (thirdDSData) { + let modalTrigger = document.querySelector('#delete_trigger'); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + let modalTrigger = document.querySelector('#disable_dnssec_trigger'); + if (modalTrigger) { + modalTrigger.click(); + } + } + } + + /** + * Handles the click event on the "Cancel" button in the add DS data form. + * Resets the form fields and hides the add form section. + * @param {Event} event - Click event + */ + handleCancelAddFormClick(event) { + this.resetAddDSDataForm(); + } + + /** + * Handles the click event for the cancel button within the table form. + * + * This method identifies the edit row containing the cancel button and resets + * it to its initial state, restoring the corresponding read-only row. + * + * @param {Event} event - the click event triggered by the cancel button + */ + handleCancelClick(event) { + // get the cancel button that was clicked + let cancelButton = event.target; + // find the closest table row that contains the cancel button + let editRow = cancelButton.closest('tr'); + if (editRow) { + this.resetEditRowAndFormAndCollapseEditRow(editRow); + } else { + console.warn("Expected DOM element but did not find it"); + } + } + + /** + * Resets the edit row, restores its original values, removes validation errors, + * and collapses the edit row while making the readonly row visible again. + * @param {HTMLElement} editRow - The row that is being reset and collapsed. + */ + resetEditRowAndFormAndCollapseEditRow(editRow) { + let readOnlyRow = editRow.previousElementSibling; // Get the next row + if (!editRow || !readOnlyRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + // reset the values set in editRow + this.resetInputValuesInElement(editRow); + // copy values from editRow to readOnlyRow + this.copyEditRowToReadonlyRow(editRow, readOnlyRow); + // remove errors from the editRow + removeErrorsFromElement(editRow); + // remove errors from the entire form + removeFormErrors(); + // reset formChanged + this.resetFormChanged(); + // hide and show rows as appropriate + hideElement(editRow); + showElement(readOnlyRow); + } + + /** + * Resets the 'Add DS data' form by clearing its input fields, removing errors, + * and hiding the form to return it to its initial state. + */ + resetAddDSDataForm() { + if (this.addDSDataForm) { + // reset the values set in addDSDataForm + this.resetInputValuesInElement(this.addDSDataForm); + // remove errors from the addDSDataForm + removeErrorsFromElement(this.addDSDataForm); + // remove errors from the entire form + removeFormErrors(); + // reset formChanged + this.resetFormChanged(); + // hide the addDSDataForm + hideElement(this.addDSDataForm); + } + } + + /** + * Resets all text input fields within the specified DOM element to their initial values. + * Triggers an 'input' event to ensure any event listeners update accordingly. + * @param {HTMLElement} domElement - The parent element containing text input fields to be reset. + */ + resetInputValuesInElement(domElement) { + const inputEvent = new Event('input'); + const changeEvent = new Event('change'); + // Reset text and number inputs + let inputs = domElement.querySelectorAll("input[type='text'], input[type='number']"); + inputs.forEach(input => { + // Reset input value to its initial stored value + input.value = input.dataset.initialValue; + // Dispatch input event to update any event-driven changes + input.dispatchEvent(inputEvent); + }); + // Reset select elements + let selects = domElement.querySelectorAll("select"); + selects.forEach(select => { + // Reset select value to its initial stored value + select.value = select.dataset.initialValue; + // Dispatch change event to update any event-driven changes + select.dispatchEvent(changeEvent); + }); + } + + /** + * Copies values from the editable row's text inputs into the corresponding + * readonly row cells, formatting them appropriately. + * @param {HTMLElement} editRow - The row containing editable input fields. + * @param {HTMLElement} readOnlyRow - The row where values will be displayed in a non-editable format. + */ + copyEditRowToReadonlyRow(editRow, readOnlyRow) { + let numberInput = editRow.querySelector("input[type='number']"); + let selects = editRow.querySelectorAll("select"); + let textInput = editRow.querySelector("input[type='text']"); + let tds = readOnlyRow.querySelectorAll("td"); + let updatedText = ''; + + // Copy the number input value + if (numberInput) { + tds[0].innerText = numberInput.value || ""; + } + + // Copy select values (showing the selected label instead of value) + selects.forEach((select, index) => { + let selectedOption = select.options[select.selectedIndex]; + if (tds[index + 1]) { + tds[index + 1].innerText = selectedOption ? selectedOption.text : ""; + } + }); + + // Copy the text input value + if (textInput) { + tds[3].innerText = textInput.value || ""; + } + } + + /** + * Resets the form change state. + * This method marks the form as unchanged by setting `formChanged` to false. + * It is useful for tracking whether a user has modified any form fields. + */ + resetFormChanged() { + this.formChanged = false; + } + + /** + * Removes all existing alert messages from the main content area. + * This ensures that only the latest alert is displayed to the user. + */ + resetAlerts() { + const mainContent = document.getElementById("main-content"); + if (mainContent) { + // Remove all alert elements within the main content area + mainContent.querySelectorAll(".usa-alert:not(.usa-alert--do-not-reset)").forEach(alert => alert.remove()); + } else { + console.warn("Expecting main-content DOM element"); + } + } + + /** + * Displays an alert message at the top of the main content area. + * It first removes any existing alerts before adding a new one to ensure only the latest alert is visible. + * @param {string} level - The alert level (e.g., 'error', 'success', 'warning', 'info'). + * @param {string} message - The message to display inside the alert. + */ + addAlert(level, message) { + this.resetAlerts(); // Remove any existing alerts before adding a new one + + const mainContent = document.getElementById("main-content"); + if (!mainContent) return; + + // Create a new alert div with appropriate classes based on alert level + const alertDiv = document.createElement("div"); + alertDiv.className = `usa-alert usa-alert--${level} usa-alert--slim margin-bottom-2`; + alertDiv.setAttribute("role", "alert"); // Add the role attribute + + // Create the alert body to hold the message text + const alertBody = document.createElement("div"); + alertBody.className = "usa-alert__body"; + alertBody.textContent = message; + + // Append the alert body to the alert div and insert it at the top of the main content area + alertDiv.appendChild(alertBody); + mainContent.insertBefore(alertDiv, mainContent.firstChild); + + // Scroll the page to make the alert visible to the user + scrollToElement("class", "usa-alert__body"); + } +} + +/** + * Initializes the DSDataForm when the DOM is fully loaded. + */ +export function initFormDSData() { + document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('dsdata-add-button')) { + const dsDataForm = new DSDataForm(); + dsDataForm.init(); + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/form-helpers.js b/src/registrar/assets/src/js/getgov/form-helpers.js index fabfab98a..7a9b0c38f 100644 --- a/src/registrar/assets/src/js/getgov/form-helpers.js +++ b/src/registrar/assets/src/js/getgov/form-helpers.js @@ -38,6 +38,11 @@ export function removeErrorsFromElement(domElement) { domElement.querySelectorAll("input.usa-input--error").forEach(input => { input.classList.remove("usa-input--error"); }); + + // Remove the 'usa-input--error' class from all select elements + domElement.querySelectorAll("select.usa-input--error").forEach(select => { + select.classList.remove("usa-input--error"); + }); } /** diff --git a/src/registrar/assets/src/js/getgov/formset-forms.js b/src/registrar/assets/src/js/getgov/formset-forms.js index b4a40e5cf..1d2724b7f 100644 --- a/src/registrar/assets/src/js/getgov/formset-forms.js +++ b/src/registrar/assets/src/js/getgov/formset-forms.js @@ -84,7 +84,7 @@ function markForm(e, formLabel){ } /** - * Prepare the namerservers, DS data and Other Contacts formsets' delete button + * Prepare the Other Contacts formsets' delete button * for the last added form. We call this from the Add function * */ @@ -108,7 +108,7 @@ function prepareNewDeleteButton(btn, formLabel) { } /** - * Prepare the namerservers, DS data and Other Contacts formsets' delete buttons + * Prepare the Other Contacts formsets' delete buttons * We will call this on the forms init * */ @@ -172,16 +172,11 @@ export function initFormsetsForms() { let cloneIndex = 0; let formLabel = ''; let isOtherContactsForm = document.querySelector(".other-contacts-form"); - let isDsDataForm = document.querySelector(".ds-data-form"); let isDotgovDomain = document.querySelector(".dotgov-domain-form"); - if( !(isOtherContactsForm || isDotgovDomain || isDsDataForm) ){ + if( !(isOtherContactsForm || isDotgovDomain) ){ return } - // DNSSEC: DS Data - if (isDsDataForm) { - formLabel = "DS data record"; - // The Other Contacts form - } else if (isOtherContactsForm) { + if (isOtherContactsForm) { formLabel = "Organization contact"; container = document.querySelector("#other-employees"); formIdentifier = "other_contacts" @@ -287,26 +282,3 @@ export function initFormsetsForms() { prepareNewDeleteButton(newDeleteButton, formLabel); } } - -export function triggerModalOnDsDataForm() { - let saveButon = document.querySelector("#save-ds-data"); - - // The view context will cause a hitherto hidden modal trigger to - // show up. On save, we'll test for that modal trigger appearing. We'll - // run that test once every 100 ms for 5 secs, which should balance performance - // while accounting for network or lag issues. - if (saveButon) { - let i = 0; - var tryToTriggerModal = setInterval(function() { - i++; - if (i > 100) { - clearInterval(tryToTriggerModal); - } - let modalTrigger = document.querySelector("#ds-toggle-dnssec-alert"); - if (modalTrigger) { - modalTrigger.click() - clearInterval(tryToTriggerModal); - } - }, 50); - } -} diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 0529d3614..03d970d7e 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -1,7 +1,8 @@ import { hookupYesNoListener } from './radios.js'; import { initDomainValidators } from './domain-validators.js'; -import { initFormsetsForms, triggerModalOnDsDataForm } from './formset-forms.js'; -import { initFormNameservers } from './form-nameservers' +import { initFormsetsForms } from './formset-forms.js'; +import { initFormNameservers } from './form-nameservers'; +import { initFormDSData } from './form-dsdata.js'; import { initializeUrbanizationToggle } from './urbanization.js'; import { userProfileListener, finishUserSetupListener } from './user-profile.js'; import { handleRequestingEntityFieldset } from './requesting-entity.js'; @@ -13,7 +14,6 @@ import { initEditMemberDomainsTable } from './table-edit-member-domains.js'; import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js'; import { initDomainRequestForm } from './domain-request-form.js'; import { initDomainManagersPage } from './domain-managers.js'; -import { initDomainDSData } from './domain-dsdata.js'; import { initDomainDNSSEC } from './domain-dnssec.js'; import { initFormErrorHandling } from './form-errors.js'; import { initButtonLinks } from '../getgov-admin/button-utils.js'; @@ -21,8 +21,8 @@ import { initButtonLinks } from '../getgov-admin/button-utils.js'; initDomainValidators(); initFormsetsForms(); -triggerModalOnDsDataForm(); initFormNameservers(); +initFormDSData(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); @@ -42,7 +42,6 @@ initEditMemberDomainsTable(); initDomainRequestForm(); initDomainManagersPage(); -initDomainDSData(); initDomainDNSSEC(); initFormErrorHandling(); diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 538edc7ab..bb87ad119 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -688,7 +688,7 @@ class DomainDsdataForm(forms.Form): DomainDsdataFormset = formset_factory( DomainDsdataForm, - extra=0, + extra=1, can_delete=True, ) diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index 95e8e3d5f..14b8ad519 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -34,122 +34,334 @@ {% endif %} {% endblock breadcrumb %} - {% if domain.dnssecdata is None %} -
-
- You have no DS data added. Enable DNSSEC by adding DS data. +
+
+

DS data

+
+ +
+
+ +

In order to enable DNSSEC, you must first configure it with your DNS provider.

+ +

Click "Add DS data" and enter the values given by your DNS provider for DS (Delegation Signer) data.

+ + {% comment %} + This template supports the rendering of three different DS data forms, conditionally displayed: + 1 - Add DS Data form (rendered when there are no existing DS data records defined for the domain) + 2 - DS Data table (rendered when the domain has existing DS data, which can be viewed and edited) + 3 - Add DS Data form (rendered above the DS Data table to add a single additional DS Data record) + {% endcomment %} + + {% if formset.initial and formset.forms.0.initial %} + + {% comment %}This section renders both the DS Data table and the Add DS Data form {% endcomment %} + + {% include "includes/required_fields.html" %} + +
+ {% csrf_token %} + {{ formset.management_form }} + + {% for form in formset %} + {% if forloop.last %} + + {% comment %}This section renders the Add DS data form.{% endcomment %} + + {% endif %} + {% endfor %} + + + + + + + + + + + + + + {% for form in formset %} + {% if not forloop.last or form.initial %} + + {% comment %} + This section renders table rows for each existing DS data records. Two rows are rendered, a readonly row + and an edit row. Only one of which is displayed at a time. + {% endcomment %} + + + + + + + + + + + + + + + + + + {% endif %} + {% endfor %} + +
Key tagAlgorithmDigest typeDigestAction
{{ form.key_tag.value }} + {% for value, label in form.algorithm.field.choices %} + {% if value|stringformat:"s" == form.algorithm.value|stringformat:"s" %} + {{ label }} + {% endif %} + {% endfor %} + + {% for value, label in form.digest_type.field.choices %} + {% if value|stringformat:"s" == form.digest_type.value|stringformat:"s" %} + {{ label }} + {% endif %} + {% endfor %} + {{ form.digest.value }} +
+ + + + + Delete + + +
+
+ +
+ +
+
+
+
+ + {% else %} + + {% comment %} + This section renders Add DS Data form which renders when there are no existing + DS records defined on the domain. + {% endcomment %} + + {% endif %} -

DS data

- -

In order to enable DNSSEC, you must first configure it with your DNS hosting service.

- -

Enter the values given by your DNS provider for DS data.

- - {% include "includes/required_fields.html" %} - -
- {% csrf_token %} - {{ formset.management_form }} - - {% for form in formset %} -
- - DS data record {{forloop.counter}} - -

DS data record {{forloop.counter}}

- -
-
- {% with attr_required=True add_group_class="usa-form-group--unstyled-error" %} - {% input_with_errors form.key_tag %} - {% endwith %} -
-
- {% with attr_required=True add_group_class="usa-form-group--unstyled-error" %} - {% input_with_errors form.algorithm %} - {% endwith %} -
-
- {% with attr_required=True add_group_class="usa-form-group--unstyled-error" %} - {% input_with_errors form.digest_type %} - {% endwith %} -
-
- -
-
- {% with attr_required=True add_group_class="usa-form-group--unstyled-error" %} - {% input_with_errors form.digest %} - {% endwith %} -
-
- -
-
- -
-
- -
- {% endfor %} - - - - - - -
- - {% if trigger_modal %}
+
+ {% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="You have unsaved changes that will be lost." modal_button_id="unsaved-changes-click-button" modal_button_text="Continue without saving" cancel_button_text="Go back" %} +
+ + +
+ {% include 'includes/modal.html' with modal_heading="Are you sure you want to delete this DS data record?" modal_description="This action cannot be undone." modal_button_id="delete-click-button" modal_button_text="Yes, delete" modal_button_class="usa-button--secondary" %} +
+ + - {% endif %} - {# Use data-force-action to take esc out of the equation and pass cancel_button_resets_ds_form to effectuate a reset in the view #}
- {% include 'includes/modal.html' with cancel_button_resets_ds_form=True modal_heading="Warning: You are about to remove all DS records on your domain." modal_description="To fully disable DNSSEC: In addition to removing your DS records here, you’ll need to delete the DS records at your DNS host. To avoid causing your domain to appear offline, you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button_id="disable-override-click-button" modal_button_text="Remove all DS data" modal_button_class="usa-button--secondary" %} + {% include 'includes/modal.html' with modal_heading="Warning: You are about to remove all DS records on your domain." modal_description="To fully disable DNSSEC: In addition to removing your DS records here, you’ll need to delete the DS records at your DNS host. To avoid causing your domain to appear offline, you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button_id="disable-dnssec-click-button" modal_button_text="Remove all DS data" modal_button_class="usa-button--secondary" %}
-
- {% csrf_token %} - -
-
- {% csrf_token %} - -
{% endblock %} {# domain_content #} diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 3a083393e..4f44f0b01 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -1060,10 +1060,6 @@ class DomainDsDataView(DomainFormBaseView): for record in dnssecdata.dsData ) - # Ensure at least 1 record, filled or empty - while len(initial_data) == 0: - initial_data.append({}) - return initial_data def get_success_url(self): @@ -1082,29 +1078,8 @@ class DomainDsDataView(DomainFormBaseView): """Formset submission posts to this view.""" self._get_domain(request) formset = self.get_form() - override = False - # This is called by the form cancel button, - # and also by the modal's X and cancel buttons - if "btn-cancel-click" in request.POST: - url = self.get_success_url() - return HttpResponseRedirect(url) - - # This is called by the Disable DNSSEC modal to override - if "disable-override-click" in request.POST: - override = True - - # This is called when all DNSSEC data has been deleted and the - # Save button is pressed - if len(formset) == 0 and formset.initial != [{}] and override is False: - # trigger the modal - # get context data from super() rather than self - # to preserve the context["form"] - context = super().get_context_data(form=formset) - context["trigger_modal"] = True - return self.render_to_response(context) - - if formset.is_valid() or override: + if formset.is_valid(): return self.form_valid(formset) else: return self.form_invalid(formset) @@ -1116,9 +1091,10 @@ class DomainDsDataView(DomainFormBaseView): dnssecdata = extensions.DNSSECExtension() for form in formset: + if form.cleaned_data.get("DELETE"): # Check if form is marked for deletion + continue # Skip processing this form + try: - # if 'delete' not in form.cleaned_data - # or form.cleaned_data['delete'] == False: dsrecord = { "keyTag": form.cleaned_data["key_tag"], "alg": int(form.cleaned_data["algorithm"]), From 0d44b3426024464c93f4997a9a96ea6638a576de Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 10 Mar 2025 19:02:03 -0400 Subject: [PATCH 048/285] cleaned up modal code --- src/registrar/templates/includes/modal.html | 49 +++++---------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/src/registrar/templates/includes/modal.html b/src/registrar/templates/includes/modal.html index af49d2b6c..09e2909c0 100644 --- a/src/registrar/templates/includes/modal.html +++ b/src/registrar/templates/includes/modal.html @@ -58,18 +58,7 @@ {% endif %}
  • - {% comment %} The cancel button the DS form actually triggers a context change in the view, - in addition to being a close modal hook {% endcomment %} - {% if cancel_button_resets_ds_form %} - - {% elif not cancel_button_only %} + {% if not cancel_button_only %}
  • - {% comment %} The cancel button the DS form actually triggers a context change in the view, - in addition to being a close modal hook {% endcomment %} - {% if cancel_button_resets_ds_form %} - - {% else %} - - {% endif %} + From b1e6730d87cf43441cadac3a734521d59b50cd0d Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 10 Mar 2025 19:30:10 -0400 Subject: [PATCH 049/285] added cancel modal --- .../assets/src/js/getgov/form-dsdata.js | 40 ++++++++++++++++--- src/registrar/templates/domain_dsdata.html | 16 ++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/form-dsdata.js b/src/registrar/assets/src/js/getgov/form-dsdata.js index e9be4135e..a9219ae94 100644 --- a/src/registrar/assets/src/js/getgov/form-dsdata.js +++ b/src/registrar/assets/src/js/getgov/form-dsdata.js @@ -107,6 +107,15 @@ export class DSDataForm { this.executeCallback(); }); } + const cancel_changes_modal = document.getElementById('cancel-changes-modal'); + if (cancel_changes_modal) { + const submitButton = document.getElementById('cancel-changes-click-button'); + const closeButton = cancel_changes_modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + this.executeCallback(); + }); + } const delete_modal = document.getElementById('delete-modal'); if (delete_modal) { const submitButton = document.getElementById('delete-click-button'); @@ -146,7 +155,6 @@ export class DSDataForm { */ handleAddFormClick(event) { this.callback = () => { - console.log("handleAddFormClick callback"); // Check if any other edit row is currently visible and hide it document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => { this.resetEditRowAndFormAndCollapseEditRow(openEditRow); @@ -279,7 +287,18 @@ export class DSDataForm { * @param {Event} event - Click event */ handleCancelAddFormClick(event) { - this.resetAddDSDataForm(); + this.callback = () => { + this.resetAddDSDataForm(); + } + if (this.formChanged) { + // Show the cancel changes confirmation modal + let modalTrigger = document.querySelector("#cancel_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.executeCallback(); + } } /** @@ -295,10 +314,21 @@ export class DSDataForm { let cancelButton = event.target; // find the closest table row that contains the cancel button let editRow = cancelButton.closest('tr'); - if (editRow) { - this.resetEditRowAndFormAndCollapseEditRow(editRow); + this.callback = () => { + if (editRow) { + this.resetEditRowAndFormAndCollapseEditRow(editRow); + } else { + console.warn("Expected DOM element but did not find it"); + } + } + if (this.formChanged) { + // Show the cancel changes confirmation modal + let modalTrigger = document.querySelector("#cancel_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } } else { - console.warn("Expected DOM element but did not find it"); + this.executeCallback(); } } diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index 14b8ad519..42c94c9f5 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -331,6 +331,22 @@ {% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="You have unsaved changes that will be lost." modal_button_id="unsaved-changes-click-button" modal_button_text="Continue without saving" cancel_button_text="Go back" %} + +
    + {% include 'includes/modal.html' with modal_heading="Are you sure you want to cancel your changes?" modal_description="This action cannot be undone." modal_button_id="cancel-changes-click-button" modal_button_text="Yes, cancel" cancel_button_text="Go back" %} +
    + Date: Mon, 10 Mar 2025 17:19:56 -0700 Subject: [PATCH 050/285] 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 ca06625c14453d4b12e4849e491bff2ded89b509 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 11 Mar 2025 08:46:51 -0600 Subject: [PATCH 051/285] Test disclose settings --- src/registrar/models/domain.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 9a54adb0d..fede6c96d 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1676,15 +1676,17 @@ class Domain(TimeStampedModel, DomainHelper): """creates a disclose object that can be added to a contact Create using .disclose= on the command before sending. if item is security email then make sure email is visible""" - is_security = contact.contact_type == contact.ContactTypeChoices.SECURITY DF = epp.DiscloseField - fields = {DF.EMAIL} - - hidden_security_emails = [email for email in DefaultEmail] - disclose = is_security and contact.email not in hidden_security_emails - # Delete after testing on other devices + fields = {} + disclose = False + match contact.contact_type: + case contact.ContactTypeChoices.SECURITY: + fields = {DF.EMAIL} + disclose = True + case contact.ContactTypeChoices.ADMINISTRATIVE: + fields = {DF.EMAIL, DF.VOICE, DF.ADDR} + disclose = True logger.info("Updated domain contact %s to disclose: %s", contact.email, disclose) - # Will only disclose DF.EMAIL if its not the default return epp.Disclose( flag=disclose, fields=fields, From 705f1d6614c0d54077bbc86a965c773395096813 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 11 Mar 2025 10:58:50 -0400 Subject: [PATCH 052/285] added cancel modal to nameservers page --- .../assets/src/js/getgov/form-nameservers.js | 39 +++++++++++++++++-- .../templates/domain_nameservers.html | 16 ++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/form-nameservers.js b/src/registrar/assets/src/js/getgov/form-nameservers.js index 57b868d70..75a35f35b 100644 --- a/src/registrar/assets/src/js/getgov/form-nameservers.js +++ b/src/registrar/assets/src/js/getgov/form-nameservers.js @@ -176,6 +176,15 @@ export class NameserverForm { this.executeCallback(); }); } + const cancel_changes_modal = document.getElementById('cancel-changes-modal'); + if (cancel_changes_modal) { + const submitButton = document.getElementById('cancel-changes-click-button'); + const closeButton = cancel_changes_modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + this.executeCallback(); + }); + } const delete_modal = document.getElementById('delete-modal'); if (delete_modal) { const submitButton = document.getElementById('delete-click-button'); @@ -338,7 +347,18 @@ export class NameserverForm { * @param {Event} event - Click event */ handleCancelAddFormClick(event) { - this.resetAddNameserversForm(); + this.callback = () => { + this.resetAddNameserversForm(); + } + if (this.formChanged) { + // Show the cancel changes confirmation modal + let modalTrigger = document.querySelector("#cancel_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.executeCallback(); + } } /** @@ -354,10 +374,21 @@ export class NameserverForm { let cancelButton = event.target; // find the closest table row that contains the cancel button let editRow = cancelButton.closest('tr'); - if (editRow) { - this.resetEditRowAndFormAndCollapseEditRow(editRow); + this.callback = () => { + if (editRow) { + this.resetEditRowAndFormAndCollapseEditRow(editRow); + } else { + console.warn("Expected DOM element but did not find it"); + } + } + if (this.formChanged) { + // Show the cancel changes confirmation modal + let modalTrigger = document.querySelector("#cancel_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } } else { - console.warn("Expected DOM element but did not find it"); + this.executeCallback(); } } diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html index bd8216d05..b6314ea5f 100644 --- a/src/registrar/templates/domain_nameservers.html +++ b/src/registrar/templates/domain_nameservers.html @@ -309,6 +309,22 @@ > {% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="You have unsaved changes that will be lost." modal_button_id="unsaved-changes-click-button" modal_button_text="Continue without saving" cancel_button_text="Go back" %} + + +
    + {% include 'includes/modal.html' with modal_heading="Are you sure you want to cancel your changes?" modal_description="This action cannot be undone." modal_button_id="cancel-changes-click-button" modal_button_text="Yes, cancel" cancel_button_text="Go back" %} +
    Date: Tue, 11 Mar 2025 12:00:16 -0400 Subject: [PATCH 053/285] button click interaction and helper text in form --- .../assets/src/js/getgov/form-dsdata.js | 5 +++ src/registrar/templates/domain_dsdata.html | 34 +++++++++++++------ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/form-dsdata.js b/src/registrar/assets/src/js/getgov/form-dsdata.js index a9219ae94..a8c022056 100644 --- a/src/registrar/assets/src/js/getgov/form-dsdata.js +++ b/src/registrar/assets/src/js/getgov/form-dsdata.js @@ -166,6 +166,11 @@ export class DSDataForm { } // show add ds data form showElement(this.addDSDataForm); + // focus on key tag in the form + let keyTagInput = this.addDSDataForm.querySelector('input[name$="-key_tag"]'); + if (keyTagInput) { + keyTagInput.focus(); + } } }; if (this.formChanged) { diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index 42c94c9f5..e419a03b4 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -76,8 +76,10 @@
    - {% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %} - {% input_with_errors form.key_tag %} + {% with sublabel_text="Numbers (0-9) only." %} + {% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %} + {% input_with_errors form.key_tag %} + {% endwith %} {% endwith %}
    @@ -94,8 +96,10 @@
    - {% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %} - {% input_with_errors form.digest %} + {% with sublabel_text="Numbers (0-9) and letters (a-f) only. SHA-1: 40 chars, SHA-256: 64 chars." %} + {% with attr_required=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error" %} + {% input_with_errors form.digest %} + {% endwith %} {% endwith %}
    @@ -197,7 +201,7 @@ type="button" class="usa-button usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary dsdata-delete-kebab" name="btn-delete-kebab-click" - aria-label="Delete the DS record from the registry" + aria-label="Delete DS record {{ forloop.counter }} from the registry" > Delete @@ -209,9 +213,11 @@
    + {% else %} diff --git a/src/registrar/templates/domain_request_form.html b/src/registrar/templates/domain_request_form.html index 2142372e8..b9844aa06 100644 --- a/src/registrar/templates/domain_request_form.html +++ b/src/registrar/templates/domain_request_form.html @@ -5,10 +5,10 @@ {% block content %}
    -
    +
    {% include 'domain_request_sidebar.html' %}
    -
    +
    {% if steps.current == steps.first %} From 0bc6595a17321739e5748d6e828a904ff8b1b216 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 11 Mar 2025 15:16:48 -0700 Subject: [PATCH 067/285] 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 2d21129a870762765d07c619c606e69ce5ece9c4 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Tue, 11 Mar 2025 16:32:30 -0600 Subject: [PATCH 068/285] ellipsis --- src/registrar/templates/domain_dsdata.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index f6574600c..0c5e78859 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -160,7 +160,7 @@ {% endif %} {% endfor %} - {{ form.digest.value }} + {{ form.digest.value }}