From 9777d27340f5729052b55f0c156e8465c053a846 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Fri, 25 Oct 2024 10:44:38 -0400 Subject: [PATCH 01/56] adjusted test --- src/registrar/tests/test_views_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 17e6bcbe6..651da90a1 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -491,7 +491,7 @@ class DomainRequestTests(TestWithUser, WebTest): # Review page contains all the previously entered data # Let's make sure the long org name is displayed self.assertContains(review_page, "Federal") - self.assertContains(review_page, "Executive") + self.assertContains(review_page, "executive") self.assertContains(review_page, "Testorg") self.assertContains(review_page, "address 1") self.assertContains(review_page, "address 2") From 963edc1c05d991767f990184935609d5c883a104 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Thu, 31 Oct 2024 11:49:18 -0400 Subject: [PATCH 02/56] changes --- src/registrar/admin.py | 2 ++ src/registrar/models/domain_invitation.py | 7 ++++ src/registrar/views/domain.py | 36 +++++++++++++++---- src/registrar/views/utility/__init__.py | 1 + .../views/utility/permission_views.py | 5 ++- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0b96b4c48..7f26e9ab6 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1354,7 +1354,9 @@ class DomainInvitationAdmin(ListHeaderAdmin): class Meta: model = models.DomainInvitation fields = "__all__" + + print(DomainInvitation.objects.all()) _meta = Meta() # Columns diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index c9cbc8b39..bed1ef88b 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -26,6 +26,7 @@ class DomainInvitation(TimeStampedModel): class DomainInvitationStatus(models.TextChoices): INVITED = "invited", "Invited" RETRIEVED = "retrieved", "Retrieved" + CANCELED = "canceled", "Canceled" email = models.EmailField( null=False, @@ -73,3 +74,9 @@ class DomainInvitation(TimeStampedModel): # something strange happened and this role already existed when # the invitation was retrieved. Log that this occurred. logger.warn("Invitation %s was retrieved for a role that already exists.", self) + + @transition(field=status, source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED) + def cancel_invitation(self): + logger.info(f"Invitation for {self.domain} has been cancelled.") + + diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 6e85c9ffb..ab368b614 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -62,7 +62,7 @@ from epplibwrapper import ( ) from ..utility.email import send_templated_email, EmailSendingError -from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView +from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView, DomainInvitationUpdateView logger = logging.getLogger(__name__) @@ -1051,11 +1051,33 @@ class DomainAddUserView(DomainFormBaseView): return redirect(self.get_success_url()) -# The order of the superclasses matters here. BaseDeleteView has a bug where the -# "form_valid" function does not call super, so it cannot use SuccessMessageMixin. -# The workaround is to use SuccessMessageMixin first. -class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView): - object: DomainInvitation # workaround for type mismatch in DeleteView +# # The order of the superclasses matters here. BaseDeleteView has a bug where the +# # "form_valid" function does not call super, so it cannot use SuccessMessageMixin. +# # The workaround is to use SuccessMessageMixin first. +# class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView): +# object: DomainInvitation # workaround for type mismatch in DeleteView + +# def post(self, request, *args, **kwargs): +# """Override post method in order to error in the case when the +# domain invitation status is RETRIEVED""" +# self.object = self.get_object() +# form = self.get_form() +# if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: +# return self.form_valid(form) +# else: +# # Produce an error message if the domain invatation status is RETRIEVED +# messages.error(request, f"Invitation to {self.object.email} has already been retrieved.") +# return HttpResponseRedirect(self.get_success_url()) + +# def get_success_url(self): +# return reverse("domain-users", kwargs={"pk": self.object.domain.id}) + +# def get_success_message(self, cleaned_data): +# return f"Canceled invitation to {self.object.email}." + + +class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationUpdateView): + object: DomainInvitation def post(self, request, *args, **kwargs): """Override post method in order to error in the case when the @@ -1063,6 +1085,7 @@ class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermission self.object = self.get_object() form = self.get_form() if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: + self.object.cancel_invitation() return self.form_valid(form) else: # Produce an error message if the domain invatation status is RETRIEVED @@ -1076,6 +1099,7 @@ class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermission return f"Canceled invitation to {self.object.email}." + class DomainDeleteUserView(UserDomainRolePermissionDeleteView): """Inside of a domain's user management, a form for deleting users.""" diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index bb135b3d3..d97ec035d 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -9,5 +9,6 @@ from .permission_views import ( DomainRequestWizardPermissionView, PortfolioMembersPermission, DomainRequestPortfolioViewonlyView, + DomainInvitationUpdateView ) from .api_views import get_senior_official_from_federal_agency_json diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 1b6db24de..23beb657f 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -2,7 +2,7 @@ import abc # abstract base class -from django.views.generic import DetailView, DeleteView, TemplateView +from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio from registrar.models.user import User from registrar.models.user_domain_role import UserDomainRole @@ -168,6 +168,9 @@ class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteVie model = DomainInvitation object: DomainInvitation # workaround for type mismatch in DeleteView +class DomainInvitationUpdateView(DomainInvitationPermission, UpdateView, abc.ABC): + model = DomainInvitation + object: DomainInvitation class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC): """Abstract view for deleting a DomainRequest.""" From cb771f62083c7133ab5e04101390e4e9256b5d0b Mon Sep 17 00:00:00 2001 From: asaki222 Date: Thu, 31 Oct 2024 14:55:17 -0400 Subject: [PATCH 03/56] changes --- src/registrar/views/__init__.py | 1 + src/registrar/views/domain.py | 34 ++++++++++++++++----------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index cd3f74fc8..6b3bcac7c 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -12,6 +12,7 @@ from .domain import ( DomainUsersView, DomainAddUserView, DomainInvitationDeleteView, + DomainInvitationCancelView, DomainDeleteUserView, ) from .user_profile import UserProfileView, FinishProfileSetupView diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index ab368b614..d437bce5a 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -1054,26 +1054,26 @@ class DomainAddUserView(DomainFormBaseView): # # The order of the superclasses matters here. BaseDeleteView has a bug where the # # "form_valid" function does not call super, so it cannot use SuccessMessageMixin. # # The workaround is to use SuccessMessageMixin first. -# class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView): -# object: DomainInvitation # workaround for type mismatch in DeleteView +class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView): + object: DomainInvitation # workaround for type mismatch in DeleteView -# def post(self, request, *args, **kwargs): -# """Override post method in order to error in the case when the -# domain invitation status is RETRIEVED""" -# self.object = self.get_object() -# form = self.get_form() -# if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: -# return self.form_valid(form) -# else: -# # Produce an error message if the domain invatation status is RETRIEVED -# messages.error(request, f"Invitation to {self.object.email} has already been retrieved.") -# return HttpResponseRedirect(self.get_success_url()) + def post(self, request, *args, **kwargs): + """Override post method in order to error in the case when the + domain invitation status is RETRIEVED""" + self.object = self.get_object() + form = self.get_form() + if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: + return self.form_valid(form) + else: + # Produce an error message if the domain invatation status is RETRIEVED + messages.error(request, f"Invitation to {self.object.email} has already been retrieved.") + return HttpResponseRedirect(self.get_success_url()) -# def get_success_url(self): -# return reverse("domain-users", kwargs={"pk": self.object.domain.id}) + def get_success_url(self): + return reverse("domain-users", kwargs={"pk": self.object.domain.id}) -# def get_success_message(self, cleaned_data): -# return f"Canceled invitation to {self.object.email}." + def get_success_message(self, cleaned_data): + return f"Canceled invitation to {self.object.email}." class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationUpdateView): From 723bb0816aebcce04f96823b47e9193caf4bbf43 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 4 Nov 2024 09:05:34 -0500 Subject: [PATCH 04/56] changes --- src/registrar/config/urls.py | 6 ++-- src/registrar/templates/domain_users.html | 2 +- src/registrar/templatetags/custom_filters.py | 2 +- src/registrar/views/__init__.py | 1 - src/registrar/views/domain.py | 34 ++++++++++---------- 5 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index f61e31e54..869ef9e74 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -321,9 +321,9 @@ urlpatterns = [ name="user-profile", ), path( - "invitation//delete", - views.DomainInvitationDeleteView.as_view(http_method_names=["post"]), - name="invitation-delete", + "invitation//cancel", + views.DomainInvitationCancelView.as_view(http_method_names=["post"]), + name="invitation-cancel", ), path( "domain-request//delete", diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index a2eb3e604..c19c9bf69 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -134,7 +134,7 @@ {{ invitation.status|title }} {% if invitation.status == invitation.DomainInvitationStatus.INVITED %} -
+ {% csrf_token %}
{% endif %} diff --git a/src/registrar/templatetags/custom_filters.py b/src/registrar/templatetags/custom_filters.py index b29dccb08..2ace00576 100644 --- a/src/registrar/templatetags/custom_filters.py +++ b/src/registrar/templatetags/custom_filters.py @@ -200,7 +200,7 @@ def is_domain_subpage(path): "domain-users-add", "domain-request-delete", "domain-user-delete", - "invitation-delete", + "invitation-cancel", ] return get_url_name(path) in url_names diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index 6b3bcac7c..9a9cb7856 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -11,7 +11,6 @@ from .domain import ( DomainSecurityEmailView, DomainUsersView, DomainAddUserView, - DomainInvitationDeleteView, DomainInvitationCancelView, DomainDeleteUserView, ) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index d437bce5a..ab368b614 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -1054,26 +1054,26 @@ class DomainAddUserView(DomainFormBaseView): # # The order of the superclasses matters here. BaseDeleteView has a bug where the # # "form_valid" function does not call super, so it cannot use SuccessMessageMixin. # # The workaround is to use SuccessMessageMixin first. -class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView): - object: DomainInvitation # workaround for type mismatch in DeleteView +# class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView): +# object: DomainInvitation # workaround for type mismatch in DeleteView - def post(self, request, *args, **kwargs): - """Override post method in order to error in the case when the - domain invitation status is RETRIEVED""" - self.object = self.get_object() - form = self.get_form() - if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: - return self.form_valid(form) - else: - # Produce an error message if the domain invatation status is RETRIEVED - messages.error(request, f"Invitation to {self.object.email} has already been retrieved.") - return HttpResponseRedirect(self.get_success_url()) +# def post(self, request, *args, **kwargs): +# """Override post method in order to error in the case when the +# domain invitation status is RETRIEVED""" +# self.object = self.get_object() +# form = self.get_form() +# if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: +# return self.form_valid(form) +# else: +# # Produce an error message if the domain invatation status is RETRIEVED +# messages.error(request, f"Invitation to {self.object.email} has already been retrieved.") +# return HttpResponseRedirect(self.get_success_url()) - def get_success_url(self): - return reverse("domain-users", kwargs={"pk": self.object.domain.id}) +# def get_success_url(self): +# return reverse("domain-users", kwargs={"pk": self.object.domain.id}) - def get_success_message(self, cleaned_data): - return f"Canceled invitation to {self.object.email}." +# def get_success_message(self, cleaned_data): +# return f"Canceled invitation to {self.object.email}." class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationUpdateView): From 81d3ec0b1b927714122f890b1b9bbcb28c4a46c7 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 4 Nov 2024 13:49:20 -0500 Subject: [PATCH 05/56] reordering of fields, js for conditional Approved Domain display --- src/registrar/admin.py | 27 +++++++---- src/registrar/assets/js/get-gov-admin.js | 57 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 8a0a458f8..59c0e83a9 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1759,24 +1759,31 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): None, { "fields": [ - "portfolio", - "sub_organization", - "requested_suborganization", - "suborganization_city", - "suborganization_state_territory", "status_history", "status", "rejection_reason", "rejection_reason_email", "action_needed_reason", "action_needed_reason_email", - "investigator", - "creator", "approved_domain", + "investigator", "notes", ] }, ), + ( + "Requested by", + { + "fields": [ + "portfolio", + "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", + "creator", + ] + } + ), (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), ( "Contacts", @@ -1890,10 +1897,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def get_fieldsets(self, request, obj=None): fieldsets = super().get_fieldsets(request, obj) - # Hide certain suborg fields behind the organization feature flag + # Hide certain portfolio and suborg fields behind the organization requests flag # if it is not enabled - if not flag_is_active_for_user(request.user, "organization_feature"): + if not flag_is_active_for_user(request.user, "organization_requests"): excluded_fields = [ + "portfolio", + "sub_organization", "requested_suborganization", "suborganization_city", "suborganization_state_territory", diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index a5c55acb1..3bd7e0163 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -797,6 +797,63 @@ document.addEventListener('DOMContentLoaded', function() { customEmail.loadRejectedEmail() }); +/** An IIFE that hides and shows approved domain select2 row in domain request + * conditionally based on the Status field selection. If Approved, show. If not Approved, + * don't show. + */ +document.addEventListener('DOMContentLoaded', function() { + const domainRequestForm = document.getElementById("domainrequest_form"); + if (!domainRequestForm) { + return; + } + + const statusToCheck = "approved"; + const statusSelect = document.getElementById("id_status"); + const sessionVariableName = "showApprovedDomain"; + let approvedDomainFormGroup = document.querySelector(".field-approved_domain"); + + function updateFormGroupVisibility(showFormGroups) { + if (showFormGroups) { + showElement(approvedDomainFormGroup); + }else { + hideElement(approvedDomainFormGroup); + } + } + + // Handle showing/hiding the related fields on page load. + function initializeFormGroups() { + let isStatus = statusSelect.value == statusToCheck; + + // 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); + }); + + // 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 + // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide + // accurately for this edge case, we use cache and test for the back/forward navigation. + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.type === "back_forward") { + let showTextAreaFormGroup = sessionStorage.getItem(sessionVariableName) !== null; + updateFormGroupVisibility(showTextAreaFormGroup); + } + }); + }); + observer.observe({ type: "navigation" }); + } + + initializeFormGroups(); + +}); + /** An IIFE for copy summary button (appears in DomainRegistry models) */ From b01bb922e26c62a21c62ed8ac0c8f4716bde42a3 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 4 Nov 2024 19:19:58 -0500 Subject: [PATCH 06/56] javascript hide and show based on portfolio selection --- src/registrar/admin.py | 83 +++++++++++++++++++ src/registrar/assets/js/get-gov-admin.js | 65 ++++++++++++++- .../admin/includes/detail_table_fieldset.html | 9 ++ 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 59c0e83a9..782b59eb0 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1,6 +1,7 @@ from datetime import date import logging import copy +from typing import Optional from django import forms from django.db.models import Value, CharField, Q from django.db.models.functions import Concat, Coalesce @@ -1728,6 +1729,42 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): custom_election_board.admin_order_field = "is_election_board" # type: ignore custom_election_board.short_description = "Election office" # type: ignore + + # 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 + portfolio_senior_official.short_description = "Senior official" + def portfolio_organization_type(self, obj): + return obj.portfolio.organization_type if obj.portfolio else "" + portfolio_organization_type.short_description = "Organization type" + def portfolio_federal_type(self, obj): + return BranchChoices.get_branch_label(obj.portfolio.federal_type) if obj.portfolio and obj.portfolio.federal_type else "-" + portfolio_federal_type.short_description = "Federal type" + def portfolio_organization_name(self, obj): + return obj.portfolio.organization_name if obj.portfolio else "" + portfolio_organization_name.short_description = "Organization name" + def portfolio_federal_agency(self, obj): + return obj.portfolio.federal_agency if obj.portfolio else "" + portfolio_federal_agency.short_description = "Federal agency" + def portfolio_state_territory(self, obj): + return obj.portfolio.state_territory if obj.portfolio else "" + portfolio_state_territory.short_description = "State, territory, or military post" + def portfolio_address_line1(self, obj): + return obj.portfolio.address_line1 if obj.portfolio else "" + portfolio_address_line1.short_description = "Address line 1" + def portfolio_address_line2(self, obj): + return obj.portfolio.address_line2 if obj.portfolio else "" + portfolio_address_line2.short_description = "Address line 2" + def portfolio_city(self, obj): + return obj.portfolio.city if obj.portfolio else "" + portfolio_city.short_description = "City" + def portfolio_zipcode(self, obj): + return obj.portfolio.zipcode if obj.portfolio else "" + portfolio_zipcode.short_description = "Zip code" + def portfolio_urbanization(self, obj): + return obj.portfolio.urbanization if obj.portfolio else "" + portfolio_urbanization.short_description = "Urbanization" + # This is just a placeholder. This field will be populated in the detail_table_fieldset view. # This is not a field that exists on the model. def status_history(self, obj): @@ -1790,6 +1827,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): { "fields": [ "senior_official", + "portfolio_senior_official", "other_contacts", "no_other_contacts_rationale", "cisa_representative_first_name", @@ -1846,10 +1884,55 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ], }, ), + # the below three sections are for portfolio fields + ( + "Type of organization", + { + "fields": [ + "portfolio_organization_type", + "portfolio_federal_type", + ] + } + ), + ( + "Organization name and mailing address", + { + "fields": [ + "portfolio_organization_name", + "portfolio_federal_agency", + ] + }, + ), + ( + "Show details", + { + "classes": ["collapse--dgfieldset"], + "description": "Extends organization name and mailing address", + "fields": [ + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + ], + }, + ), ] # Readonly fields for analysts and superusers readonly_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", diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 3bd7e0163..8efab10d0 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -86,6 +86,68 @@ function handleSuborganizationFields( portfolioDropdown.on("change", toggleSuborganizationFields); } +function handlePortfolioSelection() { + // These dropdown are select2 fields so they must be interacted with via jquery + const portfolioDropdown = django.jQuery("#id_portfolio"); + const suborganizationDropdown = django.jQuery("#id_sub_organization"); + const suborganizationField = document.querySelector(".field-sub_organization"); + const seniorOfficialField = document.querySelector(".field-senior_official"); + const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official"); + const otherEmployeesField = document.querySelector(".field-other_contacts"); + const noOtherContactsRationaleField = document.querySelector(".field-no_other_contacts_rationale"); + const cisaRepresentativeFirstNameField = document.querySelector(".field-cisa_representative_first_name"); + const cisaRepresentativeLastNameField = document.querySelector(".field-cisa_representative_last_name"); + const cisaRepresentativeEmailField = document.querySelector(".field-cisa_representative_email"); + const orgTypeFieldSet = document.querySelector(".field-is_election_board").parentElement; + const orgTypeFieldSetDetails = orgTypeFieldSet.nextElementSibling; + const orgNameFieldSet = document.querySelector(".field-organization_name").parentElement; + const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling; + const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement; + const portfolioOrgNameFieldSet = document.querySelector(".field-portfolio_organization_name").parentElement; + const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling; + + + function toggleSuborganizationFields() { + if (portfolioDropdown.val()) { + showElement(suborganizationField); + hideElement(seniorOfficialField); + showElement(portfolioSeniorOfficialField); + hideElement(otherEmployeesField); + hideElement(noOtherContactsRationaleField); + hideElement(cisaRepresentativeFirstNameField); + hideElement(cisaRepresentativeLastNameField); + hideElement(cisaRepresentativeEmailField); + hideElement(orgTypeFieldSet); + hideElement(orgTypeFieldSetDetails); + hideElement(orgNameFieldSet); + hideElement(orgNameFieldSetDetails); + showElement(portfolioOrgTypeFieldSet); + showElement(portfolioOrgNameFieldSet); + showElement(portfolioOrgNameFieldSetDetails); + }else { + hideElement(suborganizationField); + showElement(seniorOfficialField); + hideElement(portfolioSeniorOfficialField); + showElement(otherEmployeesField); + showElement(noOtherContactsRationaleField); + showElement(cisaRepresentativeFirstNameField); + showElement(cisaRepresentativeLastNameField); + showElement(cisaRepresentativeEmailField); + showElement(orgTypeFieldSet); + showElement(orgTypeFieldSetDetails); + showElement(orgNameFieldSet); + showElement(orgNameFieldSetDetails); + hideElement(portfolioOrgTypeFieldSet); + hideElement(portfolioOrgNameFieldSet); + hideElement(portfolioOrgNameFieldSetDetails); + } + } + + // Run the function once on page startup, then attach an event listener + toggleSuborganizationFields(); + portfolioDropdown.on("change", toggleSuborganizationFields); +} + // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Initialization code. @@ -1181,7 +1243,7 @@ document.addEventListener('DOMContentLoaded', function() { updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); }else { if (readonlySeniorOfficial) { - let seniorOfficialLink = `${seniorOfficialName}` + let seniorOfficialLink = `${seniorOfficialName}` readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; } } @@ -1276,6 +1338,7 @@ document.addEventListener('DOMContentLoaded', function() { const domainRequestPage = document.getElementById("domainrequest_form"); if (domainRequestPage) { handleSuborganizationFields(); + handlePortfolioSelection(); } })(); 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 317604c5e..a73a4d5e6 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -66,6 +66,10 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) No changelog to display. {% endif %} + {% elif field.field.name == "portfolio_senior_official" %} + {% if original_object.portfolio.senior_official %} + + {% endif %} {% elif field.field.name == "other_contacts" %} {% if all_contacts.count > 2 %}
@@ -332,6 +336,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly %}
+ {% elif field.field.name == "portfolio_senior_official" %} +
+ + {% include "django/admin/includes/contact_detail_list.html" with user=original_object.portfolio.senior_official no_title_top_padding=field.is_readonly %} +
{% elif field.field.name == "other_contacts" and original_object.other_contacts.all %} {% with all_contacts=original_object.other_contacts.all %} {% if all_contacts.count > 2 %} From fa7bc950e696609345e341e4a0ab94cc4fecb90b Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 5 Nov 2024 07:08:52 -0500 Subject: [PATCH 07/56] wip --- src/registrar/assets/js/get-gov-admin.js | 40 ++++++++++++++++-- src/registrar/config/urls.py | 6 +++ .../admin/domain_request_change_form.html | 7 ++++ src/registrar/views/utility/api_views.py | 41 +++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 8efab10d0..f1b4e7c56 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -105,10 +105,44 @@ function handlePortfolioSelection() { const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement; const portfolioOrgNameFieldSet = document.querySelector(".field-portfolio_organization_name").parentElement; const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling; + const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; - function toggleSuborganizationFields() { + function getPortfolio(portfolio_id) { + // get portfolio via ajax + fetch(`${portfolioJsonUrl}?id=${portfolio_id}`) + .then(response => { + return response.json().then(data => data); + }) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + } else { + return data; + } + }) + .catch(error => { + console.error("Error retrieving portfolio", error) + }); + } + + function updatePortfolioFields(portfolio) { + // replace selections in suborganizationDropdown with + // values in portfolio.suborganizations + suborganizationDropdown.empty(); + portfolio.suborganizations.forEach(suborg => { + suborganizationDropdown.append(new Option(suborg.name, suborg.id)); + }); + + // update portfolio senior official field with portfolio.senior_official + + // update portfolio organization type + } + + function togglePortfolioFields() { if (portfolioDropdown.val()) { + let portfolio = getPortfolio(portfolioDropdown.val()); + updatePortfolioFields(portfolio); showElement(suborganizationField); hideElement(seniorOfficialField); showElement(portfolioSeniorOfficialField); @@ -144,8 +178,8 @@ function handlePortfolioSelection() { } // Run the function once on page startup, then attach an event listener - toggleSuborganizationFields(); - portfolioDropdown.on("change", toggleSuborganizationFields); + togglePortfolioFields(); + portfolioDropdown.on("change", togglePortfolioFields); } // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index d289eaf90..02b14b6bf 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -28,6 +28,7 @@ from registrar.views.domain_requests_json import get_domain_requests_json from registrar.views.domains_json import get_domains_json from registrar.views.utility.api_views import ( get_senior_official_from_federal_agency_json, + get_portfolio_json, get_federal_and_portfolio_types_from_federal_agency_json, get_action_needed_email_for_user_json, get_rejection_email_for_user_json, @@ -200,6 +201,11 @@ urlpatterns = [ get_senior_official_from_federal_agency_json, name="get-senior-official-from-federal-agency-json", ), + path( + "admin/api/get-portfolio-json/", + get_portfolio_json, + name="get-portfolio-json", + ), path( "admin/api/get-federal-and-portfolio-types-from-federal-agency-json/", get_federal_and_portfolio_types_from_federal_agency_json, diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index 8d58bc696..ef76ac0b1 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -2,6 +2,13 @@ {% load custom_filters %} {% load i18n static %} +{% block content %} + {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} + {% url 'get-portfolio-json' as url %} + + {{ block.super }} +{% endblock content %} + {% block field_sets %} {# Create an invisible tag so that we can use a click event to toggle the modal. #} diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index 9d73b5197..c31b64360 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -39,6 +39,47 @@ def get_senior_official_from_federal_agency_json(request): return JsonResponse({"error": "Senior Official not found"}, status=404) +@login_required +@staff_member_required +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) + except Portfolio.DoesNotExist: + return JsonResponse({"error": "Portfolio not found"}, status=404) + + # Convert the portfolio to a dictionary + portfolio_dict = model_to_dict(portfolio) + + # Add suborganizations related to this portfolio + suborganizations = portfolio.portfolio_suborganizations.all().values("id", "name") + portfolio_dict["suborganizations"] = list(suborganizations) + + # Add senior official information if it exists + if portfolio.senior_official: + senior_official = model_to_dict( + portfolio.senior_official, + fields=["first_name", "last_name", "title", "phone", "email"] + ) + # The phone number field isn't json serializable, so we + # convert this to a string first if it exists. + if "phone" in senior_official and senior_official.get("phone"): + senior_official["phone"] = str(senior_official["phone"]) + portfolio_dict["senior_official"] = senior_official + else: + portfolio_dict["senior_official"] = None + + return JsonResponse(portfolio_dict) + + @login_required @staff_member_required def get_federal_and_portfolio_types_from_federal_agency_json(request): From 75ef64314fddfdc96292bd28d6b6169120477500 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 5 Nov 2024 16:51:36 -0500 Subject: [PATCH 08/56] wip --- src/registrar/assets/js/get-gov-admin.js | 104 ++++++++++++++++++++--- src/registrar/config/urls.py | 6 ++ src/registrar/views/utility/api_views.py | 27 +++++- 3 files changed, 121 insertions(+), 16 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index f1b4e7c56..cc326a1b6 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -106,7 +106,7 @@ function handlePortfolioSelection() { const portfolioOrgNameFieldSet = document.querySelector(".field-portfolio_organization_name").parentElement; const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling; const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; - + let isPageLoading = true; function getPortfolio(portfolio_id) { // get portfolio via ajax @@ -126,23 +126,101 @@ function handlePortfolioSelection() { }); } - function updatePortfolioFields(portfolio) { + function updatePortfolioFieldsData(portfolio) { // replace selections in suborganizationDropdown with // values in portfolio.suborganizations suborganizationDropdown.empty(); - portfolio.suborganizations.forEach(suborg => { - suborganizationDropdown.append(new Option(suborg.name, suborg.id)); - }); - + + // // update autocomplete url for suborganizationDropdown + // suborganizationDropdown.attr("data-ajax--url", "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolio); + // update portfolio senior official field with portfolio.senior_official // update portfolio organization type } - function togglePortfolioFields() { + function updatePortfolioFields() { + console.log("isPageLoading = " + isPageLoading); + if (!isPageLoading) { + if (portfolioDropdown.val()) { + console.log("there is a value in portfolio dropdown") + let portfolio = getPortfolio(portfolioDropdown.val()); + updatePortfolioFieldsData(portfolio); + } + console.log("updating display"); + updatePortfolioFieldsDisplay(); + } else { + isPageLoading = false; + } + } + + function getUrl() { + return "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolioDropdown.val(); + } + + function updateSubOrganizationUrl() { if (portfolioDropdown.val()) { - let portfolio = getPortfolio(portfolioDropdown.val()); - updatePortfolioFields(portfolio); + const dropdown = django.jQuery("#id_sub_organization"); + if (dropdown.data('select2')) { + console.log("destroying select2"); + dropdown.select2("destroy"); + } + let newURL = "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolioDropdown.val(); + dropdown.select2({ + ajax: { + url: function (params) { + return newURL; + }, + dataType: 'json', + delay: 250, + cache: true + }, + theme: 'admin-autocomplete', + allowClear: true, + placeholder: dropdown.attr('data-placeholder') + }); + } + } + + function updatePortfolioFieldsDisplay() { + + if (portfolioDropdown.val()) { + // update autocomplete url for suborganizationDropdown + // console.log("updating suborganization dropdown, id to " + portfolioDropdown.val()); + // console.log(typeof django.jQuery().select2); // Should output 'function' + // console.log("Attributes of #id_sub_organization:"); + console.log(suborganizationDropdown); + //suborganizationDropdown.attr("data-ajax--url", function () { return getUrl(); }); + django.jQuery(document).ready(function() { + console.log(suborganizationDropdown); + + let dropdown = django.jQuery("#id_sub_organization"); + if (dropdown.data('select2')) { + dropdown.select2('destroy'); + } + let newURL = "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolioDropdown.val(); + + // Reinitialize Select2 with the updated URL + dropdown = django.jQuery("#id_sub_organization"); + dropdown.select2({ + ajax: { + url: newURL, + dataType: 'json', + delay: 250, + cache: true + }, + theme: 'admin-autocomplete', + allowClear: true, + placeholder: dropdown.attr('data-placeholder') + }); + console.log(dropdown); + }); + + + // suborganizationDropdown.attr("ajaxUrl", "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolioDropdown.val()); + + + showElement(suborganizationField); hideElement(seniorOfficialField); showElement(portfolioSeniorOfficialField); @@ -177,9 +255,11 @@ function handlePortfolioSelection() { } } - // Run the function once on page startup, then attach an event listener - togglePortfolioFields(); - portfolioDropdown.on("change", togglePortfolioFields); + // django.jQuery(document).ready(function() { + // updateSubOrganizationUrl(); + // Run the function once on page startup, then attach an event listener + updatePortfolioFieldsDisplay(); + portfolioDropdown.on("change", updatePortfolioFields); } // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 02b14b6bf..1e72e233b 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -29,6 +29,7 @@ from registrar.views.domains_json import get_domains_json from registrar.views.utility.api_views import ( get_senior_official_from_federal_agency_json, get_portfolio_json, + get_suborganization_list_json, get_federal_and_portfolio_types_from_federal_agency_json, get_action_needed_email_for_user_json, get_rejection_email_for_user_json, @@ -206,6 +207,11 @@ urlpatterns = [ get_portfolio_json, name="get-portfolio-json", ), + path( + "admin/api/get-suborganization-list-json/", + get_suborganization_list_json, + name="get-suborganization-list-json", + ), path( "admin/api/get-federal-and-portfolio-types-from-federal-agency-json/", get_federal_and_portfolio_types_from_federal_agency_json, diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index c31b64360..41315105a 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -59,10 +59,6 @@ def get_portfolio_json(request): # Convert the portfolio to a dictionary portfolio_dict = model_to_dict(portfolio) - # Add suborganizations related to this portfolio - suborganizations = portfolio.portfolio_suborganizations.all().values("id", "name") - portfolio_dict["suborganizations"] = list(suborganizations) - # Add senior official information if it exists if portfolio.senior_official: senior_official = model_to_dict( @@ -80,6 +76,29 @@ def get_portfolio_json(request): return JsonResponse(portfolio_dict) +@login_required +@staff_member_required +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) + except Portfolio.DoesNotExist: + return JsonResponse({"error": "Portfolio not found"}, status=404) + + # Add suborganizations related to this portfolio + suborganizations = portfolio.portfolio_suborganizations.all().values("id", "name") + results = [{"id": sub["id"], "text": sub["name"]} for sub in suborganizations] + return JsonResponse({"results": results, "pagination": { "more": False }}) + + @login_required @staff_member_required def get_federal_and_portfolio_types_from_federal_agency_json(request): From cc116c5cd7c49b50a8dd5006cb24683ccfeebe58 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 5 Nov 2024 17:04:56 -0500 Subject: [PATCH 09/56] wip --- src/registrar/assets/js/get-gov-admin.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index cc326a1b6..3bfecb195 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -190,30 +190,37 @@ function handlePortfolioSelection() { // console.log(typeof django.jQuery().select2); // Should output 'function' // console.log("Attributes of #id_sub_organization:"); console.log(suborganizationDropdown); - //suborganizationDropdown.attr("data-ajax--url", function () { return getUrl(); }); + suborganizationDropdown.attr("data-ajax--url", "/admin/api/get-suborganization-list-json/"); django.jQuery(document).ready(function() { console.log(suborganizationDropdown); let dropdown = django.jQuery("#id_sub_organization"); - if (dropdown.data('select2')) { - dropdown.select2('destroy'); + if (suborganizationDropdown.data('select2')) { + suborganizationDropdown.select2('destroy'); } let newURL = "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolioDropdown.val(); // Reinitialize Select2 with the updated URL dropdown = django.jQuery("#id_sub_organization"); - dropdown.select2({ + suborganizationDropdown.select2({ ajax: { - url: newURL, + url: "/admin/api/get-suborganization-list-json/", + data: function (params) { + var query = { + search: params.term, + portfolio_id: portfolioDropdown.val() + } + return query; + }, dataType: 'json', delay: 250, cache: true }, theme: 'admin-autocomplete', allowClear: true, - placeholder: dropdown.attr('data-placeholder') + placeholder: suborganizationDropdown.attr('data-placeholder') }); - console.log(dropdown); + console.log(suborganizationDropdown); }); From 05a387daa04010d338be1beee2b00d6a50bb80a4 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 5 Nov 2024 17:07:02 -0500 Subject: [PATCH 10/56] wip --- src/registrar/assets/js/get-gov-admin.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 3bfecb195..8ed65ea31 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -198,13 +198,11 @@ function handlePortfolioSelection() { if (suborganizationDropdown.data('select2')) { suborganizationDropdown.select2('destroy'); } - let newURL = "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolioDropdown.val(); // Reinitialize Select2 with the updated URL dropdown = django.jQuery("#id_sub_organization"); suborganizationDropdown.select2({ ajax: { - url: "/admin/api/get-suborganization-list-json/", data: function (params) { var query = { search: params.term, From 3c9e93ee46d3853eff1f82d47a3ca41bcfa03040 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Tue, 5 Nov 2024 18:31:34 -0500 Subject: [PATCH 11/56] wip --- src/registrar/admin.py | 2 +- src/registrar/assets/js/get-gov-admin.js | 94 ++++++++---------------- 2 files changed, 30 insertions(+), 66 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 782b59eb0..b305ffb45 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -236,7 +236,7 @@ class DomainRequestAdminForm(forms.ModelForm): widgets = { "current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False), "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), - "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), + "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False) } labels = { "action_needed_reason_email": "Email", diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 8ed65ea31..8cd638492 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -140,36 +140,33 @@ function handlePortfolioSelection() { } function updatePortfolioFields() { - console.log("isPageLoading = " + isPageLoading); if (!isPageLoading) { if (portfolioDropdown.val()) { - console.log("there is a value in portfolio dropdown") let portfolio = getPortfolio(portfolioDropdown.val()); updatePortfolioFieldsData(portfolio); } - console.log("updating display"); updatePortfolioFieldsDisplay(); } else { isPageLoading = false; } } - function getUrl() { - return "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolioDropdown.val(); - } - - function updateSubOrganizationUrl() { - if (portfolioDropdown.val()) { - const dropdown = django.jQuery("#id_sub_organization"); - if (dropdown.data('select2')) { - console.log("destroying select2"); - dropdown.select2("destroy"); + function updateSubOrganizationDropdown(portfolio_id) { + django.jQuery(document).ready(function() { + + if (suborganizationDropdown.data('select2')) { + suborganizationDropdown.select2('destroy'); } - let newURL = "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolioDropdown.val(); - dropdown.select2({ + + // Reinitialize Select2 with the updated URL + suborganizationDropdown.select2({ ajax: { - url: function (params) { - return newURL; + data: function (params) { + var query = { + search: params.term, + portfolio_id: portfolio_id + } + return query; }, dataType: 'json', delay: 250, @@ -177,55 +174,22 @@ function handlePortfolioSelection() { }, theme: 'admin-autocomplete', allowClear: true, - placeholder: dropdown.attr('data-placeholder') + placeholder: suborganizationDropdown.attr('data-placeholder') }); - } + }); + } + + function initializeSubOrganizationUrl() { + suborganizationDropdown.attr("data-ajax--url", "/admin/api/get-suborganization-list-json/"); } function updatePortfolioFieldsDisplay() { - - if (portfolioDropdown.val()) { - // update autocomplete url for suborganizationDropdown - // console.log("updating suborganization dropdown, id to " + portfolioDropdown.val()); - // console.log(typeof django.jQuery().select2); // Should output 'function' - // console.log("Attributes of #id_sub_organization:"); - console.log(suborganizationDropdown); - suborganizationDropdown.attr("data-ajax--url", "/admin/api/get-suborganization-list-json/"); - django.jQuery(document).ready(function() { - console.log(suborganizationDropdown); - - let dropdown = django.jQuery("#id_sub_organization"); - if (suborganizationDropdown.data('select2')) { - suborganizationDropdown.select2('destroy'); - } + let portfolio_id = portfolioDropdown.val(); - // Reinitialize Select2 with the updated URL - dropdown = django.jQuery("#id_sub_organization"); - suborganizationDropdown.select2({ - ajax: { - data: function (params) { - var query = { - search: params.term, - portfolio_id: portfolioDropdown.val() - } - return query; - }, - dataType: 'json', - delay: 250, - cache: true - }, - theme: 'admin-autocomplete', - allowClear: true, - placeholder: suborganizationDropdown.attr('data-placeholder') - }); - console.log(suborganizationDropdown); - }); + if (portfolio_id) { + + updateSubOrganizationDropdown(portfolio_id); - - // suborganizationDropdown.attr("ajaxUrl", "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolioDropdown.val()); - - - showElement(suborganizationField); hideElement(seniorOfficialField); showElement(portfolioSeniorOfficialField); @@ -260,11 +224,11 @@ function handlePortfolioSelection() { } } - // django.jQuery(document).ready(function() { - // updateSubOrganizationUrl(); - // Run the function once on page startup, then attach an event listener - updatePortfolioFieldsDisplay(); - portfolioDropdown.on("change", updatePortfolioFields); + + initializeSubOrganizationUrl(); + // Run the function once on page startup, then attach an event listener + updatePortfolioFieldsDisplay(); + portfolioDropdown.on("change", updatePortfolioFields); } // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> From bd22700f8f8bb09e686c3a0a7614b74e10425e27 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 5 Nov 2024 18:57:58 -0500 Subject: [PATCH 12/56] updatePortfolioFieldsDataDynamicDisplay --- src/registrar/assets/js/get-gov-admin.js | 54 +++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 8cd638492..55e891871 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -139,13 +139,65 @@ function handlePortfolioSelection() { // update portfolio organization type } + function updatePortfolioFieldsDataDynamicDisplay(portfolio) { + + // handleFederalAgencyChange + federalAgencyContainer = document.querySelector(".field-portfolio_federal_agency"); + + let selectedText = document.querySelector('.field-portfolio_federal_agency .readonly').innerText; + let organizationTypeValue = document.querySelector(".field-portfolio_organization_type .readonly").innerText.toLowerCase(); + readonlyOrganizationType = document.querySelector(".field-portfolio_organization_type .readonly"); + if (selectedText !== "Non-Federal Agency") { + if (organizationTypeValue !== "federal") { + readonlyOrganizationType.innerText = "Federal" + } + }else { + if (organizationTypeValue === "federal") { + readonlyOrganizationType.innerText = "-" + } + } + + // handleStateTerritoryChange + urbanizationField = document.querySelector(".field-portfolio_urbanization"); + stateTerritory = document.querySelector(".field-portfolio_state_territory .readonly").innerText; + if (stateTerritory === "PR") { + showElement(urbanizationField) + } else { + hideElement(urbanizationField) + } + + // handleOrganizationTypeChange + let organizationNameContainer = document.querySelector(".field-portfolio_organization_name"); + let organizationType = document.querySelector(".field-portfolio_organization_type .readonly"); + let federalType = document.querySelector(".field-portfolio_federal_type"); + if (organizationType && organizationNameContainer) { + let selectedValue = organizationType.innerText; + if (selectedValue === "-") { + hideElement(organizationNameContainer); + showElement(federalAgencyContainer); + if (federalType) { + showElement(federalType); + } + } else { + showElement(organizationNameContainer); + hideElement(federalAgencyContainer); + if (federalType) { + hideElement(federalType); + } + } + } + } + function updatePortfolioFields() { if (!isPageLoading) { if (portfolioDropdown.val()) { let portfolio = getPortfolio(portfolioDropdown.val()); updatePortfolioFieldsData(portfolio); + updatePortfolioFieldsDisplay(); + updatePortfolioFieldsDataDynamicDisplay(portfolio); + } else { + updatePortfolioFieldsDisplay(); } - updatePortfolioFieldsDisplay(); } else { isPageLoading = false; } From df6649a12e2fa7689fc10bffbb748dea2c279eff Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 5 Nov 2024 21:03:00 -0500 Subject: [PATCH 13/56] revise updatePortfolioFieldsDataDynamicDisplay to work with dynamic data updates --- src/registrar/assets/js/get-gov-admin.js | 206 +++++++++++++++++++---- 1 file changed, 175 insertions(+), 31 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 55e891871..534a36100 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -139,40 +139,41 @@ function handlePortfolioSelection() { // update portfolio organization type } - function updatePortfolioFieldsDataDynamicDisplay(portfolio) { + function updatePortfolioFieldsDataDynamicDisplay() { - // handleFederalAgencyChange - federalAgencyContainer = document.querySelector(".field-portfolio_federal_agency"); + // the federal agency change listener fires on page load, which we don't want. + var isInitialPageLoad = true - let selectedText = document.querySelector('.field-portfolio_federal_agency .readonly').innerText; - let organizationTypeValue = document.querySelector(".field-portfolio_organization_type .readonly").innerText.toLowerCase(); - readonlyOrganizationType = document.querySelector(".field-portfolio_organization_type .readonly"); - if (selectedText !== "Non-Federal Agency") { - if (organizationTypeValue !== "federal") { - readonlyOrganizationType.innerText = "Federal" + // This is the additional information that exists beneath the SO element. + var contactList = document.querySelector(".field-portfolio_senior_official .dja-address-contact-list"); + const federalAgencyContainer = document.querySelector(".field-portfolio_federal_agency"); + document.addEventListener('DOMContentLoaded', function() { + + let federalAgencyValue = federalAgencyContainer.querySelector(".readonly").innerText; + let readonlyOrganizationType = document.querySelector(".field-portfolio_organization_type .readonly"); + let organizationTypeValue = readonlyOrganizationType.innerText; + + let organizationNameContainer = document.querySelector(".field-portfolio_organization_name"); + let federalType = document.querySelector(".field-portfolio_federal_type"); + + if (federalAgencyValue && organizationTypeValue) { + handleFederalAgencyChange(federalAgencyValue, organizationTypeValue, organizationNameContainer, federalType); } - }else { + // Handle dynamically hiding the urbanization field + let urbanizationField = document.querySelector(".field-portfolio_urbanization"); + let stateTerritory = document.querySelector(".field-portfolio_state_territory"); + let stateTerritoryValue = stateTerritory.innerText; + if (urbanizationField && stateTerritoryValue) { + handleStateTerritoryChange(stateTerritoryValue, urbanizationField); + } + + // Handle hiding the organization name field when the organization_type is federal. + // Run this first one page load, then secondly on a change event. + handleOrganizationTypeChange(organizationTypeValue, organizationNameContainer, federalType); + }); + + function handleOrganizationTypeChange(organizationTypeValue, organizationNameContainer, federalType) { if (organizationTypeValue === "federal") { - readonlyOrganizationType.innerText = "-" - } - } - - // handleStateTerritoryChange - urbanizationField = document.querySelector(".field-portfolio_urbanization"); - stateTerritory = document.querySelector(".field-portfolio_state_territory .readonly").innerText; - if (stateTerritory === "PR") { - showElement(urbanizationField) - } else { - hideElement(urbanizationField) - } - - // handleOrganizationTypeChange - let organizationNameContainer = document.querySelector(".field-portfolio_organization_name"); - let organizationType = document.querySelector(".field-portfolio_organization_type .readonly"); - let federalType = document.querySelector(".field-portfolio_federal_type"); - if (organizationType && organizationNameContainer) { - let selectedValue = organizationType.innerText; - if (selectedValue === "-") { hideElement(organizationNameContainer); showElement(federalAgencyContainer); if (federalType) { @@ -186,6 +187,148 @@ function handlePortfolioSelection() { } } } + + function handleFederalAgencyChange(federalAgencyValue, organizationTypeValue, organizationNameContainer, federalType) { + // Don't do anything on page load + if (isInitialPageLoad) { + isInitialPageLoad = false; + return; + } + + // There isn't a federal senior official associated with null records + if (!federalAgencyValue) { + return; + } + + if (federalAgencyValue !== "Non-Federal Agency") { + if (organizationTypeValue !== "federal") { + readonlyOrganizationType.innerText = "Federal" + } + }else { + if (organizationTypeValue === "federal") { + readonlyOrganizationType.innerText = "-" + } + } + + handleOrganizationTypeChange(organizationTypeValue, organizationNameContainer, federalType); + + // We are missing these hooks that exist on the portfolio template, so I hardcoded them for now: + // + // + // + + let federalPortfolioApi = "/admin/api/get-federal-and-portfolio-types-from-federal-agency-json/"; + fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`) + .then(response => { + const statusCode = response.status; + return response.json().then(data => ({ statusCode, data })); + }) + .then(({ statusCode, data }) => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + return; + } + updateReadOnly(data.federal_type, '.field-portfolio_federal_type'); + }) + .catch(error => console.error("Error fetching federal and portfolio types: ", error)); + + hideElement(contactList.parentElement); + + let seniorOfficialAddUrl = "/admin/registrar/seniorofficial/add/"; + let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly"); + let seniorOfficialApi = "/admin/api/get-senior-official-from-federal-agency-json/"; + fetch(`${seniorOfficialApi}?agency_name=${selectedText}`) + .then(response => { + const statusCode = response.status; + return response.json().then(data => ({ statusCode, data })); + }) + .then(({ statusCode, data }) => { + if (data.error) { + if (statusCode === 404) { + readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; + console.warn("Record not found: " + data.error); + }else { + console.error("Error in AJAX call: " + data.error); + } + return; + } + + // Update the "contact details" blurb beneath senior official + updateContactInfo(data); + showElement(contactList.parentElement); + + // Get the associated senior official with this federal agency + let seniorOfficialId = data.id; + let seniorOfficialName = [data.first_name, data.last_name].join(" "); + + if (readonlySeniorOfficial) { + let seniorOfficialLink = `${seniorOfficialName}` + readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + } + + }) + .catch(error => console.error("Error fetching senior official: ", error)); + + } + + function handleStateTerritoryChange(stateTerritoryValue, urbanizationField) { + if (stateTerritoryValue === "PR") { + showElement(urbanizationField) + } else { + hideElement(urbanizationField) + } + } + + /** + * Utility that selects a div from the DOM using selectorString, + * and updates a div within that div which has class of 'readonly' + * so that the text of the div is updated to updateText + * @param {*} updateText + * @param {*} selectorString + */ + function updateReadOnly(updateText, selectorString) { + // find the div by selectorString + const selectedDiv = document.querySelector(selectorString); + if (selectedDiv) { + // find the nested div with class 'readonly' inside the selectorString div + const readonlyDiv = selectedDiv.querySelector('.readonly'); + if (readonlyDiv) { + // Update the text content of the readonly div + readonlyDiv.textContent = updateText !== null ? updateText : '-'; + } + } + } + + function updateContactInfo(data) { + if (!contactList) return; + + const titleSpan = contactList.querySelector("#contact_info_title"); + const emailSpan = contactList.querySelector("#contact_info_email"); + const phoneSpan = contactList.querySelector("#contact_info_phone"); + + if (titleSpan) { + titleSpan.textContent = data.title || "None"; + }; + + // Update the email field and the content for the clipboard + if (emailSpan) { + let copyButton = contactList.querySelector(".admin-icon-group"); + emailSpan.textContent = data.email || "None"; + if (data.email) { + const clipboardInput = contactList.querySelector(".admin-icon-group input"); + if (clipboardInput) { + clipboardInput.value = data.email; + }; + showElement(copyButton); + }else { + hideElement(copyButton); + } + } + + if (phoneSpan) { + phoneSpan.textContent = data.phone || "None"; + }; + } } function updatePortfolioFields() { @@ -194,7 +337,7 @@ function handlePortfolioSelection() { let portfolio = getPortfolio(portfolioDropdown.val()); updatePortfolioFieldsData(portfolio); updatePortfolioFieldsDisplay(); - updatePortfolioFieldsDataDynamicDisplay(portfolio); + updatePortfolioFieldsDataDynamicDisplay(); } else { updatePortfolioFieldsDisplay(); } @@ -280,6 +423,7 @@ function handlePortfolioSelection() { initializeSubOrganizationUrl(); // Run the function once on page startup, then attach an event listener updatePortfolioFieldsDisplay(); + updatePortfolioFieldsDataDynamicDisplay(); portfolioDropdown.on("change", updatePortfolioFields); } From e948dc4311e3e020ae84b55c64efaf1775269efd Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 6 Nov 2024 11:07:50 -0500 Subject: [PATCH 14/56] wip --- src/registrar/assets/js/get-gov-admin.js | 151 ++++++++++++++--------- src/registrar/views/utility/api_views.py | 13 ++ 2 files changed, 106 insertions(+), 58 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 534a36100..b63740f84 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -103,27 +103,40 @@ function handlePortfolioSelection() { const orgNameFieldSet = document.querySelector(".field-organization_name").parentElement; const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling; const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement; - const portfolioOrgNameFieldSet = document.querySelector(".field-portfolio_organization_name").parentElement; + const portfolioOrgType = document.querySelector(".field-portfolio_organization_type .readonly"); + const portfolioFederalTypeField = document.querySelector(".field-portfolio_federal_type"); + const portfolioFederalType = portfolioFederalTypeField.querySelector(".readonly"); + const portfolioOrgNameField = document.querySelector(".field-portfolio_organization_name") + const portfolioOrgName = portfolioOrgNameField.querySelector(".readonly"); + const portfolioOrgNameFieldSet = portfolioOrgNameField.parentElement; const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling; + const portfolioFederalAgencyField = document.querySelector(".field-portfolio_federal_agency"); + const portfolioFederalAgency = portfolioFederalAgencyField.querySelector(".readonly"); + const portfolioStateTerritory = document.querySelector(".field-portfolio_state_territory .readonly"); + const portfolioAddressLine1 = document.querySelector(".field-portfolio_address_line1 .readonly"); + const portfolioAddressLine2 = document.querySelector(".field-portfolio_address_line2 .readonly"); + const portfolioCity = document.querySelector(".field-portfolio_city .readonly"); + const portfolioZipcode = document.querySelector(".field-portfolio_zipcode .readonly"); + const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization"); + const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly"); const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; let isPageLoading = true; function getPortfolio(portfolio_id) { - // get portfolio via ajax - fetch(`${portfolioJsonUrl}?id=${portfolio_id}`) - .then(response => { - return response.json().then(data => data); - }) - .then(data => { - if (data.error) { - console.error("Error in AJAX call: " + data.error); - } else { - return data; - } - }) - .catch(error => { - console.error("Error retrieving portfolio", error) - }); + return fetch(`${portfolioJsonUrl}?id=${portfolio_id}`) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + return null; + } else { + return data; + } + }) + .catch(error => { + console.error("Error retrieving portfolio", error); + return null; + }); } function updatePortfolioFieldsData(portfolio) { @@ -131,12 +144,38 @@ function handlePortfolioSelection() { // values in portfolio.suborganizations suborganizationDropdown.empty(); - // // update autocomplete url for suborganizationDropdown - // suborganizationDropdown.attr("data-ajax--url", "/admin/api/get-suborganization-list-json/?portfolio_id=" + portfolio); - - // update portfolio senior official field with portfolio.senior_official + // update portfolio senior official // update portfolio organization type + portfolioOrgType.innerText = portfolio.organization_type; + + // update portfolio federal type + portfolioFederalType.innerText = portfolio.federal_type + + // update portfolio organization name + portfolioOrgName.innerText = portfolio.organization_name; + + // update portfolio federal agency + portfolioFederalAgency.innerText = portfolio.federal_agency; + + // update portfolio state + portfolioStateTerritory.innerText = portfolio.state_territory; + + // update portfolio address line 1 + portfolioAddressLine1.innerText = portfolio.address_line1; + + // update portfolio address line 2 + portfolioAddressLine2.innerText = portfolio.address_line2; + + // update portfolio city + portfolioCity.innerText = portfolio.city; + + // update portfolio zip code + portfolioZipcode.innerText = portfolio.zipcode + + // update portfolio urbanization + portfolioUrbanization.innerText = portfolio.urbanization; + } function updatePortfolioFieldsDataDynamicDisplay() { @@ -146,49 +185,42 @@ function handlePortfolioSelection() { // This is the additional information that exists beneath the SO element. var contactList = document.querySelector(".field-portfolio_senior_official .dja-address-contact-list"); - const federalAgencyContainer = document.querySelector(".field-portfolio_federal_agency"); document.addEventListener('DOMContentLoaded', function() { - let federalAgencyValue = federalAgencyContainer.querySelector(".readonly").innerText; - let readonlyOrganizationType = document.querySelector(".field-portfolio_organization_type .readonly"); - let organizationTypeValue = readonlyOrganizationType.innerText; + let federalAgencyValue = portfolioFederalAgency.innerText; + let portfolioOrgTypeValue = portfolioOrgType.innerText; - let organizationNameContainer = document.querySelector(".field-portfolio_organization_name"); - let federalType = document.querySelector(".field-portfolio_federal_type"); - - if (federalAgencyValue && organizationTypeValue) { - handleFederalAgencyChange(federalAgencyValue, organizationTypeValue, organizationNameContainer, federalType); + if (federalAgencyValue && portfolioOrgTypeValue) { + handleFederalAgencyChange(federalAgencyValue, portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField); } // Handle dynamically hiding the urbanization field - let urbanizationField = document.querySelector(".field-portfolio_urbanization"); - let stateTerritory = document.querySelector(".field-portfolio_state_territory"); - let stateTerritoryValue = stateTerritory.innerText; - if (urbanizationField && stateTerritoryValue) { - handleStateTerritoryChange(stateTerritoryValue, urbanizationField); + let portfolioStateTerritoryValue = portfolioStateTerritory.innerText; + if (portfolioUrbanizationField && portfolioStateTerritoryValue) { + handleStateTerritoryChange(portfolioStateTerritoryValue, portfolioUrbanizationField); } // Handle hiding the organization name field when the organization_type is federal. // Run this first one page load, then secondly on a change event. - handleOrganizationTypeChange(organizationTypeValue, organizationNameContainer, federalType); + handleOrganizationTypeChange(portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField); }); - function handleOrganizationTypeChange(organizationTypeValue, organizationNameContainer, federalType) { - if (organizationTypeValue === "federal") { - hideElement(organizationNameContainer); - showElement(federalAgencyContainer); - if (federalType) { - showElement(federalType); + function handleOrganizationTypeChange(portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField) { + if (portfolioOrgTypeValue === "federal") { + hideElement(portfolioOrgNameField); + showElement(portfolioFederalAgencyField); + if (portfolioFederalTypeField) { + showElement(portfolioFederalTypeField); } } else { - showElement(organizationNameContainer); - hideElement(federalAgencyContainer); - if (federalType) { - hideElement(federalType); + showElement(portfolioOrgNameField); + hideElement(portfolioFederalAgencyField); + if (portfolioFederalTypeField) { + hideElement(portfolioFederalTypeField); } } } - function handleFederalAgencyChange(federalAgencyValue, organizationTypeValue, organizationNameContainer, federalType) { + function handleFederalAgencyChange(federalAgencyValue, organizationTypeValue, portfolioOrgNameField, portfolioFederalTypeField) { // Don't do anything on page load if (isInitialPageLoad) { isInitialPageLoad = false; @@ -202,15 +234,15 @@ function handlePortfolioSelection() { if (federalAgencyValue !== "Non-Federal Agency") { if (organizationTypeValue !== "federal") { - readonlyOrganizationType.innerText = "Federal" + portfolioOrgType.innerText = "Federal" } }else { if (organizationTypeValue === "federal") { - readonlyOrganizationType.innerText = "-" + portfolioOrgType.innerText = "-" } } - handleOrganizationTypeChange(organizationTypeValue, organizationNameContainer, federalType); + handleOrganizationTypeChange(organizationTypeValue, portfolioOrgNameField, portfolioFederalTypeField); // We are missing these hooks that exist on the portfolio template, so I hardcoded them for now: // @@ -271,11 +303,11 @@ function handlePortfolioSelection() { } - function handleStateTerritoryChange(stateTerritoryValue, urbanizationField) { - if (stateTerritoryValue === "PR") { - showElement(urbanizationField) + function handleStateTerritoryChange(portfolioStateTerritoryValue, portfolioUrbanizationField) { + if (portfolioStateTerritoryValue === "PR") { + showElement(portfolioUrbanizationField) } else { - hideElement(urbanizationField) + hideElement(portfolioUrbanizationField) } } @@ -331,13 +363,16 @@ function handlePortfolioSelection() { } } - function updatePortfolioFields() { + async function updatePortfolioFields() { if (!isPageLoading) { if (portfolioDropdown.val()) { - let portfolio = getPortfolio(portfolioDropdown.val()); - updatePortfolioFieldsData(portfolio); - updatePortfolioFieldsDisplay(); - updatePortfolioFieldsDataDynamicDisplay(); + + getPortfolio(portfolioDropdown.val()).then((portfolio) => { + console.log(portfolio); + updatePortfolioFieldsData(portfolio); + updatePortfolioFieldsDisplay(); + updatePortfolioFieldsDataDynamicDisplay(); + }); } else { updatePortfolioFieldsDisplay(); } diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index 41315105a..75273f51e 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -59,6 +59,9 @@ def get_portfolio_json(request): # Convert the portfolio to a dictionary portfolio_dict = model_to_dict(portfolio) + # map portfolio federal type + portfolio_dict["federal_type"] = BranchChoices.get_branch_label(portfolio.federal_type) if portfolio.federal_type else "-" + # Add senior official information if it exists if portfolio.senior_official: senior_official = model_to_dict( @@ -73,6 +76,16 @@ def get_portfolio_json(request): else: portfolio_dict["senior_official"] = None + # Add federal agency information if it exists + if portfolio.federal_agency: + federal_agency = model_to_dict( + portfolio.federal_agency, + fields=["agency"] + ) + portfolio_dict["federal_agency"] = federal_agency["agency"] + else: + portfolio_dict["federal_agency"] = None + return JsonResponse(portfolio_dict) From 661f9ff254d28ac6aff046b1c0264f1094ac0d53 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 6 Nov 2024 12:25:49 -0500 Subject: [PATCH 15/56] revise IDs in contact_detail_list to classes --- src/registrar/assets/js/get-gov-admin.js | 26 +++++++++---------- .../admin/includes/contact_detail_list.html | 10 +++---- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index b63740f84..b59b8f935 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -334,9 +334,9 @@ function handlePortfolioSelection() { function updateContactInfo(data) { if (!contactList) return; - const titleSpan = contactList.querySelector("#contact_info_title"); - const emailSpan = contactList.querySelector("#contact_info_email"); - const phoneSpan = contactList.querySelector("#contact_info_phone"); + const titleSpan = contactList.querySelector(".contact_info_title"); + const emailSpan = contactList.querySelector(".contact_info_email"); + const phoneSpan = contactList.querySelector(".contact_info_phone"); if (titleSpan) { titleSpan.textContent = data.title || "None"; @@ -1277,10 +1277,10 @@ document.addEventListener('DOMContentLoaded', function() { if (contacts) { contacts.forEach(contact => { // Check if the
element is not empty - const name = contact.querySelector('a#contact_info_name')?.innerText; - const title = contact.querySelector('span#contact_info_title')?.innerText; - const email = contact.querySelector('span#contact_info_email')?.innerText; - const phone = contact.querySelector('span#contact_info_phone')?.innerText; + const name = contact.querySelector('a.contact_info_name')?.innerText; + const title = contact.querySelector('span.contact_info_title')?.innerText; + const email = contact.querySelector('span.contact_info_email')?.innerText; + const phone = contact.querySelector('span.contact_info_phone')?.innerText; const url = nameToUrlMap[name] || '#'; // Format the contact information const listItem = document.createElement('li'); @@ -1331,9 +1331,9 @@ document.addEventListener('DOMContentLoaded', function() { const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); const seniorOfficialElement = document.getElementById('id_senior_official'); const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text; - const seniorOfficialTitle = extractTextById('contact_info_title', seniorOfficialDiv); - const seniorOfficialEmail = extractTextById('contact_info_email', seniorOfficialDiv); - const seniorOfficialPhone = extractTextById('contact_info_phone', seniorOfficialDiv); + const seniorOfficialTitle = seniorOfficialDiv.querySelector('.contact_info_title'); + const seniorOfficialEmail = seniorOfficialDiv.querySelector('.contact_info_email'); + const seniorOfficialPhone = seniorOfficialDiv.querySelector('.contact_info_phone'); let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`; const html_summary = `Recommendation:
` + @@ -1617,9 +1617,9 @@ document.addEventListener('DOMContentLoaded', function() { function updateContactInfo(data) { if (!contactList) return; - const titleSpan = contactList.querySelector("#contact_info_title"); - const emailSpan = contactList.querySelector("#contact_info_email"); - const phoneSpan = contactList.querySelector("#contact_info_phone"); + const titleSpan = contactList.querySelector(".contact_info_title"); + const emailSpan = contactList.querySelector(".contact_info_email"); + const phoneSpan = contactList.querySelector(".contact_info_phone"); if (titleSpan) { titleSpan.textContent = data.title || "None"; 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 84fb07f33..651478215 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,7 @@ {% if show_formatted_name %} {% if user.get_formatted_name %} - {{ user.get_formatted_name }} + {{ user.get_formatted_name }} {% else %} None {% endif %} @@ -16,7 +16,7 @@ {% if user|has_contact_info %} {# Title #} {% if user.title %} - {{ user.title }} + {{ user.title }} {% else %} None {% endif %} @@ -24,7 +24,7 @@ {# Email #} {% if user.email %} - {{ user.email }} + {{ user.email }} {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% else %} @@ -33,7 +33,7 @@ {# Phone #} {% if user.phone %} - {{ user.phone }} + {{ user.phone }}
{% else %} None
@@ -44,6 +44,6 @@ {% endif %} {% if user_verification_type and not skip_additional_contact_info %} - {{ user_verification_type }} + {{ user_verification_type }} {% endif %} From e013dccba1e59915d55d16fd7b5017f868ca9c27 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 6 Nov 2024 12:26:40 -0500 Subject: [PATCH 16/56] temp --- src/registrar/assets/js/get-gov-admin.js | 49 ++++++++++++++++++------ src/registrar/views/utility/api_views.py | 2 +- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index b63740f84..63bf17c19 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -92,7 +92,6 @@ function handlePortfolioSelection() { const suborganizationDropdown = django.jQuery("#id_sub_organization"); const suborganizationField = document.querySelector(".field-sub_organization"); const seniorOfficialField = document.querySelector(".field-senior_official"); - const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official"); const otherEmployeesField = document.querySelector(".field-other_contacts"); const noOtherContactsRationaleField = document.querySelector(".field-no_other_contacts_rationale"); const cisaRepresentativeFirstNameField = document.querySelector(".field-cisa_representative_first_name"); @@ -102,6 +101,8 @@ function handlePortfolioSelection() { const orgTypeFieldSetDetails = orgTypeFieldSet.nextElementSibling; const orgNameFieldSet = document.querySelector(".field-organization_name").parentElement; const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling; + const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official"); + const portfolioSeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly"); const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement; const portfolioOrgType = document.querySelector(".field-portfolio_organization_type .readonly"); const portfolioFederalTypeField = document.querySelector(".field-portfolio_federal_type"); @@ -145,39 +146,65 @@ function handlePortfolioSelection() { suborganizationDropdown.empty(); // update portfolio senior official + // need to add url and name // update portfolio organization type portfolioOrgType.innerText = portfolio.organization_type; - // update portfolio federal type portfolioFederalType.innerText = portfolio.federal_type - // update portfolio organization name portfolioOrgName.innerText = portfolio.organization_name; - // update portfolio federal agency portfolioFederalAgency.innerText = portfolio.federal_agency; - // update portfolio state portfolioStateTerritory.innerText = portfolio.state_territory; - // update portfolio address line 1 portfolioAddressLine1.innerText = portfolio.address_line1; - // update portfolio address line 2 portfolioAddressLine2.innerText = portfolio.address_line2; - // update portfolio city portfolioCity.innerText = portfolio.city; - // update portfolio zip code portfolioZipcode.innerText = portfolio.zipcode - // update portfolio urbanization portfolioUrbanization.innerText = portfolio.urbanization; } + function updatePortfolioSeniorOfficial(portfoliorSeniorOfficialField, senior_official) { + let seniorOfficialAddUrl = "/admin/registrar/seniorofficial/add/"; + + let readonlySeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly"); + + if (senior_official) { + + } else { + + } + + if (statusCode === 404) { + readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; + console.warn("Record not found: " + data.error); + }else { + console.error("Error in AJAX call: " + data.error); + } + return; + } + + // Update the "contact details" blurb beneath senior official + updateContactInfo(data); + showElement(contactList.parentElement); + + // Get the associated senior official with this federal agency + let seniorOfficialId = data.id; + let seniorOfficialName = [data.first_name, data.last_name].join(" "); + + if (readonlySeniorOfficial) { + let seniorOfficialLink = `${seniorOfficialName}` + readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + } + } + function updatePortfolioFieldsDataDynamicDisplay() { // the federal agency change listener fires on page load, which we don't want. @@ -366,9 +393,7 @@ function handlePortfolioSelection() { async function updatePortfolioFields() { if (!isPageLoading) { if (portfolioDropdown.val()) { - getPortfolio(portfolioDropdown.val()).then((portfolio) => { - console.log(portfolio); updatePortfolioFieldsData(portfolio); updatePortfolioFieldsDisplay(); updatePortfolioFieldsDataDynamicDisplay(); diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index 75273f51e..404126cbb 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -66,7 +66,7 @@ def get_portfolio_json(request): if portfolio.senior_official: senior_official = model_to_dict( portfolio.senior_official, - fields=["first_name", "last_name", "title", "phone", "email"] + fields=["id", "first_name", "last_name", "title", "phone", "email"] ) # The phone number field isn't json serializable, so we # convert this to a string first if it exists. From 5f65f618f02bbcdbb49bd0c546c1ea4af9f0e4bd Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 6 Nov 2024 13:28:27 -0500 Subject: [PATCH 17/56] wip --- src/registrar/assets/js/get-gov-admin.js | 73 +++++++++++-------- .../templates/admin/input_with_clipboard.html | 20 ++++- .../admin/includes/contact_detail_list.html | 9 ++- .../admin/includes/detail_table_fieldset.html | 2 +- 4 files changed, 69 insertions(+), 35 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 039cac579..40f3ddc1e 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -146,7 +146,7 @@ function handlePortfolioSelection() { suborganizationDropdown.empty(); // update portfolio senior official - // need to add url and name + updatePortfolioSeniorOfficial(portfolioSeniorOfficialField, portfolio.senior_official); // update portfolio organization type portfolioOrgType.innerText = portfolio.organization_type; @@ -171,38 +171,47 @@ function handlePortfolioSelection() { } - function updatePortfolioSeniorOfficial(portfoliorSeniorOfficialField, senior_official) { - let seniorOfficialAddUrl = "/admin/registrar/seniorofficial/add/"; + function updatePortfolioSeniorOfficial(seniorOfficialField, senior_official) { - let readonlySeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly"); + let seniorOfficial = seniorOfficialField.querySelector(".readonly"); + let seniorOfficialAddress = seniorOfficialField.querySelector(".dja-address-contact-list"); + - if (senior_official) { + if (senior_official) { + let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(' '); + let seniorOfficialLink = `${seniorOfficialName}` + seniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + updateSeniorOfficialContactInfo(seniorOfficialAddress, senior_official); + showElement(portfolioSeniorOfficialAddress); + } else { + portfolioSeniorOfficial.innerText = "No senior official found."; + hideElement(portfolioSeniorOfficialAddress); + } + } - } else { - + function updateSeniorOfficialContactInfo(addressField, senior_official) { + + const titleSpan = addressField.querySelector(".contact_info_title"); + const emailSpan = addressField.querySelector(".contact_info_email"); + const phoneSpan = addressField.querySelector(".contact_info_phone"); + const hiddenInput = addressField.querySelector("input"); + const copyButton = addressField.querySelector(".admin-icon-group"); + + if (titleSpan) { + titleSpan.textContent = senior_official.title || "None"; + }; + if (emailSpan) { + emailSpan.textContent = senior_official.email || "None"; + if (senior_official.email) { + hiddenInput.value = data.email; + showElement(copyButton); + }else { + hideElement(copyButton); } - - if (statusCode === 404) { - readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; - console.warn("Record not found: " + data.error); - }else { - console.error("Error in AJAX call: " + data.error); - } - return; - } - - // Update the "contact details" blurb beneath senior official - updateContactInfo(data); - showElement(contactList.parentElement); - - // Get the associated senior official with this federal agency - let seniorOfficialId = data.id; - let seniorOfficialName = [data.first_name, data.last_name].join(" "); - - if (readonlySeniorOfficial) { - let seniorOfficialLink = `${seniorOfficialName}` - readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; - } + } + if (phoneSpan) { + phoneSpan.textContent = senior_official.phone || "None"; + }; } function updatePortfolioFieldsDataDynamicDisplay() { @@ -217,9 +226,9 @@ function handlePortfolioSelection() { let federalAgencyValue = portfolioFederalAgency.innerText; let portfolioOrgTypeValue = portfolioOrgType.innerText; - if (federalAgencyValue && portfolioOrgTypeValue) { - handleFederalAgencyChange(federalAgencyValue, portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField); - } + // if (federalAgencyValue && portfolioOrgTypeValue) { + // handleFederalAgencyChange(federalAgencyValue, portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField); + // } // Handle dynamically hiding the urbanization field let portfolioStateTerritoryValue = portfolioStateTerritory.innerText; if (portfolioUrbanizationField && portfolioStateTerritoryValue) { diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index d6a016fd5..441890978 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -4,7 +4,25 @@ Template for an input field with a clipboard {% endcomment %} -{% if not invisible_input_field %} +{% if empty_field %} +
+ + +
+{% elif not invisible_input_field %}
{{ field }}
{% endif %} {% elif field.field.name == "portfolio_senior_official" %} - {% if original_object.portfolio.senior_official %} - - {% endif %} +
+ {% if original_object.portfolio.senior_official %} + {{ field.contents }}
+ {% endif %} + {% elif field.field.name == "other_contacts" %} {% if all_contacts.count > 2 %}
From 4e12b1295b21b6236f0279460cc799c8202233e5 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 6 Nov 2024 17:31:40 -0500 Subject: [PATCH 19/56] updatePortfolioFieldsDataDynamicDisplay cleanup --- src/registrar/admin.py | 2 +- src/registrar/assets/js/get-gov-admin.js | 153 +++--------------- .../admin/includes/detail_table_fieldset.html | 9 +- src/registrar/views/utility/api_views.py | 6 +- 4 files changed, 35 insertions(+), 135 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index b305ffb45..2812130a0 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1735,7 +1735,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None portfolio_senior_official.short_description = "Senior official" def portfolio_organization_type(self, obj): - return obj.portfolio.organization_type if obj.portfolio else "" + return DomainRequest.OrganizationChoices.get_org_label(obj.portfolio.organization_type) if obj.portfolio and obj.portfolio.organization_type else "-" portfolio_organization_type.short_description = "Organization type" def portfolio_federal_type(self, obj): return BranchChoices.get_branch_label(obj.portfolio.federal_type) if obj.portfolio and obj.portfolio.federal_type else "-" diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index ffd4a4c8e..b08a8c574 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -86,6 +86,10 @@ function handleSuborganizationFields( portfolioDropdown.on("change", toggleSuborganizationFields); } + +/** + * IMPORTANT NOTE: The logic in this method is paired dynamicPortfolioFields +*/ function handlePortfolioSelection() { // These dropdown are select2 fields so they must be interacted with via jquery const portfolioDropdown = django.jQuery("#id_portfolio"); @@ -121,6 +125,7 @@ function handlePortfolioSelection() { const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization"); const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly"); const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; + const seniorOfficialAddress = portfolioSeniorOfficialField.querySelector(".dja-address-contact-list"); let isPageLoading = true; function getPortfolio(portfolio_id) { @@ -174,19 +179,16 @@ function handlePortfolioSelection() { function updatePortfolioSeniorOfficial(seniorOfficialField, senior_official) { let seniorOfficial = seniorOfficialField.querySelector(".readonly"); - let seniorOfficialAddress = seniorOfficialField.querySelector(".dja-address-contact-list"); - let portfolioSeniorOfficialAddress = document.querySelector(".field-portfolio_senior_official .dja-address-contact-list"); - - + if (senior_official) { let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(' '); let seniorOfficialLink = `${seniorOfficialName}` seniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; updateSeniorOfficialContactInfo(seniorOfficialAddress, senior_official); - showElement(portfolioSeniorOfficialAddress); + showElement(seniorOfficialAddress); } else { portfolioSeniorOfficial.innerText = "No senior official found."; - hideElement(portfolioSeniorOfficialAddress); + hideElement(seniorOfficialAddress); } } @@ -216,20 +218,15 @@ function handlePortfolioSelection() { } function updatePortfolioFieldsDataDynamicDisplay() { - - // the federal agency change listener fires on page load, which we don't want. - var isInitialPageLoad = true - - // This is the additional information that exists beneath the SO element. - var contactList = document.querySelector(".field-portfolio_senior_official .dja-address-contact-list"); + document.addEventListener('DOMContentLoaded', function() { let federalAgencyValue = portfolioFederalAgency.innerText; let portfolioOrgTypeValue = portfolioOrgType.innerText; - // if (federalAgencyValue && portfolioOrgTypeValue) { - // handleFederalAgencyChange(federalAgencyValue, portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField); - // } + if (federalAgencyValue && portfolioOrgTypeValue) { + handleFederalAgencyChange(federalAgencyValue, portfolioOrgTypeValue); + } // Handle dynamically hiding the urbanization field let portfolioStateTerritoryValue = portfolioStateTerritory.innerText; if (portfolioUrbanizationField && portfolioStateTerritoryValue) { @@ -237,8 +234,9 @@ function handlePortfolioSelection() { } // Handle hiding the organization name field when the organization_type is federal. - // Run this first one page load, then secondly on a change event. handleOrganizationTypeChange(portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField); + + handleNoSeniorOfficialData(); }); function handleOrganizationTypeChange(portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField) { @@ -257,13 +255,7 @@ function handlePortfolioSelection() { } } - function handleFederalAgencyChange(federalAgencyValue, organizationTypeValue, portfolioOrgNameField, portfolioFederalTypeField) { - // Don't do anything on page load - if (isInitialPageLoad) { - isInitialPageLoad = false; - return; - } - + function handleFederalAgencyChange(federalAgencyValue, organizationTypeValue) { // There isn't a federal senior official associated with null records if (!federalAgencyValue) { return; @@ -273,70 +265,11 @@ function handlePortfolioSelection() { if (organizationTypeValue !== "federal") { portfolioOrgType.innerText = "Federal" } - }else { + } else { if (organizationTypeValue === "federal") { portfolioOrgType.innerText = "-" } - } - - handleOrganizationTypeChange(organizationTypeValue, portfolioOrgNameField, portfolioFederalTypeField); - - // We are missing these hooks that exist on the portfolio template, so I hardcoded them for now: - // - // - // - - let federalPortfolioApi = "/admin/api/get-federal-and-portfolio-types-from-federal-agency-json/"; - fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`) - .then(response => { - const statusCode = response.status; - return response.json().then(data => ({ statusCode, data })); - }) - .then(({ statusCode, data }) => { - if (data.error) { - console.error("Error in AJAX call: " + data.error); - return; - } - updateReadOnly(data.federal_type, '.field-portfolio_federal_type'); - }) - .catch(error => console.error("Error fetching federal and portfolio types: ", error)); - - hideElement(contactList.parentElement); - - let seniorOfficialAddUrl = "/admin/registrar/seniorofficial/add/"; - let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly"); - let seniorOfficialApi = "/admin/api/get-senior-official-from-federal-agency-json/"; - fetch(`${seniorOfficialApi}?agency_name=${selectedText}`) - .then(response => { - const statusCode = response.status; - return response.json().then(data => ({ statusCode, data })); - }) - .then(({ statusCode, data }) => { - if (data.error) { - if (statusCode === 404) { - readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; - console.warn("Record not found: " + data.error); - }else { - console.error("Error in AJAX call: " + data.error); - } - return; - } - - // Update the "contact details" blurb beneath senior official - updateContactInfo(data); - showElement(contactList.parentElement); - - // Get the associated senior official with this federal agency - let seniorOfficialId = data.id; - let seniorOfficialName = [data.first_name, data.last_name].join(" "); - - if (readonlySeniorOfficial) { - let seniorOfficialLink = `${seniorOfficialName}` - readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; - } - - }) - .catch(error => console.error("Error fetching senior official: ", error)); + } } @@ -348,56 +281,11 @@ function handlePortfolioSelection() { } } - /** - * Utility that selects a div from the DOM using selectorString, - * and updates a div within that div which has class of 'readonly' - * so that the text of the div is updated to updateText - * @param {*} updateText - * @param {*} selectorString - */ - function updateReadOnly(updateText, selectorString) { - // find the div by selectorString - const selectedDiv = document.querySelector(selectorString); - if (selectedDiv) { - // find the nested div with class 'readonly' inside the selectorString div - const readonlyDiv = selectedDiv.querySelector('.readonly'); - if (readonlyDiv) { - // Update the text content of the readonly div - readonlyDiv.textContent = updateText !== null ? updateText : '-'; - } + function handleNoSeniorOfficialData() { + if (portfolioSeniorOfficial.innerText.includes("No additional contact information found")) { + hideElement(seniorOfficialAddress); } } - - function updateContactInfo(data) { - if (!contactList) return; - - const titleSpan = contactList.querySelector(".contact_info_title"); - const emailSpan = contactList.querySelector(".contact_info_email"); - const phoneSpan = contactList.querySelector(".contact_info_phone"); - - if (titleSpan) { - titleSpan.textContent = data.title || "None"; - }; - - // Update the email field and the content for the clipboard - if (emailSpan) { - let copyButton = contactList.querySelector(".admin-icon-group"); - emailSpan.textContent = data.email || "None"; - if (data.email) { - const clipboardInput = contactList.querySelector(".admin-icon-group input"); - if (clipboardInput) { - clipboardInput.value = data.email; - }; - showElement(copyButton); - }else { - hideElement(copyButton); - } - } - - if (phoneSpan) { - phoneSpan.textContent = data.phone || "None"; - }; - } } async function updatePortfolioFields() { @@ -1426,6 +1314,7 @@ document.addEventListener('DOMContentLoaded', function() { /** An IIFE for dynamically changing some fields on the portfolio admin model + * IMPORTANT NOTE: The logic in this IIFE is paired handlePortfolioSelection */ (function dynamicPortfolioFields(){ 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 d81a64288..dd8827b7b 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,9 @@ 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 }}
+ {{ field.contents }} + {% else %} + No additional contact information found.
{% endif %}
{% elif field.field.name == "other_contacts" %} @@ -341,6 +343,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% elif field.field.name == "portfolio_senior_official" %}
+ {% comment %}fields_always_present=True will shortcut the contact_detail_list template when + 1. Senior official field should be hidden on domain request because no portfoloio is selected, which is desirable + 2. A portfolio is selected but there is no senior official on the portfolio, where the shortcut is not desirable + To solve 2, we use an else No additional contact information found on field.field.name == "portfolio_senior_official" + and we hide the placeholders from detail_table_fieldset in JS{% endcomment %} {% include "django/admin/includes/contact_detail_list.html" with user=original_object.portfolio.senior_official no_title_top_padding=field.is_readonly fields_always_present=True %}
{% elif field.field.name == "other_contacts" and original_object.other_contacts.all %} diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index 404126cbb..f806a05fe 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -6,6 +6,7 @@ from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import login_required from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email from registrar.models.portfolio import Portfolio +from registrar.models.domain_request import DomainRequest from registrar.utility.constants import BranchChoices logger = logging.getLogger(__name__) @@ -61,6 +62,9 @@ def get_portfolio_json(request): # map portfolio federal type portfolio_dict["federal_type"] = BranchChoices.get_branch_label(portfolio.federal_type) if portfolio.federal_type else "-" + + # map portfolio organization type + portfolio_dict["organization_type"] = DomainRequest.OrganizationChoices.get_org_label(portfolio.organization_type) if portfolio.organization_type else "-" # Add senior official information if it exists if portfolio.senior_official: @@ -84,7 +88,7 @@ def get_portfolio_json(request): ) portfolio_dict["federal_agency"] = federal_agency["agency"] else: - portfolio_dict["federal_agency"] = None + portfolio_dict["federal_agency"] = '-' return JsonResponse(portfolio_dict) From b54017840e687345914a43fc35273086312ccb21 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 6 Nov 2024 18:19:11 -0500 Subject: [PATCH 20/56] blank value displays on portfolio and suborg, return ids in json --- src/registrar/admin.py | 13 ++++++++++++- src/registrar/utility/admin_helpers.py | 10 ++++++++++ src/registrar/views/utility/api_views.py | 6 ++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 2812130a0..e46bc89fc 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -8,6 +8,7 @@ from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect from registrar.models.federal_agency import FederalAgency from registrar.utility.admin_helpers import ( + AutocompleteSelectWithPlaceholder, get_action_needed_reason_default_email, get_rejection_reason_default_email, get_field_links_as_list, @@ -236,7 +237,17 @@ class DomainRequestAdminForm(forms.ModelForm): widgets = { "current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False), "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), - "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False) + "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), + 'portfolio': AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field('portfolio'), + admin.site, + attrs={'data-placeholder': '---------'} + ), + 'sub_organization': AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field('sub_organization'), + admin.site, + attrs={'data-placeholder': '---------'} + ), } labels = { "action_needed_reason_email": "Email", diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 37e0a0e00..6780ff011 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -4,6 +4,7 @@ from django.utils.html import format_html from django.urls import reverse from django.utils.html import escape from registrar.models.utility.generic_helper import value_of_attribute +from django.contrib.admin.widgets import AutocompleteSelect def get_action_needed_reason_default_email(domain_request, action_needed_reason): @@ -94,3 +95,12 @@ def get_field_links_as_list( else: links = "".join(links) return format_html(f'
    {links}
') if links else msg_for_none + +class AutocompleteSelectWithPlaceholder(AutocompleteSelect): + """Override of the default autoselect element. This is because by default, + the autocomplete element clears data-placeholder""" + def build_attrs(self, base_attrs, extra_attrs=None): + attrs = super().build_attrs(base_attrs, extra_attrs=extra_attrs) + if 'data-placeholder' in base_attrs: + attrs['data-placeholder'] = base_attrs['data-placeholder'] + return attrs \ No newline at end of file diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index f806a05fe..aa8bfb30c 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -60,6 +60,8 @@ def get_portfolio_json(request): # Convert the portfolio to a dictionary portfolio_dict = model_to_dict(portfolio) + portfolio_dict["id"] = portfolio.id + # map portfolio federal type portfolio_dict["federal_type"] = BranchChoices.get_branch_label(portfolio.federal_type) if portfolio.federal_type else "-" @@ -84,9 +86,9 @@ def get_portfolio_json(request): if portfolio.federal_agency: federal_agency = model_to_dict( portfolio.federal_agency, - fields=["agency"] + fields=["agency", "id"] ) - portfolio_dict["federal_agency"] = federal_agency["agency"] + portfolio_dict["federal_agency"] = federal_agency else: portfolio_dict["federal_agency"] = '-' From ca523a38f27b21dfcd2d438e7c64f3c359ba53da Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 7 Nov 2024 05:58:06 -0500 Subject: [PATCH 21/56] added comments and refactored code for simplicity and readability --- src/registrar/assets/js/get-gov-admin.js | 349 +++++++++++++++++------ 1 file changed, 260 insertions(+), 89 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index b08a8c574..8b2133f81 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -87,7 +87,10 @@ function handleSuborganizationFields( } -/** +/** + * This function handles the portfolio selection as well as display of + * portfolio-related fields in the DomainRequest Form. + * * IMPORTANT NOTE: The logic in this method is paired dynamicPortfolioFields */ function handlePortfolioSelection() { @@ -107,6 +110,7 @@ function handlePortfolioSelection() { const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling; const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official"); const portfolioSeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly"); + const portfolioSeniorOfficialAddress = portfolioSeniorOfficialField.querySelector(".dja-address-contact-list"); const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement; const portfolioOrgType = document.querySelector(".field-portfolio_organization_type .readonly"); const portfolioFederalTypeField = document.querySelector(".field-portfolio_federal_type"); @@ -124,10 +128,21 @@ function handlePortfolioSelection() { const portfolioZipcode = document.querySelector(".field-portfolio_zipcode .readonly"); const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization"); const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly"); - const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; - const seniorOfficialAddress = portfolioSeniorOfficialField.querySelector(".dja-address-contact-list"); + const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; let isPageLoading = true; + /** + * Fetches portfolio data by ID using an AJAX call. + * + * @param {number|string} portfolio_id - The ID of the portfolio to retrieve. + * @returns {Promise} - A promise that resolves to the portfolio data object if successful, + * or null if there was an error. + * + * This function performs an asynchronous fetch request to retrieve portfolio data. + * If the request is successful, it returns the portfolio data as an object. + * If an error occurs during the request or the data contains an error, it logs the error + * to the console and returns null. + */ function getPortfolio(portfolio_id) { return fetch(`${portfolioJsonUrl}?id=${portfolio_id}`) .then(response => response.json()) @@ -145,14 +160,25 @@ function handlePortfolioSelection() { }); } + /** + * Updates various UI elements with the data from a given portfolio object. + * + * @param {Object} portfolio - The portfolio data object containing values to populate in the UI. + * + * This function updates multiple fields in the UI to reflect data in the `portfolio` object: + * - Clears and replaces selections in the `suborganizationDropdown` with values from `portfolio.suborganizations`. + * - Calls `updatePortfolioSeniorOfficial` to set the senior official information. + * - Sets the portfolio organization type, federal type, name, federal agency, and other address-related fields. + * + * The function expects that elements like `portfolioOrgType`, `portfolioFederalAgency`, etc., + * are already defined and accessible in the global scope. + */ function updatePortfolioFieldsData(portfolio) { // replace selections in suborganizationDropdown with // values in portfolio.suborganizations suborganizationDropdown.empty(); - // update portfolio senior official - updatePortfolioSeniorOfficial(portfolioSeniorOfficialField, portfolio.senior_official); - + updatePortfolioSeniorOfficial(portfolio.senior_official); // update portfolio organization type portfolioOrgType.innerText = portfolio.organization_type; // update portfolio federal type @@ -160,7 +186,7 @@ function handlePortfolioSelection() { // update portfolio organization name portfolioOrgName.innerText = portfolio.organization_name; // update portfolio federal agency - portfolioFederalAgency.innerText = portfolio.federal_agency; + portfolioFederalAgency.innerText = portfolio.federal_agency ? portfolio.federal_agency.agency : ''; // update portfolio state portfolioStateTerritory.innerText = portfolio.state_territory; // update portfolio address line 1 @@ -173,33 +199,61 @@ function handlePortfolioSelection() { portfolioZipcode.innerText = portfolio.zipcode // update portfolio urbanization portfolioUrbanization.innerText = portfolio.urbanization; - } - function updatePortfolioSeniorOfficial(seniorOfficialField, senior_official) { - - let seniorOfficial = seniorOfficialField.querySelector(".readonly"); - + /** + * Updates the UI to display the senior official information from a given object. + * + * @param {Object} senior_official - The senior official's data object, containing details like + * first name, last name, and ID. If `senior_official` is null, displays a default message. + * + * This function: + * - Displays the senior official's name as a link (if available) in the `portfolioSeniorOfficial` element. + * - If a senior official exists, it sets `portfolioSeniorOfficialAddress` to show the official's contact info + * and displays it by calling `updateSeniorOfficialContactInfo`. + * - If no senior official is provided, it hides `portfolioSeniorOfficialAddress` and shows a "No senior official found." message. + * + * Dependencies: + * - Expects the `portfolioSeniorOfficial` and `portfolioSeniorOfficialAddress` elements to be available globally. + * - Uses `showElement` and `hideElement` for visibility control. + */ + function updatePortfolioSeniorOfficial(senior_official) { if (senior_official) { let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(' '); let seniorOfficialLink = `${seniorOfficialName}` - seniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; - updateSeniorOfficialContactInfo(seniorOfficialAddress, senior_official); - showElement(seniorOfficialAddress); + portfolioSeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + updateSeniorOfficialContactInfo(portfolioSeniorOfficialAddress, senior_official); + showElement(portfolioSeniorOfficialAddress); } else { portfolioSeniorOfficial.innerText = "No senior official found."; - hideElement(seniorOfficialAddress); + hideElement(portfolioSeniorOfficialAddress); } } + /** + * Populates and displays contact information for a senior official within a specified address field element. + * + * @param {HTMLElement} addressField - The DOM element containing contact info fields for the senior official. + * @param {Object} senior_official - The senior official's data object, containing properties like title, email, and phone. + * + * This function: + * - Sets the `title`, `email`, and `phone` fields in `addressField` to display the senior official's data. + * - Updates the `titleSpan` with the official's title, or "None" if unavailable. + * - Updates the `emailSpan` with the official's email, or "None" if unavailable. + * - If an email is provided, populates `hiddenInput` with the email for copying and shows the `copyButton`. + * - If no email is provided, hides the `copyButton`. + * - Updates the `phoneSpan` with the official's phone number, or "None" if unavailable. + * + * Dependencies: + * - Uses `showElement` and `hideElement` to control visibility of the `copyButton`. + * - Expects `addressField` to have specific classes (.contact_info_title, .contact_info_email, etc.) for query selectors to work. + */ function updateSeniorOfficialContactInfo(addressField, senior_official) { - const titleSpan = addressField.querySelector(".contact_info_title"); const emailSpan = addressField.querySelector(".contact_info_email"); const phoneSpan = addressField.querySelector(".contact_info_phone"); const hiddenInput = addressField.querySelector("input"); const copyButton = addressField.querySelector(".admin-icon-group"); - if (titleSpan) { titleSpan.textContent = senior_official.title || "None"; }; @@ -217,77 +271,87 @@ function handlePortfolioSelection() { }; } + /** + * Dynamically updates the visibility of certain portfolio fields based on specific conditions. + * + * This function adjusts the display of fields within the portfolio UI based on: + * - The presence of a senior official's contact information. + * - The selected state or territory, affecting the visibility of the urbanization field. + * - The organization type (Federal vs. non-Federal), toggling the visibility of related fields. + * + * Functionality: + * 1. **Senior Official Contact Info Display**: + * - If `portfolioSeniorOfficial` contains "No additional contact information found", + * hides `portfolioSeniorOfficialAddress`; otherwise, shows it. + * + * 2. **Urbanization Field Display**: + * - Displays `portfolioUrbanizationField` only when the `portfolioStateTerritory` value is "PR" (Puerto Rico). + * + * 3. **Federal Organization Type Display**: + * - If `portfolioOrgType` is "Federal", hides `portfolioOrgNameField` and shows both `portfolioFederalAgencyField` + * and `portfolioFederalTypeField`. + * - If not Federal, shows `portfolioOrgNameField` and hides `portfolioFederalAgencyField` and `portfolioFederalTypeField`. + * + * Dependencies: + * - Expects specific elements to be defined globally (`portfolioSeniorOfficial`, `portfolioUrbanizationField`, etc.). + * - Uses `showElement` and `hideElement` functions to control element visibility. + */ function updatePortfolioFieldsDataDynamicDisplay() { - - document.addEventListener('DOMContentLoaded', function() { - let federalAgencyValue = portfolioFederalAgency.innerText; - let portfolioOrgTypeValue = portfolioOrgType.innerText; - - if (federalAgencyValue && portfolioOrgTypeValue) { - handleFederalAgencyChange(federalAgencyValue, portfolioOrgTypeValue); - } - // Handle dynamically hiding the urbanization field - let portfolioStateTerritoryValue = portfolioStateTerritory.innerText; - if (portfolioUrbanizationField && portfolioStateTerritoryValue) { - handleStateTerritoryChange(portfolioStateTerritoryValue, portfolioUrbanizationField); - } - - // Handle hiding the organization name field when the organization_type is federal. - handleOrganizationTypeChange(portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField); - - handleNoSeniorOfficialData(); - }); - - function handleOrganizationTypeChange(portfolioOrgTypeValue, portfolioOrgNameField, portfolioFederalTypeField) { - if (portfolioOrgTypeValue === "federal") { - hideElement(portfolioOrgNameField); - showElement(portfolioFederalAgencyField); - if (portfolioFederalTypeField) { - showElement(portfolioFederalTypeField); - } - } else { - showElement(portfolioOrgNameField); - hideElement(portfolioFederalAgencyField); - if (portfolioFederalTypeField) { - hideElement(portfolioFederalTypeField); - } - } + // Handle visibility of senior official's contact information + if (portfolioSeniorOfficial.innerText.includes("No additional contact information found")) { + hideElement(portfolioSeniorOfficialAddress); + } else { + showElement(portfolioSeniorOfficialAddress); } - function handleFederalAgencyChange(federalAgencyValue, organizationTypeValue) { - // There isn't a federal senior official associated with null records - if (!federalAgencyValue) { - return; - } - - if (federalAgencyValue !== "Non-Federal Agency") { - if (organizationTypeValue !== "federal") { - portfolioOrgType.innerText = "Federal" - } - } else { - if (organizationTypeValue === "federal") { - portfolioOrgType.innerText = "-" - } - } - + // Handle visibility of urbanization field based on state/territory value + let portfolioStateTerritoryValue = portfolioStateTerritory.innerText; + if (portfolioStateTerritoryValue === "PR") { + showElement(portfolioUrbanizationField); + } else { + hideElement(portfolioUrbanizationField); } - function handleStateTerritoryChange(portfolioStateTerritoryValue, portfolioUrbanizationField) { - if (portfolioStateTerritoryValue === "PR") { - showElement(portfolioUrbanizationField) - } else { - hideElement(portfolioUrbanizationField) - } + // Handle visibility of fields based on organization type (Federal vs. others) + if (portfolioOrgType.innerText === "Federal") { + hideElement(portfolioOrgNameField); + showElement(portfolioFederalAgencyField); + showElement(portfolioFederalTypeField); + } else { + showElement(portfolioOrgNameField); + hideElement(portfolioFederalAgencyField); + hideElement(portfolioFederalTypeField); } - function handleNoSeniorOfficialData() { - if (portfolioSeniorOfficial.innerText.includes("No additional contact information found")) { - hideElement(seniorOfficialAddress); - } - } } + /** + * Asynchronously updates portfolio fields in the UI based on the selected portfolio. + * + * This function first checks if the page is loading or if a portfolio selection is available + * in the `portfolioDropdown`. If a portfolio is selected, it retrieves the portfolio data, + * then updates the UI fields to display relevant data. If no portfolio is selected, it simply + * refreshes the UI field display without new data. The `isPageLoading` flag prevents + * updates during page load. + * + * Workflow: + * 1. **Check Page Loading**: + * - If `isPageLoading` is `true`, set it to `false` and exit to prevent redundant updates. + * - If `isPageLoading` is `false`, proceed with portfolio field updates. + * + * 2. **Portfolio Selection**: + * - If a portfolio is selected (`portfolioDropdown.val()`), fetch the portfolio data. + * - Once data is fetched, run three update functions: + * - `updatePortfolioFieldsData`: Populates specific portfolio-related fields. + * - `updatePortfolioFieldsDisplay`: Handles the visibility of general portfolio fields. + * - `updatePortfolioFieldsDataDynamicDisplay`: Manages conditional display based on portfolio data. + * - If no portfolio is selected, only refreshes the field display using `updatePortfolioFieldsDisplay`. + * + * Dependencies: + * - Expects global elements (`portfolioDropdown`, etc.) and `isPageLoading` flag to be defined. + * - Assumes `getPortfolio`, `updatePortfolioFieldsData`, `updatePortfolioFieldsDisplay`, and `updatePortfolioFieldsDataDynamicDisplay` are available as functions. + */ async function updatePortfolioFields() { if (!isPageLoading) { if (portfolioDropdown.val()) { @@ -304,13 +368,39 @@ function handlePortfolioSelection() { } } + /** + * Updates the Suborganization Dropdown with new data based on the provided portfolio ID. + * + * This function uses the Select2 jQuery plugin to update the dropdown by fetching suborganization + * data relevant to the selected portfolio. Upon invocation, it checks if Select2 is already initialized + * on `suborganizationDropdown` and destroys the existing instance to avoid duplication. + * It then reinitializes Select2 with customized options for an AJAX request, allowing the user to search + * and select suborganizations dynamically, with results filtered based on `portfolio_id`. + * + * Key workflow: + * 1. **Document Ready**: Ensures that the function runs only once the DOM is fully loaded. + * 2. **Check and Reinitialize Select2**: + * - If Select2 is already initialized, it’s destroyed to refresh with new options. + * - Select2 is reinitialized with AJAX settings for dynamic data fetching. + * 3. **AJAX Options**: + * - **Data Function**: Prepares the query by capturing the user's search term (`params.term`) + * and the provided `portfolio_id` to filter relevant suborganizations. + * - **Data Type**: Ensures responses are returned as JSON. + * - **Delay**: Introduces a 250ms delay to prevent excessive requests on fast typing. + * - **Cache**: Enables caching to improve performance. + * 4. **Theme and Placeholder**: + * - Sets the dropdown theme to ‘admin-autocomplete’ for consistent styling. + * - Allows clearing of the dropdown and displays a placeholder as defined in the HTML. + * + * Dependencies: + * - Requires `suborganizationDropdown` element, the jQuery library, and the Select2 plugin. + * - `portfolio_id` is passed to filter results relevant to a specific portfolio. + */ function updateSubOrganizationDropdown(portfolio_id) { django.jQuery(document).ready(function() { - if (suborganizationDropdown.data('select2')) { suborganizationDropdown.select2('destroy'); } - // Reinitialize Select2 with the updated URL suborganizationDropdown.select2({ ajax: { @@ -332,20 +422,70 @@ function handlePortfolioSelection() { }); } + /** + * Initializes the Suborganization Dropdown’s AJAX URL. + * + * This function sets the `data-ajax--url` attribute for `suborganizationDropdown`, defining the endpoint + * from which the dropdown will retrieve suborganization data. The endpoint is expected to return a JSON + * list of suborganizations suitable for Select2 integration. + * + * **Purpose**: To ensure the dropdown is configured with the correct endpoint for fetching data + * in real-time, thereby allowing seamless updates and filtering based on user selections. + * + * **Workflow**: + * - Attaches an endpoint URL to `suborganizationDropdown` for fetching suborganization data. + * + * Dependencies: + * - Expects `suborganizationDropdown` to be defined in the global scope. + */ function initializeSubOrganizationUrl() { suborganizationDropdown.attr("data-ajax--url", "/admin/api/get-suborganization-list-json/"); } + /** + * Updates the display of portfolio-related fields based on whether a portfolio is selected. + * + * This function controls the visibility of specific fields by showing or hiding them + * depending on the presence of a selected portfolio ID in the dropdown. When a portfolio + * is selected, certain fields are shown (like suborganizations and portfolio-related fields), + * while others are hidden (like senior official and other employee-related fields). + * + * Workflow: + * 1. **Retrieve Portfolio ID**: + * - Fetches the selected value from `portfolioDropdown` to check if a portfolio is selected. + * + * 2. **Display Fields for Selected Portfolio**: + * - If a `portfolio_id` exists, it updates the `suborganizationDropdown` for the specific portfolio. + * - Shows or hides various fields to display only relevant portfolio information: + * - Shows `suborganizationField`, `portfolioSeniorOfficialField`, and fields related to the portfolio organization. + * - Hides fields that are not applicable when a portfolio is selected, such as `seniorOfficialField` and `otherEmployeesField`. + * + * 3. **Display Fields for No Portfolio Selected**: + * - If no portfolio is selected (i.e., `portfolio_id` is falsy), it reverses the visibility: + * - Hides `suborganizationField` and other portfolio-specific fields. + * - Shows fields that are applicable when no portfolio is selected, such as the `seniorOfficialField`. + * + * Dependencies: + * - `portfolioDropdown` is assumed to be a dropdown element containing portfolio IDs. + * - `showElement` and `hideElement` utility functions are used to control element visibility. + * - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used. + */ function updatePortfolioFieldsDisplay() { + // Retrieve the selected portfolio ID let portfolio_id = portfolioDropdown.val(); if (portfolio_id) { + // A portfolio is selected - update suborganization dropdown and show/hide relevant fields + // Update suborganization dropdown for the selected portfolio updateSubOrganizationDropdown(portfolio_id); - + + // Show fields relevant to a selected portfolio showElement(suborganizationField); hideElement(seniorOfficialField); showElement(portfolioSeniorOfficialField); + + // Hide fields not applicable when a portfolio is selected hideElement(otherEmployeesField); hideElement(noOtherContactsRationaleField); hideElement(cisaRepresentativeFirstNameField); @@ -355,11 +495,18 @@ function handlePortfolioSelection() { hideElement(orgTypeFieldSetDetails); hideElement(orgNameFieldSet); hideElement(orgNameFieldSetDetails); + + // Show portfolio-specific fields showElement(portfolioOrgTypeFieldSet); showElement(portfolioOrgNameFieldSet); showElement(portfolioOrgNameFieldSetDetails); - }else { + } else { + // No portfolio is selected - reverse visibility of fields + + // Hide suborganization field as no portfolio is selected hideElement(suborganizationField); + + // Show fields that are relevant when no portfolio is selected showElement(seniorOfficialField); hideElement(portfolioSeniorOfficialField); showElement(otherEmployeesField); @@ -367,22 +514,46 @@ function handlePortfolioSelection() { showElement(cisaRepresentativeFirstNameField); showElement(cisaRepresentativeLastNameField); showElement(cisaRepresentativeEmailField); + + // Show organization type and name fields showElement(orgTypeFieldSet); showElement(orgTypeFieldSetDetails); showElement(orgNameFieldSet); showElement(orgNameFieldSetDetails); + + // Hide portfolio-specific fields that aren’t applicable hideElement(portfolioOrgTypeFieldSet); hideElement(portfolioOrgNameFieldSet); hideElement(portfolioOrgNameFieldSetDetails); } } - - initializeSubOrganizationUrl(); - // Run the function once on page startup, then attach an event listener - updatePortfolioFieldsDisplay(); - updatePortfolioFieldsDataDynamicDisplay(); - portfolioDropdown.on("change", updatePortfolioFields); + /** + * Initializes necessary data and display configurations for the portfolio fields. + * Also, sets up event listeners for interactive dropdown elements. + */ + function initializePortfolioSettings() { + // Set URL for the suborganization dropdown, enabling asynchronous data fetching. + initializeSubOrganizationUrl(); + + // Update the visibility of portfolio-related fields based on current dropdown selection. + updatePortfolioFieldsDisplay(); + + // Dynamically adjust the display of certain fields based on the selected portfolio's characteristics. + updatePortfolioFieldsDataDynamicDisplay(); + } + + /** + * Sets event listeners for key UI elements. + */ + function setEventListeners() { + // When the `portfolioDropdown` selection changes, refresh the displayed portfolio fields. + portfolioDropdown.on("change", updatePortfolioFields); + } + + // Run initial setup functions + initializePortfolioSettings(); + setEventListeners(); } // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> From 96c478e8fa94e30813c1e66cdecb116fc4e93c91 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 7 Nov 2024 06:30:45 -0500 Subject: [PATCH 22/56] modify text to links for portfolio fields --- src/registrar/assets/js/get-gov-admin.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 8b2133f81..d596a4367 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -291,6 +291,8 @@ function handlePortfolioSelection() { * - If `portfolioOrgType` is "Federal", hides `portfolioOrgNameField` and shows both `portfolioFederalAgencyField` * and `portfolioFederalTypeField`. * - If not Federal, shows `portfolioOrgNameField` and hides `portfolioFederalAgencyField` and `portfolioFederalTypeField`. + * - Certain text fields (Organization Type, Organization Name, Federal Type, Federal Agency) updated to links + * to edit the portfolio * * Dependencies: * - Expects specific elements to be defined globally (`portfolioSeniorOfficial`, `portfolioUrbanizationField`, etc.). @@ -324,6 +326,20 @@ function handlePortfolioSelection() { hideElement(portfolioFederalTypeField); } + // Modify the display of certain fields to convert them from text to links + // to edit the portfolio + let portfolio_id = portfolioDropdown.val(); + let portfolioEditUrl = `/admin/registrar/portfolio/${portfolio_id}/change/`; + let portfolioOrgTypeValue = portfolioOrgType.innerText; + portfolioOrgType.innerHTML = `${portfolioOrgTypeValue}`; + let portfolioOrgNameValue = portfolioOrgName.innerText; + portfolioOrgName.innerHTML = `${portfolioOrgNameValue}`; + let portfolioFederalAgencyValue = portfolioFederalAgency.innerText; + portfolioFederalAgency.innerHTML = `${portfolioFederalAgencyValue}`; + let portfolioFederalTypeValue = portfolioFederalType.innerText; + if (portfolioFederalTypeValue !== '-') + portfolioFederalType.innerHTML = `${portfolioFederalTypeValue}`; + } /** @@ -1652,7 +1668,7 @@ document.addEventListener('DOMContentLoaded', function() { updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); }else { if (readonlySeniorOfficial) { - let seniorOfficialLink = `${seniorOfficialName}` + let seniorOfficialLink = `${seniorOfficialName}` readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; } } From 2d5da9028686a359858d605c45fbb1461f19e97b Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 7 Nov 2024 06:38:36 -0500 Subject: [PATCH 23/56] git ignore VS code specific files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f2d82f599..3092682c2 100644 --- a/.gitignore +++ b/.gitignore @@ -171,6 +171,9 @@ node_modules # Vim *.swp +# VS Code +.vscode + # Compliance/trestle related docs/compliance/.trestle/cache From e23a2ee0ba67fa8ef19c66ba74c3bd8dc92ba003 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 7 Nov 2024 07:07:44 -0500 Subject: [PATCH 24/56] updated tests, linted some --- src/registrar/admin.py | 50 +++++++++++++++++------ src/registrar/tests/test_admin_request.py | 35 +++++++++++++++- src/registrar/utility/admin_helpers.py | 10 +++-- src/registrar/views/utility/api_views.py | 25 ++++++------ 4 files changed, 90 insertions(+), 30 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e46bc89fc..e93db9893 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -238,15 +238,11 @@ class DomainRequestAdminForm(forms.ModelForm): "current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False), "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), - 'portfolio': AutocompleteSelectWithPlaceholder( - DomainRequest._meta.get_field('portfolio'), - admin.site, - attrs={'data-placeholder': '---------'} + "portfolio": AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"} ), - 'sub_organization': AutocompleteSelectWithPlaceholder( - DomainRequest._meta.get_field('sub_organization'), - admin.site, - attrs={'data-placeholder': '---------'} + "sub_organization": AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field("sub_organization"), admin.site, attrs={"data-placeholder": "---------"} ), } labels = { @@ -1740,40 +1736,68 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): custom_election_board.admin_order_field = "is_election_board" # type: ignore custom_election_board.short_description = "Election office" # type: ignore - # 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 + portfolio_senior_official.short_description = "Senior official" + def portfolio_organization_type(self, obj): - return DomainRequest.OrganizationChoices.get_org_label(obj.portfolio.organization_type) if obj.portfolio and obj.portfolio.organization_type else "-" + return ( + DomainRequest.OrganizationChoices.get_org_label(obj.portfolio.organization_type) + if obj.portfolio and obj.portfolio.organization_type + else "-" + ) + portfolio_organization_type.short_description = "Organization type" + def portfolio_federal_type(self, obj): - return BranchChoices.get_branch_label(obj.portfolio.federal_type) if obj.portfolio and obj.portfolio.federal_type else "-" + return ( + BranchChoices.get_branch_label(obj.portfolio.federal_type) + if obj.portfolio and obj.portfolio.federal_type + else "-" + ) + portfolio_federal_type.short_description = "Federal type" + def portfolio_organization_name(self, obj): return obj.portfolio.organization_name if obj.portfolio else "" + portfolio_organization_name.short_description = "Organization name" + def portfolio_federal_agency(self, obj): return obj.portfolio.federal_agency if obj.portfolio else "" + portfolio_federal_agency.short_description = "Federal agency" + def portfolio_state_territory(self, obj): return obj.portfolio.state_territory if obj.portfolio else "" + portfolio_state_territory.short_description = "State, territory, or military post" + def portfolio_address_line1(self, obj): return obj.portfolio.address_line1 if obj.portfolio else "" + portfolio_address_line1.short_description = "Address line 1" + def portfolio_address_line2(self, obj): return obj.portfolio.address_line2 if obj.portfolio else "" + portfolio_address_line2.short_description = "Address line 2" + def portfolio_city(self, obj): return obj.portfolio.city if obj.portfolio else "" + portfolio_city.short_description = "City" + def portfolio_zipcode(self, obj): return obj.portfolio.zipcode if obj.portfolio else "" + portfolio_zipcode.short_description = "Zip code" + def portfolio_urbanization(self, obj): return obj.portfolio.urbanization if obj.portfolio else "" + portfolio_urbanization.short_description = "Urbanization" # This is just a placeholder. This field will be populated in the detail_table_fieldset view. @@ -1830,7 +1854,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "suborganization_state_territory", "creator", ] - } + }, ), (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), ( @@ -1903,7 +1927,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "portfolio_organization_type", "portfolio_federal_type", ] - } + }, ), ( "Organization name and mailing address", diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 57d7d9ac6..670d36363 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1526,7 +1526,7 @@ class TestDomainRequestAdmin(MockEppLib): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "copy-to-clipboard", count=4) + self.assertContains(response, "copy-to-clipboard", count=5) # Test that Creator counts display properly self.assertNotContains(response, "Approved domains") @@ -1626,6 +1626,17 @@ class TestDomainRequestAdmin(MockEppLib): readonly_fields = self.admin.get_readonly_fields(request, domain_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", @@ -1691,6 +1702,17 @@ class TestDomainRequestAdmin(MockEppLib): readonly_fields = self.admin.get_readonly_fields(request) self.maxDiff = None 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", @@ -1723,6 +1745,17 @@ class TestDomainRequestAdmin(MockEppLib): 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", diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 6780ff011..2852cbadc 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -95,12 +95,14 @@ def get_field_links_as_list( else: links = "".join(links) return format_html(f'
    {links}
') if links else msg_for_none - + + class AutocompleteSelectWithPlaceholder(AutocompleteSelect): """Override of the default autoselect element. This is because by default, the autocomplete element clears data-placeholder""" + def build_attrs(self, base_attrs, extra_attrs=None): attrs = super().build_attrs(base_attrs, extra_attrs=extra_attrs) - if 'data-placeholder' in base_attrs: - attrs['data-placeholder'] = base_attrs['data-placeholder'] - return attrs \ No newline at end of file + if "data-placeholder" in base_attrs: + attrs["data-placeholder"] = base_attrs["data-placeholder"] + return attrs diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index aa8bfb30c..aedfa757a 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -6,7 +6,6 @@ from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import login_required from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email from registrar.models.portfolio import Portfolio -from registrar.models.domain_request import DomainRequest from registrar.utility.constants import BranchChoices logger = logging.getLogger(__name__) @@ -63,16 +62,21 @@ def get_portfolio_json(request): portfolio_dict["id"] = portfolio.id # map portfolio federal type - portfolio_dict["federal_type"] = BranchChoices.get_branch_label(portfolio.federal_type) if portfolio.federal_type else "-" + portfolio_dict["federal_type"] = ( + BranchChoices.get_branch_label(portfolio.federal_type) if portfolio.federal_type else "-" + ) # map portfolio organization type - portfolio_dict["organization_type"] = DomainRequest.OrganizationChoices.get_org_label(portfolio.organization_type) if portfolio.organization_type else "-" - + portfolio_dict["organization_type"] = ( + DomainRequest.OrganizationChoices.get_org_label(portfolio.organization_type) + if portfolio.organization_type + else "-" + ) + # Add senior official information if it exists if portfolio.senior_official: senior_official = model_to_dict( - portfolio.senior_official, - fields=["id", "first_name", "last_name", "title", "phone", "email"] + portfolio.senior_official, fields=["id", "first_name", "last_name", "title", "phone", "email"] ) # The phone number field isn't json serializable, so we # convert this to a string first if it exists. @@ -84,13 +88,10 @@ def get_portfolio_json(request): # Add federal agency information if it exists if portfolio.federal_agency: - federal_agency = model_to_dict( - portfolio.federal_agency, - fields=["agency", "id"] - ) + federal_agency = model_to_dict(portfolio.federal_agency, fields=["agency", "id"]) portfolio_dict["federal_agency"] = federal_agency else: - portfolio_dict["federal_agency"] = '-' + portfolio_dict["federal_agency"] = "-" return JsonResponse(portfolio_dict) @@ -115,7 +116,7 @@ def get_suborganization_list_json(request): # Add suborganizations related to this portfolio suborganizations = portfolio.portfolio_suborganizations.all().values("id", "name") results = [{"id": sub["id"], "text": sub["name"]} for sub in suborganizations] - return JsonResponse({"results": results, "pagination": { "more": False }}) + return JsonResponse({"results": results, "pagination": {"more": False}}) @login_required From bc86c44cfca8035afc6c79212ce468f494857269 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 7 Nov 2024 10:10:13 -0500 Subject: [PATCH 25/56] linter --- src/registrar/admin.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e93db9893..399ab5377 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1740,7 +1740,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None - portfolio_senior_official.short_description = "Senior official" + portfolio_senior_official.short_description = "Senior official" # type: ignore def portfolio_organization_type(self, obj): return ( @@ -1749,7 +1749,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): else "-" ) - portfolio_organization_type.short_description = "Organization type" + portfolio_organization_type.short_description = "Organization type" # type: ignore def portfolio_federal_type(self, obj): return ( @@ -1758,47 +1758,47 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): else "-" ) - portfolio_federal_type.short_description = "Federal type" + portfolio_federal_type.short_description = "Federal type" # type: ignore def portfolio_organization_name(self, obj): return obj.portfolio.organization_name if obj.portfolio else "" - portfolio_organization_name.short_description = "Organization name" + portfolio_organization_name.short_description = "Organization name" # type: ignore def portfolio_federal_agency(self, obj): return obj.portfolio.federal_agency if obj.portfolio else "" - portfolio_federal_agency.short_description = "Federal agency" + portfolio_federal_agency.short_description = "Federal agency" # type: ignore def portfolio_state_territory(self, obj): return obj.portfolio.state_territory if obj.portfolio else "" - portfolio_state_territory.short_description = "State, territory, or military post" + portfolio_state_territory.short_description = "State, territory, or military post" # type: ignore def portfolio_address_line1(self, obj): return obj.portfolio.address_line1 if obj.portfolio else "" - portfolio_address_line1.short_description = "Address line 1" + portfolio_address_line1.short_description = "Address line 1" # type: ignore def portfolio_address_line2(self, obj): return obj.portfolio.address_line2 if obj.portfolio else "" - portfolio_address_line2.short_description = "Address line 2" + portfolio_address_line2.short_description = "Address line 2" # type: ignore def portfolio_city(self, obj): return obj.portfolio.city if obj.portfolio else "" - portfolio_city.short_description = "City" + portfolio_city.short_description = "City" # type: ignore def portfolio_zipcode(self, obj): return obj.portfolio.zipcode if obj.portfolio else "" - portfolio_zipcode.short_description = "Zip code" + portfolio_zipcode.short_description = "Zip code" # type: ignore def portfolio_urbanization(self, obj): return obj.portfolio.urbanization if obj.portfolio else "" - portfolio_urbanization.short_description = "Urbanization" + portfolio_urbanization.short_description = "Urbanization" # type: ignore # This is just a placeholder. This field will be populated in the detail_table_fieldset view. # This is not a field that exists on the model. From 0b284350e9926b4f85e36086f427f0bf72d65d9e Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 7 Nov 2024 11:13:58 -0500 Subject: [PATCH 26/56] api tests written --- src/registrar/tests/test_api.py | 73 ++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/registrar/tests/test_api.py b/src/registrar/tests/test_api.py index aba2d52e8..e5732a0ad 100644 --- a/src/registrar/tests/test_api.py +++ b/src/registrar/tests/test_api.py @@ -2,7 +2,8 @@ from django.urls import reverse from django.test import TestCase, Client from registrar.models import FederalAgency, SeniorOfficial, User, DomainRequest from django.contrib.auth import get_user_model -from registrar.tests.common import create_superuser, create_user, completed_domain_request +from registrar.models.portfolio import Portfolio +from registrar.tests.common import create_superuser, create_test_user, create_user, completed_domain_request from api.tests.common import less_console_noise_decorator from registrar.utility.constants import BranchChoices @@ -74,6 +75,76 @@ class GetSeniorOfficialJsonTest(TestCase): self.assertEqual(data["error"], "Senior Official not found") +class GetPortfolioJsonTest(TestCase): + def setUp(self): + self.client = Client() + self.user = create_test_user() + self.superuser = create_superuser() + self.analyst_user = create_user() + + self.agency = FederalAgency.objects.create(agency="Test Agency") + self.senior_official = SeniorOfficial.objects.create( + first_name="John", last_name="Doe", title="Director", federal_agency=self.agency + ) + self.portfolio = Portfolio.objects.create( + creator=self.user, federal_agency=self.agency, senior_official=self.senior_official, + organization_name="Org name", organization_type=Portfolio.OrganizationChoices.FEDERAL, + ) + + self.api_url = reverse("get-portfolio-json") + + def tearDown(self): + Portfolio.objects.all().delete() + User.objects.all().delete() + SeniorOfficial.objects.all().delete() + FederalAgency.objects.all().delete() + + @less_console_noise_decorator + def test_get_portfolio_authenticated_superuser(self): + """Test that a superuser can get the portfolio information.""" + self.client.force_login(self.superuser) + response = self.client.get(self.api_url, {"id": self.portfolio.id}) + self.assertEqual(response.status_code, 200) + portfolio = response.json() + self.assertEqual(portfolio["id"], self.portfolio.id) + self.assertEqual(portfolio["creator"], self.user.id) + self.assertEqual(portfolio["organization_name"], self.portfolio.organization_name) + self.assertEqual(portfolio["organization_type"], "Federal") + self.assertEqual(portfolio["notes"], None) + self.assertEqual(portfolio["federal_agency"]["id"], self.agency.id) + self.assertEqual(portfolio["federal_agency"]["agency"], self.agency.agency) + self.assertEqual(portfolio["senior_official"]["id"], self.senior_official.id) + self.assertEqual(portfolio["senior_official"]["first_name"], self.senior_official.first_name) + self.assertEqual(portfolio["senior_official"]["last_name"], self.senior_official.last_name) + self.assertEqual(portfolio["senior_official"]["title"], self.senior_official.title) + self.assertEqual(portfolio["senior_official"]["phone"], None) + self.assertEqual(portfolio["senior_official"]["email"], None) + self.assertEqual(portfolio["federal_type"], "-") + + @less_console_noise_decorator + def test_get_portfolio_json_authenticated_analyst(self): + """Test that an analyst user can fetch the portfolio's information.""" + self.client.force_login(self.analyst_user) + response = self.client.get(self.api_url, {"id": self.portfolio.id}) + self.assertEqual(response.status_code, 200) + portfolio = response.json() + self.assertEqual(portfolio["id"], self.portfolio.id) + + @less_console_noise_decorator + def test_get_portfolio_json_unauthenticated(self): + """Test that an unauthenticated user receives a 403 with an error message.""" + self.client.force_login(self.user) + response = self.client.get(self.api_url, {"id": self.portfolio.id}) + self.assertEqual(response.status_code, 302) + + @less_console_noise_decorator + def test_get_portfolio_json_not_found(self): + """Test that a request for a non-existent portfolio returns a 404 with an error message.""" + self.client.force_login(self.superuser) + response = self.client.get(self.api_url, {"id": -1}) + self.assertEqual(response.status_code, 404) + + class GetFederalPortfolioTypeJsonTest(TestCase): def setUp(self): self.client = Client() From 4a07c9a4fff412c803f77aa4f0bb62ad0ec8b723 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 7 Nov 2024 11:19:18 -0500 Subject: [PATCH 27/56] comment fix --- src/registrar/assets/js/get-gov-admin.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index d596a4367..bb8177f54 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -546,7 +546,6 @@ function handlePortfolioSelection() { /** * Initializes necessary data and display configurations for the portfolio fields. - * Also, sets up event listeners for interactive dropdown elements. */ function initializePortfolioSettings() { // Set URL for the suborganization dropdown, enabling asynchronous data fetching. From 54798233838890e3587557ecfe8cdbb8d0726a31 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Thu, 7 Nov 2024 12:30:11 -0500 Subject: [PATCH 28/56] updates thus far --- src/registrar/admin.py | 2 - src/registrar/models/domain_invitation.py | 10 +++-- src/registrar/templates/domain_users.html | 4 +- src/registrar/tests/test_views_domain.py | 6 +-- src/registrar/views/domain.py | 37 +++++-------------- src/registrar/views/utility/__init__.py | 2 +- .../views/utility/permission_views.py | 2 + 7 files changed, 25 insertions(+), 38 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 7f26e9ab6..0b96b4c48 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1354,9 +1354,7 @@ class DomainInvitationAdmin(ListHeaderAdmin): class Meta: model = models.DomainInvitation fields = "__all__" - - print(DomainInvitation.objects.all()) _meta = Meta() # Columns diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index bed1ef88b..11f73c6c2 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -75,8 +75,12 @@ class DomainInvitation(TimeStampedModel): # the invitation was retrieved. Log that this occurred. logger.warn("Invitation %s was retrieved for a role that already exists.", self) - @transition(field=status, source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED) + @transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED) def cancel_invitation(self): logger.info(f"Invitation for {self.domain} has been cancelled.") - - + + + @transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED) + def update_cancellation_status(self): + pass + diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index c19c9bf69..4bca62ed4 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -111,7 +111,7 @@ - {% if domain.invitations.exists %} + {% if domain_invitations|length > 0%}

Invitations

@@ -125,7 +125,7 @@ - {% for invitation in domain.invitations.all %} + {% for invitation in domain_invitations %}
{{ invitation.email }} diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index a375493be..c0c1d6c60 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -708,7 +708,7 @@ class TestDomainManagers(TestDomainOverview): invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) mock_client = MockSESClient() with boto3_mocking.clients.handler_for("sesv2", mock_client): - self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) + self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id})) mock_client.EMAILS_SENT.clear() with self.assertRaises(DomainInvitation.DoesNotExist): DomainInvitation.objects.get(id=invitation.id) @@ -720,7 +720,7 @@ class TestDomainManagers(TestDomainOverview): invitation, _ = DomainInvitation.objects.get_or_create( domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED ) - response = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}), follow=True) + response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True) # Assert that an error message is displayed to the user self.assertContains(response, f"Invitation to {email_address} has already been retrieved.") # Assert that the Cancel link is not displayed @@ -740,7 +740,7 @@ class TestDomainManagers(TestDomainOverview): self.client.force_login(other_user) mock_client = MagicMock() with boto3_mocking.clients.handler_for("sesv2", mock_client): - result = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) + result = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id})) self.assertEqual(result.status_code, 403) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index ab368b614..8781b6eaf 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -62,7 +62,7 @@ from epplibwrapper import ( ) from ..utility.email import send_templated_email, EmailSendingError -from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView, DomainInvitationUpdateView +from .utility import DomainPermissionView, DomainInvitationUpdateView logger = logging.getLogger(__name__) @@ -844,6 +844,9 @@ class DomainUsersView(DomainBaseView): # Get the email of the current user context["current_user_email"] = self.request.user.email + # Filter out the cancelled domain invitations + context["domain_invitations"] = DomainInvitation.objects.exclude(status="canceled") + return context def _add_booleans_to_context(self, context): @@ -951,6 +954,9 @@ class DomainAddUserView(DomainFormBaseView): self.request, f"{email} is already a manager for this domain.", ) + elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: + add_success = True + invite.update_cancellation_status() else: add_success = False # else if it has been sent but not accepted @@ -958,6 +964,8 @@ class DomainAddUserView(DomainFormBaseView): except Exception: logger.error("An error occured") + + try: send_templated_email( "emails/domain_invitation.txt", @@ -1051,33 +1059,9 @@ class DomainAddUserView(DomainFormBaseView): return redirect(self.get_success_url()) -# # The order of the superclasses matters here. BaseDeleteView has a bug where the -# # "form_valid" function does not call super, so it cannot use SuccessMessageMixin. -# # The workaround is to use SuccessMessageMixin first. -# class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView): -# object: DomainInvitation # workaround for type mismatch in DeleteView - -# def post(self, request, *args, **kwargs): -# """Override post method in order to error in the case when the -# domain invitation status is RETRIEVED""" -# self.object = self.get_object() -# form = self.get_form() -# if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: -# return self.form_valid(form) -# else: -# # Produce an error message if the domain invatation status is RETRIEVED -# messages.error(request, f"Invitation to {self.object.email} has already been retrieved.") -# return HttpResponseRedirect(self.get_success_url()) - -# def get_success_url(self): -# return reverse("domain-users", kwargs={"pk": self.object.domain.id}) - -# def get_success_message(self, cleaned_data): -# return f"Canceled invitation to {self.object.email}." - - class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationUpdateView): object: DomainInvitation + fields = [] def post(self, request, *args, **kwargs): """Override post method in order to error in the case when the @@ -1099,7 +1083,6 @@ class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationUpdateView return f"Canceled invitation to {self.object.email}." - class DomainDeleteUserView(UserDomainRolePermissionDeleteView): """Inside of a domain's user management, a form for deleting users.""" diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index d97ec035d..df47d98af 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -9,6 +9,6 @@ from .permission_views import ( DomainRequestWizardPermissionView, PortfolioMembersPermission, DomainRequestPortfolioViewonlyView, - DomainInvitationUpdateView + DomainInvitationUpdateView, ) from .api_views import get_senior_official_from_federal_agency_json diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 23beb657f..81c8afb83 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -168,10 +168,12 @@ class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteVie model = DomainInvitation object: DomainInvitation # workaround for type mismatch in DeleteView + class DomainInvitationUpdateView(DomainInvitationPermission, UpdateView, abc.ABC): model = DomainInvitation object: DomainInvitation + class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC): """Abstract view for deleting a DomainRequest.""" From f6062daea31a773c347e71a86d2c8115425a3bd9 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 8 Nov 2024 12:25:30 -0500 Subject: [PATCH 29/56] lint --- src/registrar/tests/test_api.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/registrar/tests/test_api.py b/src/registrar/tests/test_api.py index e5732a0ad..f0d5dbcec 100644 --- a/src/registrar/tests/test_api.py +++ b/src/registrar/tests/test_api.py @@ -87,9 +87,12 @@ class GetPortfolioJsonTest(TestCase): first_name="John", last_name="Doe", title="Director", federal_agency=self.agency ) self.portfolio = Portfolio.objects.create( - creator=self.user, federal_agency=self.agency, senior_official=self.senior_official, - organization_name="Org name", organization_type=Portfolio.OrganizationChoices.FEDERAL, - ) + creator=self.user, + federal_agency=self.agency, + senior_official=self.senior_official, + organization_name="Org name", + organization_type=Portfolio.OrganizationChoices.FEDERAL, + ) self.api_url = reverse("get-portfolio-json") @@ -120,7 +123,7 @@ class GetPortfolioJsonTest(TestCase): self.assertEqual(portfolio["senior_official"]["phone"], None) self.assertEqual(portfolio["senior_official"]["email"], None) self.assertEqual(portfolio["federal_type"], "-") - + @less_console_noise_decorator def test_get_portfolio_json_authenticated_analyst(self): """Test that an analyst user can fetch the portfolio's information.""" From 15b5252038da4d0d648b74e0bbe630f2d1a525fc Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 8 Nov 2024 14:19:49 -0500 Subject: [PATCH 30/56] senior official text found --- src/registrar/assets/js/get-gov-admin.js | 2 +- .../templates/django/admin/includes/detail_table_fieldset.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index bb8177f54..958d53ecb 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -301,7 +301,7 @@ function handlePortfolioSelection() { function updatePortfolioFieldsDataDynamicDisplay() { // Handle visibility of senior official's contact information - if (portfolioSeniorOfficial.innerText.includes("No additional contact information found")) { + if (portfolioSeniorOfficial.innerText.includes("No senior official found.")) { hideElement(portfolioSeniorOfficialAddress); } else { showElement(portfolioSeniorOfficialAddress); 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 dd8827b7b..1cb835988 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -71,7 +71,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% if original_object.portfolio.senior_official %} {{ field.contents }} {% else %} - No additional contact information found.
+ No senior official found.
{% endif %} {% elif field.field.name == "other_contacts" %} From ddb56913aa4c1737eb65bf82fd3a04a902e0e0a9 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 12 Nov 2024 11:10:14 -0500 Subject: [PATCH 31/56] final ish updates --- src/registrar/models/domain_invitation.py | 4 +-- src/registrar/tests/test_views_domain.py | 27 +++---------------- src/registrar/tests/test_views_request.py | 2 +- src/registrar/views/domain.py | 33 ++++++++++++----------- 4 files changed, 24 insertions(+), 42 deletions(-) diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index 11f73c6c2..65c6b0a85 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -77,10 +77,8 @@ class DomainInvitation(TimeStampedModel): @transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED) def cancel_invitation(self): - logger.info(f"Invitation for {self.domain} has been cancelled.") - + pass @transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED) def update_cancellation_status(self): pass - diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index c0c1d6c60..a69a77ab9 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -706,32 +706,13 @@ class TestDomainManagers(TestDomainOverview): """Posting to the delete view deletes an invitation.""" email_address = "mayor@igorville.gov" invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) - mock_client = MockSESClient() - with boto3_mocking.clients.handler_for("sesv2", mock_client): - self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id})) - mock_client.EMAILS_SENT.clear() - with self.assertRaises(DomainInvitation.DoesNotExist): - DomainInvitation.objects.get(id=invitation.id) - - @less_console_noise_decorator - def test_domain_invitation_cancel_retrieved_invitation(self): - """Posting to the delete view when invitation retrieved returns an error message""" - email_address = "mayor@igorville.gov" - invitation, _ = DomainInvitation.objects.get_or_create( - domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED - ) - response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True) - # Assert that an error message is displayed to the user - self.assertContains(response, f"Invitation to {email_address} has already been retrieved.") - # Assert that the Cancel link is not displayed - self.assertNotContains(response, "Cancel") - # Assert that the DomainInvitation is not deleted - self.assertTrue(DomainInvitation.objects.filter(id=invitation.id).exists()) - DomainInvitation.objects.filter(email=email_address).delete() + self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id})) + invitation = DomainInvitation.objects.get(id=invitation.id) + self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED) @less_console_noise_decorator def test_domain_invitation_cancel_no_permissions(self): - """Posting to the delete view as a different user should fail.""" + """Posting to the cancel view as a different user should fail.""" email_address = "mayor@igorville.gov" invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index e4234e6a3..73e538df3 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -491,7 +491,7 @@ class DomainRequestTests(TestWithUser, WebTest): # Review page contains all the previously entered data # Let's make sure the long org name is displayed self.assertContains(review_page, "Federal") - self.assertContains(review_page, "executive") + self.assertContains(review_page, "Executive") self.assertContains(review_page, "Testorg") self.assertContains(review_page, "address 1") self.assertContains(review_page, "address 2") diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 8781b6eaf..05720ca9f 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -912,6 +912,22 @@ class DomainAddUserView(DomainFormBaseView): existing_org_invitation and existing_org_invitation.portfolio != requestor_org ) + def _check_invite_status(self, invite, email): + if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: + messages.warning( + self.request, + f"{email} is already a manager for this domain.", + ) + return False + elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: + invite.update_cancellation_status() + invite.save() + return True + else: + # else if it has been sent but not accepted + messages.warning(self.request, f"{email} has already been invited to this domain") + return False + def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True): """Performs the sending of the domain invitation email, does not make a domain information object @@ -948,24 +964,10 @@ class DomainAddUserView(DomainFormBaseView): try: invite = DomainInvitation.objects.get(email=email, domain=self.object) # check if the invite has already been accepted - if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: - add_success = False - messages.warning( - self.request, - f"{email} is already a manager for this domain.", - ) - elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED: - add_success = True - invite.update_cancellation_status() - else: - add_success = False - # else if it has been sent but not accepted - messages.warning(self.request, f"{email} has already been invited to this domain") + add_success = self._check_invite_status(invite, email) except Exception: logger.error("An error occured") - - try: send_templated_email( "emails/domain_invitation.txt", @@ -1070,6 +1072,7 @@ class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationUpdateView form = self.get_form() if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: self.object.cancel_invitation() + self.object.save() return self.form_valid(form) else: # Produce an error message if the domain invatation status is RETRIEVED From 1aa96cb70020134b33d2579eeece0019feba1875 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 12 Nov 2024 13:31:42 -0500 Subject: [PATCH 32/56] added test back --- src/registrar/tests/test_views_domain.py | 18 +++++++++++++++++- src/registrar/views/domain.py | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index a69a77ab9..0adef68b7 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -709,7 +709,23 @@ class TestDomainManagers(TestDomainOverview): self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id})) invitation = DomainInvitation.objects.get(id=invitation.id) self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED) - + + @less_console_noise_decorator + def test_domain_invitation_cancel_retrieved_invitation(self): + """Posting to the cancel view when invitation retrieved returns an error message""" + email_address = "mayor@igorville.gov" + invitation, _ = DomainInvitation.objects.get_or_create( + domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED + ) + response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True) + # Assert that an error message is displayed to the user + self.assertContains(response, f"Invitation to {email_address} has already been retrieved.") + # Assert that the Cancel link is not displayed + self.assertNotContains(response, "Cancel") + # Assert that the DomainInvitation is not deleted + self.assertTrue(DomainInvitation.objects.filter(id=invitation.id).exists()) + DomainInvitation.objects.filter(email=email_address).delete() + @less_console_noise_decorator def test_domain_invitation_cancel_no_permissions(self): """Posting to the cancel view as a different user should fail.""" diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 05720ca9f..b0ce272b4 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -963,7 +963,7 @@ class DomainAddUserView(DomainFormBaseView): # Check to see if an invite has already been sent try: invite = DomainInvitation.objects.get(email=email, domain=self.object) - # check if the invite has already been accepted + # check if the invite has already been accepted or has a canceled invite add_success = self._check_invite_status(invite, email) except Exception: logger.error("An error occured") From edfce15e0b25c839854e120b6db476fb28c31197 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 12 Nov 2024 13:37:32 -0500 Subject: [PATCH 33/56] added more comments --- src/registrar/tests/test_views_domain.py | 4 ++-- src/registrar/views/domain.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 0adef68b7..0ae476395 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -709,7 +709,7 @@ class TestDomainManagers(TestDomainOverview): self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id})) invitation = DomainInvitation.objects.get(id=invitation.id) self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED) - + @less_console_noise_decorator def test_domain_invitation_cancel_retrieved_invitation(self): """Posting to the cancel view when invitation retrieved returns an error message""" @@ -725,7 +725,7 @@ class TestDomainManagers(TestDomainOverview): # Assert that the DomainInvitation is not deleted self.assertTrue(DomainInvitation.objects.filter(id=invitation.id).exists()) DomainInvitation.objects.filter(email=email_address).delete() - + @less_console_noise_decorator def test_domain_invitation_cancel_no_permissions(self): """Posting to the cancel view as a different user should fail.""" diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index b0ce272b4..7d10bf163 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -913,6 +913,7 @@ class DomainAddUserView(DomainFormBaseView): ) def _check_invite_status(self, invite, email): + """Check invitation status if it is canceled or retrieved, and gives the appropiate response""" if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: messages.warning( self.request, From d59c3a98804fe35d406f1acea60272b6e0d3755b Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 12 Nov 2024 13:42:06 -0500 Subject: [PATCH 34/56] added more comments --- src/registrar/models/domain_invitation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index 65c6b0a85..396250a38 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -77,8 +77,10 @@ class DomainInvitation(TimeStampedModel): @transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED) def cancel_invitation(self): + """When an invitation is cancel, change the status to canceled""" pass @transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED) def update_cancellation_status(self): + """When an invitation is canceled but reinvited, update the status to invited""" pass From c04b47797d3ac8cbf72405019cbee255da84a58a Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 12 Nov 2024 14:04:56 -0500 Subject: [PATCH 35/56] updates --- src/registrar/views/domain.py | 4 ++-- src/registrar/views/utility/__init__.py | 2 +- src/registrar/views/utility/permission_views.py | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 7d10bf163..84318ea88 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -62,7 +62,7 @@ from epplibwrapper import ( ) from ..utility.email import send_templated_email, EmailSendingError -from .utility import DomainPermissionView, DomainInvitationUpdateView +from .utility import DomainPermissionView, DomainInvitationPermissionCancelView logger = logging.getLogger(__name__) @@ -1062,7 +1062,7 @@ class DomainAddUserView(DomainFormBaseView): return redirect(self.get_success_url()) -class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationUpdateView): +class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView): object: DomainInvitation fields = [] diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index df47d98af..14628a9b0 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -9,6 +9,6 @@ from .permission_views import ( DomainRequestWizardPermissionView, PortfolioMembersPermission, DomainRequestPortfolioViewonlyView, - DomainInvitationUpdateView, + DomainInvitationPermissionCancelView, ) from .api_views import get_senior_official_from_federal_agency_json diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 81c8afb83..5ba84b3d6 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -169,7 +169,9 @@ class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteVie object: DomainInvitation # workaround for type mismatch in DeleteView -class DomainInvitationUpdateView(DomainInvitationPermission, UpdateView, abc.ABC): +class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC): + """Abstract view for cancelling a DomainInvitation.""" + model = DomainInvitation object: DomainInvitation From 4b1316945c4c9db64ab9ce449b8177bba576e319 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Tue, 12 Nov 2024 19:41:30 -0500 Subject: [PATCH 36/56] migration file --- .../0137_alter_domaininvitation_status.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/registrar/migrations/0137_alter_domaininvitation_status.py diff --git a/src/registrar/migrations/0137_alter_domaininvitation_status.py b/src/registrar/migrations/0137_alter_domaininvitation_status.py new file mode 100644 index 000000000..6effa017d --- /dev/null +++ b/src/registrar/migrations/0137_alter_domaininvitation_status.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.10 on 2024-11-13 00:40 + +from django.db import migrations +import django_fsm + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0136_domainrequest_requested_suborganization_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="domaininvitation", + name="status", + field=django_fsm.FSMField( + choices=[("invited", "Invited"), ("retrieved", "Retrieved"), ("canceled", "Canceled")], + default="invited", + max_length=50, + protected=True, + ), + ), + ] From 229c476d2eb5e0da023ab7a9e5f7128202907950 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Wed, 13 Nov 2024 18:59:28 -0500 Subject: [PATCH 37/56] add ADMIN tags to domain mgrs, conditional display based on portfolio --- src/registrar/templates/domain_users.html | 52 +++++++++------- src/registrar/views/domain.py | 76 +++++++++++++++++++++++ 2 files changed, 107 insertions(+), 21 deletions(-) diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index a2eb3e604..4f497dab0 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -6,10 +6,18 @@ {% block domain_content %}

Domain managers

+ {% comment %}Copy below differs depending on whether view is in portfolio mode.{% endcomment %} + {% if not portfolio %} +

+ Domain managers can update all information related to a domain within the + .gov registrar, including security email and DNS name servers. +

+ {% else %}

Domain managers can update all information related to a domain within the - .gov registrar, including security email and DNS name servers. + .gov registrar, including contact details, senior official, security email, and DNS name servers.

+ {% endif %}
  • There is no limit to the number of domain managers you can add.
  • @@ -20,7 +28,7 @@
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.
- {% if domain.permissions %} + {% if domain_manager_roles %}

Domain managers

@@ -28,17 +36,18 @@ - + {% if not portfolio %}{% endif %} - {% for permission in domain.permissions.all %} + {% for item in domain_manager_roles %} - - + {% if not portfolio %}{% endif %}
EmailRoleRoleAction
- {{ permission.user.email }} + + {{ item.permission.user.email }} + {% if item.has_admin_flag %}Admin{% endif %} {{ permission.role|title }}{{ item.permission.role|title }} {% if can_delete_users %} {# Display a custom message if the user is trying to delete themselves #} - {% if permission.user.email == current_user_email %} + {% if item.permission.user.email == current_user_email %}
-
+ {% with domain_name=domain.name|force_escape %} {% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain "|add:domain_name|add:"."|safe modal_button=modal_button_self|safe %} {% endwith %} @@ -71,11 +80,11 @@ class="usa-modal" id="toggle-user-alert-{{ forloop.counter }}" aria-labelledby="Are you sure you want to continue?" - aria-describedby="{{ permission.user.email }} will be removed" + aria-describedby="{{ item.permission.user.email }} will be removed" data-force-action > - - {% with email=permission.user.email|default:permission.user|force_escape domain_name=domain.name|force_escape %} + + {% with email=item.permission.user.email|default:item.permission.user|force_escape domain_name=domain.name|force_escape %} {% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description=""|add:email|add:" will no longer be able to manage the domain "|add:domain_name|add:"."|safe modal_button=modal_button|safe %} {% endwith %}
@@ -111,7 +120,7 @@
- {% if domain.invitations.exists %} + {% if invitations %}

Invitations

@@ -120,21 +129,22 @@ - + {% if not portfolio %}{% endif %} - {% for invitation in domain.invitations.all %} + {% for invitation in invitations %} - - - + + {% if not portfolio %}{% endif %} From 2a228054cf8f9a1ce6a5aba0caf9801991df4e32 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 13:43:37 -0500 Subject: [PATCH 51/56] updated for merge conflicts --- src/registrar/templates/domain_users.html | 59 ++++++++++++++--------- src/registrar/views/domain.py | 8 ++- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 1ba263633..0506e98f4 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -6,21 +6,30 @@ {% block domain_content %}

Domain managers

+ {% comment %}Copy below differs depending on whether view is in portfolio mode.{% endcomment %} + {% if not portfolio %} +

+ Domain managers can update all information related to a domain within the + .gov registrar, including security email and DNS name servers. +

+ {% else %}

Domain managers can update all information related to a domain within the - .gov registrar, including security email and DNS name servers. + .gov registrar, including contact details, senior official, security email, and DNS name servers.

+ {% endif %}
  • There is no limit to the number of domain managers you can add.
  • After adding a domain manager, an email invitation will be sent to that user with instructions on how to set up an account.
  • All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.
  • -
  • All domain managers will be notified when updates are made to this domain.
  • -
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.
  • + {% if not portfolio %}
  • All domain managers will be notified when updates are made to this domain.
  • {% endif %} +
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain. + {% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}
- {% if domain.permissions %} + {% if domain_manager_roles %}
Email Date createdStatusStatusAction
- {{ invitation.email }} + + {{ invitation.domain_invitation.email }} + {% if invitation.has_admin_flag %}Admin{% endif %} {{ invitation.created_at|date }} {{ invitation.status|title }}{{ invitation.domain_invitation.created_at|date }} {{ invitation.domain_invitation.status|title }} - {% if invitation.status == invitation.DomainInvitationStatus.INVITED %} -
+ {% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %} + {% csrf_token %}
{% endif %} diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 6e85c9ffb..c378feeb9 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -28,6 +28,7 @@ from registrar.models import ( UserPortfolioPermission, PublicContact, ) +from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.utility.enums import DefaultEmail from registrar.utility.errors import ( GenericError, @@ -841,11 +842,86 @@ class DomainUsersView(DomainBaseView): # Add modal buttons to the context (such as for delete) context = self._add_modal_buttons_to_context(context) + # Get portfolio from session (if set) + portfolio = self.request.session.get("portfolio") + + # Add domain manager roles separately in order to also pass admin status + context = self._add_domain_manager_roles_to_context(context, portfolio) + + # Add domain invitations separately in order to also pass admin status + context = self._add_invitations_to_context(context, portfolio) + # Get the email of the current user context["current_user_email"] = self.request.user.email return context + def get(self, request, *args, **kwargs): + """Get method for DomainUsersView.""" + # Call the parent class's `get` method to get the response and context + response = super().get(request, *args, **kwargs) + + # Ensure context is available after the parent call + context = response.context_data if hasattr(response, "context_data") else {} + + # Check if context contains `domain_managers_roles` and its length is 1 + if context.get("domain_manager_roles") and len(context["domain_manager_roles"]) == 1: + # Add an info message + messages.info(request, "This domain has one manager. Adding more can prevent issues.") + + return response + + def _add_domain_manager_roles_to_context(self, context, portfolio): + """Add domain_manager_roles to context separately, as roles need admin indicator.""" + + # Prepare a list to store roles with an admin flag + domain_manager_roles = [] + + for permission in self.object.permissions.all(): + # Determine if the user has the ORGANIZATION_ADMIN role + has_admin_flag = any( + UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles + and portfolio == portfolio_permission.portfolio + for portfolio_permission in permission.user.portfolio_permissions.all() + ) + + # Add the role along with the computed flag to the list + domain_manager_roles.append({"permission": permission, "has_admin_flag": has_admin_flag}) + + # Pass roles_with_flags to the context + context["domain_manager_roles"] = domain_manager_roles + + return context + + def _add_invitations_to_context(self, context, portfolio): + """Add invitations to context separately as invitations needs admin indicator.""" + + # Prepare a list to store invitations with an admin flag + invitations = [] + + for domain_invitation in self.object.invitations.all(): + # Check if there are any PortfolioInvitations linked to the same portfolio with the ORGANIZATION_ADMIN role + has_admin_flag = False + + # Query PortfolioInvitations linked to the same portfolio and check roles + portfolio_invitations = PortfolioInvitation.objects.filter( + portfolio=portfolio, email=domain_invitation.email + ) + + # If any of the PortfolioInvitations have the ORGANIZATION_ADMIN role, set the flag to True + for portfolio_invitation in portfolio_invitations: + if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles: + has_admin_flag = True + break # Once we find one match, no need to check further + + # Add the role along with the computed flag to the list + invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag}) + + # Pass roles_with_flags to the context + context["invitations"] = invitations + + return context + def _add_booleans_to_context(self, context): # Determine if the current user can delete managers domain_pk = None From 224fe120af6abacc1c3d1b49789a73c8df933239 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Thu, 14 Nov 2024 06:10:01 -0500 Subject: [PATCH 38/56] added unit test --- src/registrar/tests/test_views_domain.py | 30 ++++++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index a375493be..6f732bede 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -370,6 +370,17 @@ class TestDomainManagers(TestDomainOverview): ] AllowedEmail.objects.bulk_create(allowed_emails) + def setUp(self): + super().setUp() + # Add portfolio in order to test portfolio view + self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream") + # Add the portfolio to the domain_information object + self.domain_information.portfolio = self.portfolio + # Add portfolio perms to the user object + self.portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + @classmethod def tearDownClass(cls): super().tearDownClass() @@ -383,13 +394,22 @@ class TestDomainManagers(TestDomainOverview): def test_domain_managers(self): response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) self.assertContains(response, "Domain managers") + self.assertContains(response, "Add a domain manager") + # assert that the non-portfolio view contains Role column and doesn't contain Admin + self.assertContains(response, "Role") + self.assertNotContains(response, "Admin") + self.assertContains(response, "This domain has one manager. Adding more can prevent issues.") @less_console_noise_decorator - def test_domain_managers_add_link(self): - """Button to get to user add page works.""" - management_page = self.app.get(reverse("domain-users", kwargs={"pk": self.domain.id})) - add_page = management_page.click("Add a domain manager") - self.assertContains(add_page, "Add a domain manager") + @override_flag("organization_feature", active=True) + def test_domain_managers_portfolio_view(self): + response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) + self.assertContains(response, "Domain managers") + self.assertContains(response, "Add a domain manager") + # assert that the non-portfolio view contains Role column and doesn't contain Admin + self.assertNotContains(response, "Role") + self.assertContains(response, "Admin") + self.assertContains(response, "This domain has one manager. Adding more can prevent issues.") @less_console_noise_decorator def test_domain_user_add(self): From 08b9de2dced2af430a8c2aa359a6244ba7fe4f6e Mon Sep 17 00:00:00 2001 From: asaki222 Date: Thu, 14 Nov 2024 15:44:46 -0500 Subject: [PATCH 39/56] delete the deleteinvitationdelete view and added print statements to test --- src/registrar/views/utility/mixins.py | 5 ++++- src/registrar/views/utility/permission_views.py | 15 +-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index c1cf97d82..bc13ac1da 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -424,13 +424,16 @@ class DomainInvitationPermission(PermissionsLoginMixin): def has_permission(self): """Check if this user has a role on the domain of this invitation.""" if not self.request.user.is_authenticated: + print("filter is not authenticated") return False + print("is authenticated") if not DomainInvitation.objects.filter( id=self.kwargs["pk"], domain__permissions__user=self.request.user ).exists(): + print("returned false in domain invitation objects filter") return False - + print("this actually returned true") return True diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 5ba84b3d6..87021a81f 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -154,20 +154,7 @@ class DomainRequestWizardPermissionView(DomainRequestWizardPermission, TemplateV @abc.abstractmethod def template_name(self): raise NotImplementedError - - -class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteView, abc.ABC): - """Abstract view for deleting a domain invitation. - - This one is fairly specialized, but this is the only thing that we do - right now with domain invitations. We still have the full - `DomainInvitationPermission` class, but here we just pair it with a - DeleteView. - """ - - model = DomainInvitation - object: DomainInvitation # workaround for type mismatch in DeleteView - + class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC): """Abstract view for cancelling a DomainInvitation.""" From 199c38c32f04e2c9a449257d9ca1342e1ac9aaa8 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Thu, 14 Nov 2024 16:12:57 -0500 Subject: [PATCH 40/56] merge conflict --- .../migrations/0138_merge_20241114_2101.py | 13 +++++++++++++ src/registrar/views/domain.py | 2 +- src/registrar/views/utility/__init__.py | 1 - 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 src/registrar/migrations/0138_merge_20241114_2101.py diff --git a/src/registrar/migrations/0138_merge_20241114_2101.py b/src/registrar/migrations/0138_merge_20241114_2101.py new file mode 100644 index 000000000..295889b60 --- /dev/null +++ b/src/registrar/migrations/0138_merge_20241114_2101.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.10 on 2024-11-14 21:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0137_alter_domaininvitation_status"), + ("registrar", "0137_suborganization_city_suborganization_state_territory"), + ] + + operations = [] diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 84318ea88..b8ca9aa9d 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -2,7 +2,7 @@ Authorization is handled by the `DomainPermissionView`. To ensure that only authorized users can see information on a domain, every view here should -inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView). +inherit from `DomainPermissionView`. """ from datetime import date diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index 14628a9b0..6798eb4ee 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -5,7 +5,6 @@ from .permission_views import ( DomainPermissionView, DomainRequestPermissionView, DomainRequestPermissionWithdrawView, - DomainInvitationPermissionDeleteView, DomainRequestWizardPermissionView, PortfolioMembersPermission, DomainRequestPortfolioViewonlyView, From 2d4f092900b4a38485550c7abe4993ca20e678c4 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Thu, 14 Nov 2024 17:22:20 -0500 Subject: [PATCH 41/56] reformmated --- src/registrar/views/utility/permission_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 87021a81f..115b2754f 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -154,7 +154,7 @@ class DomainRequestWizardPermissionView(DomainRequestWizardPermission, TemplateV @abc.abstractmethod def template_name(self): raise NotImplementedError - + class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC): """Abstract view for cancelling a DomainInvitation.""" From 654df171312e43b20973210e00a73dfa1d7bf275 Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 15 Nov 2024 18:24:13 -0500 Subject: [PATCH 42/56] fixing a bug in suborganization handling --- src/registrar/admin.py | 4 +- src/registrar/assets/js/get-gov-admin.js | 64 +++++++++++++++--------- src/registrar/utility/admin_helpers.py | 12 +++++ 3 files changed, 54 insertions(+), 26 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 399ab5377..3ff028140 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -242,7 +242,9 @@ class DomainRequestAdminForm(forms.ModelForm): DomainRequest._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"} ), "sub_organization": AutocompleteSelectWithPlaceholder( - DomainRequest._meta.get_field("sub_organization"), admin.site, attrs={"data-placeholder": "---------"} + DomainRequest._meta.get_field("sub_organization"), + admin.site, + attrs={"data-placeholder": "---------", "ajax-url": "get-suborganization-list-json"}, ), } labels = { diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 958d53ecb..91b038fd1 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -98,6 +98,9 @@ function handlePortfolioSelection() { const portfolioDropdown = django.jQuery("#id_portfolio"); const suborganizationDropdown = django.jQuery("#id_sub_organization"); const suborganizationField = document.querySelector(".field-sub_organization"); + const requestedSuborganizationField = document.querySelector(".field-requested_suborganization"); + const suborganizationCity = document.querySelector(".field-suborganization_city"); + const suborganizationStateTerritory = document.querySelector(".field-suborganization_state_territory"); const seniorOfficialField = document.querySelector(".field-senior_official"); const otherEmployeesField = document.querySelector(".field-other_contacts"); const noOtherContactsRationaleField = document.querySelector(".field-no_other_contacts_rationale"); @@ -128,7 +131,7 @@ function handlePortfolioSelection() { const portfolioZipcode = document.querySelector(".field-portfolio_zipcode .readonly"); const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization"); const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly"); - const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; + const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; let isPageLoading = true; /** @@ -438,26 +441,6 @@ function handlePortfolioSelection() { }); } - /** - * Initializes the Suborganization Dropdown’s AJAX URL. - * - * This function sets the `data-ajax--url` attribute for `suborganizationDropdown`, defining the endpoint - * from which the dropdown will retrieve suborganization data. The endpoint is expected to return a JSON - * list of suborganizations suitable for Select2 integration. - * - * **Purpose**: To ensure the dropdown is configured with the correct endpoint for fetching data - * in real-time, thereby allowing seamless updates and filtering based on user selections. - * - * **Workflow**: - * - Attaches an endpoint URL to `suborganizationDropdown` for fetching suborganization data. - * - * Dependencies: - * - Expects `suborganizationDropdown` to be defined in the global scope. - */ - function initializeSubOrganizationUrl() { - suborganizationDropdown.attr("data-ajax--url", "/admin/api/get-suborganization-list-json/"); - } - /** * Updates the display of portfolio-related fields based on whether a portfolio is selected. * @@ -542,15 +525,45 @@ function handlePortfolioSelection() { hideElement(portfolioOrgNameFieldSet); hideElement(portfolioOrgNameFieldSetDetails); } + + updateSuborganizationFieldsDisplay(); + + } + + /** + * Updates the visibility of suborganization-related fields based on the selected value in the suborganization dropdown. + * + * If a suborganization is selected: + * - Hides the fields related to requesting a new suborganization (`requestedSuborganizationField`). + * - Hides the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields for the suborganization. + * + * If no suborganization is selected: + * - Shows the fields for requesting a new suborganization (`requestedSuborganizationField`). + * - Displays the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields. + * + * This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested. + */ + function updateSuborganizationFieldsDisplay() { + + let suborganization_id = suborganizationDropdown.val(); + + if (suborganization_id) { + // Hide suborganization request fields if suborganization is selected + hideElement(requestedSuborganizationField); + hideElement(suborganizationCity); + hideElement(suborganizationStateTerritory); + } else { + // Show suborganization request fields + showElement(requestedSuborganizationField); + showElement(suborganizationCity); + showElement(suborganizationStateTerritory); + } } /** * Initializes necessary data and display configurations for the portfolio fields. */ function initializePortfolioSettings() { - // Set URL for the suborganization dropdown, enabling asynchronous data fetching. - initializeSubOrganizationUrl(); - // Update the visibility of portfolio-related fields based on current dropdown selection. updatePortfolioFieldsDisplay(); @@ -564,6 +577,8 @@ function handlePortfolioSelection() { function setEventListeners() { // When the `portfolioDropdown` selection changes, refresh the displayed portfolio fields. portfolioDropdown.on("change", updatePortfolioFields); + // When the 'suborganizationDropdown' selection changes + suborganizationDropdown.on("change", updateSuborganizationFieldsDisplay); } // Run initial setup functions @@ -1761,7 +1776,6 @@ document.addEventListener('DOMContentLoaded', function() { (function dynamicDomainRequestFields(){ const domainRequestPage = document.getElementById("domainrequest_form"); if (domainRequestPage) { - handleSuborganizationFields(); handlePortfolioSelection(); } })(); diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 2852cbadc..93a0a16b5 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -106,3 +106,15 @@ class AutocompleteSelectWithPlaceholder(AutocompleteSelect): if "data-placeholder" in base_attrs: attrs["data-placeholder"] = base_attrs["data-placeholder"] return attrs + + def __init__(self, field, admin_site, attrs=None, choices=(), using=None): + """Set a custom ajax url for the select2 if passed through attrs""" + if attrs: + self.custom_ajax_url = attrs.pop("ajax-url", None) + super().__init__(field, admin_site, attrs, choices, using) + + def get_url(self): + """Override the get_url method to use the custom ajax url""" + if self.custom_ajax_url: + return reverse(self.custom_ajax_url) + return reverse(self.url_name % self.admin_site.name) From af10118803346eec545ac3dfb8b95b49eee2e5be Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Fri, 15 Nov 2024 18:36:16 -0500 Subject: [PATCH 43/56] fixing a bug in suborganization handling --- src/registrar/assets/js/get-gov-admin.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 91b038fd1..9fd15b9f9 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -544,19 +544,19 @@ function handlePortfolioSelection() { * This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested. */ function updateSuborganizationFieldsDisplay() { - + let portfolio_id = portfolioDropdown.val(); let suborganization_id = suborganizationDropdown.val(); - if (suborganization_id) { - // Hide suborganization request fields if suborganization is selected - hideElement(requestedSuborganizationField); - hideElement(suborganizationCity); - hideElement(suborganizationStateTerritory); - } else { + if (portfolio_id && !suborganization_id) { // Show suborganization request fields showElement(requestedSuborganizationField); showElement(suborganizationCity); showElement(suborganizationStateTerritory); + } else { + // Hide suborganization request fields if suborganization is selected + hideElement(requestedSuborganizationField); + hideElement(suborganizationCity); + hideElement(suborganizationStateTerritory); } } From 1dfab094abb75a61cb0d687cb183e209d9f7a6ea Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 10:17:14 -0500 Subject: [PATCH 44/56] some extra text --- src/registrar/views/utility/permission_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 115b2754f..cc9047ca3 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -33,7 +33,7 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC): """Abstract base view for domains that enforces permissions. This abstract view cannot be instantiated. Actual views must specify - `template_name`. + `template_name` update. """ # DetailView property for what model this is viewing From e016561e0375932cca37b67c95faa20cd3d13cbd Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 10:30:12 -0500 Subject: [PATCH 45/56] removed update --- src/registrar/views/utility/permission_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index cc9047ca3..115b2754f 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -33,7 +33,7 @@ class DomainPermissionView(DomainPermission, DetailView, abc.ABC): """Abstract base view for domains that enforces permissions. This abstract view cannot be instantiated. Actual views must specify - `template_name` update. + `template_name`. """ # DetailView property for what model this is viewing From 1b0389b6dd1188687954e26eab6b256199697c3f Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 10:51:08 -0500 Subject: [PATCH 46/56] changes --- src/registrar/views/utility/mixins.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index bc13ac1da..8a3d53f09 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -424,16 +424,12 @@ class DomainInvitationPermission(PermissionsLoginMixin): def has_permission(self): """Check if this user has a role on the domain of this invitation.""" if not self.request.user.is_authenticated: - print("filter is not authenticated") return False - print("is authenticated") if not DomainInvitation.objects.filter( id=self.kwargs["pk"], domain__permissions__user=self.request.user ).exists(): - print("returned false in domain invitation objects filter") return False - print("this actually returned true") return True From 0d3440ee4995381a0438ef158e673f17a93862d6 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 11:47:40 -0500 Subject: [PATCH 47/56] make migration --- ...tus.py => 0138_alter_domaininvitation_status.py} | 4 ++-- .../migrations/0138_merge_20241114_2101.py | 13 ------------- 2 files changed, 2 insertions(+), 15 deletions(-) rename src/registrar/migrations/{0137_alter_domaininvitation_status.py => 0138_alter_domaininvitation_status.py} (79%) delete mode 100644 src/registrar/migrations/0138_merge_20241114_2101.py diff --git a/src/registrar/migrations/0137_alter_domaininvitation_status.py b/src/registrar/migrations/0138_alter_domaininvitation_status.py similarity index 79% rename from src/registrar/migrations/0137_alter_domaininvitation_status.py rename to src/registrar/migrations/0138_alter_domaininvitation_status.py index 6effa017d..762a054f2 100644 --- a/src/registrar/migrations/0137_alter_domaininvitation_status.py +++ b/src/registrar/migrations/0138_alter_domaininvitation_status.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-11-13 00:40 +# Generated by Django 4.2.10 on 2024-11-18 16:47 from django.db import migrations import django_fsm @@ -7,7 +7,7 @@ import django_fsm class Migration(migrations.Migration): dependencies = [ - ("registrar", "0136_domainrequest_requested_suborganization_and_more"), + ("registrar", "0137_suborganization_city_suborganization_state_territory"), ] operations = [ diff --git a/src/registrar/migrations/0138_merge_20241114_2101.py b/src/registrar/migrations/0138_merge_20241114_2101.py deleted file mode 100644 index 295889b60..000000000 --- a/src/registrar/migrations/0138_merge_20241114_2101.py +++ /dev/null @@ -1,13 +0,0 @@ -# Generated by Django 4.2.10 on 2024-11-14 21:01 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("registrar", "0137_alter_domaininvitation_status"), - ("registrar", "0137_suborganization_city_suborganization_state_territory"), - ] - - operations = [] From 9c671c0f71351beaaeba97c6ea8b61724f1b1fce Mon Sep 17 00:00:00 2001 From: David Kennedy Date: Mon, 18 Nov 2024 12:27:05 -0500 Subject: [PATCH 48/56] fixed outstanding copy, and updated comment --- src/registrar/templates/domain_users.html | 5 +++-- src/registrar/tests/test_views_domain.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 4f497dab0..7fe97233f 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -24,8 +24,9 @@
  • After adding a domain manager, an email invitation will be sent to that user with instructions on how to set up an account.
  • All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.
  • -
  • All domain managers will be notified when updates are made to this domain.
  • -
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.
  • + {% if not portfolio %}
  • All domain managers will be notified when updates are made to this domain.
  • {% endif %} +
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain. + {% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}
  • {% if domain_manager_roles %} diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 6f732bede..1b7731222 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -406,7 +406,7 @@ class TestDomainManagers(TestDomainOverview): response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) self.assertContains(response, "Domain managers") self.assertContains(response, "Add a domain manager") - # assert that the non-portfolio view contains Role column and doesn't contain Admin + # assert that the portfolio view doesn't contain Role column and does contain Admin self.assertNotContains(response, "Role") self.assertContains(response, "Admin") self.assertContains(response, "This domain has one manager. Adding more can prevent issues.") From fdbcd99bfe89ffc6741612a4461d5c72596918d8 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 12:43:10 -0500 Subject: [PATCH 49/56] add error --- src/registrar/views/domain.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index b8ca9aa9d..656c1cac1 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -987,6 +987,7 @@ class DomainAddUserView(DomainFormBaseView): self.object, exc_info=True, ) + logger.info(exc) raise EmailSendingError("Could not send email invitation.") from exc else: if add_success: From d6ee1d4242cfae905a0ed95b9d843a3d89e5fa00 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 13:10:48 -0500 Subject: [PATCH 50/56] updates --- src/registrar/templates/domain_users.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 4bca62ed4..1ba263633 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -128,7 +128,7 @@ {% for invitation in domain_invitations %}
    - {{ invitation.email }} + {{ invitation }} {{ invitation.created_at|date }} {{ invitation.status|title }}

    Domain managers

    @@ -28,17 +37,18 @@ - + {% if not portfolio %}{% endif %} - {% for permission in domain.permissions.all %} + {% for item in domain_manager_roles %} - - + {% if not portfolio %}{% endif %}
    EmailRoleRoleAction
    - {{ permission.user.email }} + + {{ item.permission.user.email }} + {% if item.has_admin_flag %}Admin{% endif %} {{ permission.role|title }}{{ item.permission.role|title }} {% if can_delete_users %} {# Display a custom message if the user is trying to delete themselves #} - {% if permission.user.email == current_user_email %} + {% if item.permission.user.email == current_user_email %}
    -
    + {% with domain_name=domain.name|force_escape %} {% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain "|add:domain_name|add:"."|safe modal_button=modal_button_self|safe %} {% endwith %} @@ -71,11 +81,11 @@ class="usa-modal" id="toggle-user-alert-{{ forloop.counter }}" aria-labelledby="Are you sure you want to continue?" - aria-describedby="{{ permission.user.email }} will be removed" + aria-describedby="{{ item.permission.user.email }} will be removed" data-force-action > - - {% with email=permission.user.email|default:permission.user|force_escape domain_name=domain.name|force_escape %} + + {% with email=item.permission.user.email|default:item.permission.user|force_escape domain_name=domain.name|force_escape %} {% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description=""|add:email|add:" will no longer be able to manage the domain "|add:domain_name|add:"."|safe modal_button=modal_button|safe %} {% endwith %}
    @@ -111,7 +121,7 @@
    - {% if domain_invitations|length > 0%} + {% if invitations %}

    Invitations

    @@ -120,21 +130,22 @@ - + {% if not portfolio %}{% endif %} - {% for invitation in domain_invitations %} + {% for invitation in invitations %} - - - + + {% if not portfolio %}{% endif %}
    Email Date createdStatusStatusAction
    - {{ invitation }} + + {{ invitation.domain_invitation.email }} + {% if invitation.has_admin_flag %}Admin{% endif %} {{ invitation.created_at|date }} {{ invitation.status|title }}{{ invitation.domain_invitation.created_at|date }} {{ invitation.domain_invitation.status|title }} - {% if invitation.status == invitation.DomainInvitationStatus.INVITED %} -
    + {% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %} + {% csrf_token %}
    {% endif %} @@ -146,4 +157,4 @@ {% endif %} -{% endblock %} {# domain_content #} +{% endblock %} {# domain_content #} \ No newline at end of file diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index a412b57a9..0f2634250 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -854,9 +854,6 @@ class DomainUsersView(DomainBaseView): # Get the email of the current user context["current_user_email"] = self.request.user.email - # Filter out the cancelled domain invitations - context["domain_invitations"] = DomainInvitation.objects.exclude(status="canceled") - return context def get(self, request, *args, **kwargs): @@ -917,8 +914,9 @@ class DomainUsersView(DomainBaseView): has_admin_flag = True break # Once we find one match, no need to check further - # Add the role along with the computed flag to the list - invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag}) + # Add the role along with the computed flag to the list if the domain invitation if the status is not canceled + if domain_invitation.status != "canceled": + invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag}) # Pass roles_with_flags to the context context["invitations"] = invitations From aa18afc9e18013b7081fc965bc92ad1a2c51cc6f Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 14:17:58 -0500 Subject: [PATCH 52/56] made changes for the linter --- src/registrar/templates/domain_users.html | 2 +- src/registrar/views/domain.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 0506e98f4..b8a622455 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -157,4 +157,4 @@ {% endif %} -{% endblock %} {# domain_content #} \ No newline at end of file +{% endblock %} {# domain_content #} diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 0f2634250..95197abbe 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -914,7 +914,8 @@ class DomainUsersView(DomainBaseView): has_admin_flag = True break # Once we find one match, no need to check further - # Add the role along with the computed flag to the list if the domain invitation if the status is not canceled + # Add the role along with the computed flag to the list if the domain invitation + # if the status is not canceled if domain_invitation.status != "canceled": invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag}) From f8e404860e0f45a4e181de856c0b60124ab427a1 Mon Sep 17 00:00:00 2001 From: asaki222 <130692870+asaki222@users.noreply.github.com> Date: Mon, 18 Nov 2024 15:34:06 -0500 Subject: [PATCH 53/56] Update src/registrar/models/domain_invitation.py Co-authored-by: Matt-Spence --- src/registrar/models/domain_invitation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index 396250a38..28089dcb5 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -77,7 +77,7 @@ class DomainInvitation(TimeStampedModel): @transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED) def cancel_invitation(self): - """When an invitation is cancel, change the status to canceled""" + """When an invitation is canceled, change the status to canceled""" pass @transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED) From a43668a4b903b0118583ae516718dbdf5e019ec7 Mon Sep 17 00:00:00 2001 From: asaki222 <130692870+asaki222@users.noreply.github.com> Date: Mon, 18 Nov 2024 15:37:26 -0500 Subject: [PATCH 54/56] Update src/registrar/views/domain.py Co-authored-by: Matt-Spence --- src/registrar/views/domain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 95197abbe..9a76b6286 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -988,7 +988,7 @@ class DomainAddUserView(DomainFormBaseView): ) def _check_invite_status(self, invite, email): - """Check invitation status if it is canceled or retrieved, and gives the appropiate response""" + """Check if invitation status is canceled or retrieved, and gives the appropiate response""" if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: messages.warning( self.request, From 443c200f9122495e7385c22d85a6bbbecd2ceb78 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 15:37:56 -0500 Subject: [PATCH 55/56] updated text --- src/registrar/views/domain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 95197abbe..c74dce990 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -2,7 +2,7 @@ Authorization is handled by the `DomainPermissionView`. To ensure that only authorized users can see information on a domain, every view here should -inherit from `DomainPermissionView`. +inherit from `DomainPermissionView`. (or DomainInvitationPermissionCancelView). """ from datetime import date From 616167a63b23f05ddfeb4a1b66c6c78803ce6ab3 Mon Sep 17 00:00:00 2001 From: asaki222 Date: Mon, 18 Nov 2024 15:40:38 -0500 Subject: [PATCH 56/56] fixed typo --- src/registrar/views/domain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 16aea217a..9bf6f5313 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -2,7 +2,7 @@ Authorization is handled by the `DomainPermissionView`. To ensure that only authorized users can see information on a domain, every view here should -inherit from `DomainPermissionView`. (or DomainInvitationPermissionCancelView). +inherit from `DomainPermissionView` (or DomainInvitationPermissionCancelView). """ from datetime import date