From dfd237755dc3142c06170607fd03ef9503f6844c Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Tue, 18 Feb 2025 12:11:19 -0600 Subject: [PATCH 01/70] pushing to pull on other device --- src/registrar/assets/src/js/getgov/main.js | 2 + src/registrar/forms/domain_request_wizard.py | 64 +++++++++++++++++- ...equest_feb_naming_requirements_and_more.py | 23 +++++++ src/registrar/models/domain_request.py | 20 ++++++ .../domain_request_dotgov_domain.html | 66 ++++++++++++++----- src/registrar/views/domain_request.py | 45 +++++++++++-- 6 files changed, 197 insertions(+), 23 deletions(-) create mode 100644 src/registrar/migrations/0141_domainrequest_feb_naming_requirements_and_more.py diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index a077da929..67db06376 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -25,6 +25,8 @@ nameserversFormListener(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); +hookupYesNoListener("feb_naming_requirements", "", "domain-naming-requirements-details-container"); + initializeUrbanizationToggle(); userProfileListener(); diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index d7a02b124..91c8ccb9b 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -607,6 +607,68 @@ class DotGovDomainForm(RegistrarForm): }, ) +class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm): + """ + Form for verifying if the domain request meets the Federal Executive Branch domain naming requirements. + If the "no" option is selected, details must be provided via the separate details form. + """ + + field_name = "feb_naming_requirements" + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form based on the domain_request's attributes. + """ + return self.domain_request.feb_naming_requirements + + def clean(self): + # Skip validation if this form is not applicable. + if not (self.domain_request.is_federal() and self.domain_request.federal_type == "Executive"): + # If not executive, default to "yes" + self.cleaned_data["feb_naming_requirements"] = "yes" + return self.cleaned_data + + # Only validate the yes/no field here; details are handled by the separate details form. + cleaned = super().clean() + return cleaned + + def to_database(self, obj: DomainRequest): + """ + Saves the cleaned data from this form to the DomainRequest object. + """ + if not self.is_valid(): + return + obj.feb_naming_requirements = (self.cleaned_data["feb_naming_requirements"] == "yes") + obj.save() + + @classmethod + def from_database(cls, obj): + """ + Retrieves initial data from the DomainRequest object to prepopulate the form. + """ + initial = {} + if hasattr(obj, "feb_naming_requirements"): + initial["feb_naming_requirements"] = "yes" if obj.feb_naming_requirements else "no" + return initial + +class ExecutiveNamingRequirementsDetailsForm(BaseDeletableRegistrarForm): + JOIN = "feb_naming_requirements_details" + + # Text area for additional details; rendered conditionally when "no" is selected. + feb_naming_requirements_details = forms.CharField( + widget=forms.Textarea(attrs={'maxlength': '2000'}), + max_length=2000, + required=True, + label="", + help_text="Maximum 2000 characters allowed.", + ) + + def to_database(self, obj: DomainRequest): + if not self.is_valid(): + return + obj.feb_naming_requirements_details = self.cleaned_data["feb_naming_requirements_details"] + obj.save() class PurposeForm(RegistrarForm): purpose = forms.CharField( @@ -625,7 +687,7 @@ class PurposeForm(RegistrarForm): ], error_messages={"required": "Describe how you’ll use the .gov domain you’re requesting."}, ) - + class OtherContactsYesNoForm(BaseYesNoForm): """The yes/no field for the OtherContacts form.""" diff --git a/src/registrar/migrations/0141_domainrequest_feb_naming_requirements_and_more.py b/src/registrar/migrations/0141_domainrequest_feb_naming_requirements_and_more.py new file mode 100644 index 000000000..32634d8ee --- /dev/null +++ b/src/registrar/migrations/0141_domainrequest_feb_naming_requirements_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.17 on 2025-02-13 22:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0140_alter_portfolioinvitation_additional_permissions_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="domainrequest", + name="feb_naming_requirements", + field=models.BooleanField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="feb_naming_requirements_details", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 1cca3742f..ceaa19e77 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -501,6 +501,16 @@ class DomainRequest(TimeStampedModel): on_delete=models.PROTECT, ) + feb_naming_requirements = models.BooleanField( + null=True, + blank=True, + ) + + feb_naming_requirements_details = models.TextField( + null=True, + blank=True, + ) + alternative_domains = models.ManyToManyField( "registrar.Website", blank=True, @@ -1388,6 +1398,16 @@ class DomainRequest(TimeStampedModel): if self.has_anything_else_text is None or self.has_cisa_representative is None: has_details = False return has_details + + def is_feb(self) -> bool: + """Is this domain request for a Federal Executive Branch agency?""" + # if not self.generic_org_type: + # # generic_org_type is either blank or None, assume no + # return False + # if self.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL: + # return self.federal_type == DomainRequest.FederalChoices.EXECUTIVE + # return False + return True # TODO: this is for testing, remove before merging def is_federal(self) -> Union[bool, None]: """Is this domain request for a federal agency? diff --git a/src/registrar/templates/domain_request_dotgov_domain.html b/src/registrar/templates/domain_request_dotgov_domain.html index 91373609d..d58815e3b 100644 --- a/src/registrar/templates/domain_request_dotgov_domain.html +++ b/src/registrar/templates/domain_request_dotgov_domain.html @@ -2,19 +2,19 @@ {% load static field_helpers url_helpers %} {% block form_instructions %} -

Before requesting a .gov domain, please make sure it meets our naming requirements. Your domain name must: +

Before requesting a .gov domain, please make sure it meets our naming requirements. Your domain name must:

Names that uniquely apply to your organization are likely to be approved over names that could also apply to other organizations. - {% if not is_federal %}In most instances, this requires including your state’s two-letter abbreviation.{% endif %}

+ {% if not is_federal %}In most instances, this requires including your state's two-letter abbreviation.{% endif %}

{% if not portfolio %} -

Requests for your organization’s initials or an abbreviated name might not be approved, but we encourage you to request the name you want.

+

Requests for your organization's initials or an abbreviated name might not be approved, but we encourage you to request the name you want.

{% endif %}

Note that only federal agencies can request generic terms like @@ -41,9 +41,10 @@

What .gov domain do you want?

- -

After you enter your domain, we’ll make sure it’s available and that it meets some of our naming requirements. If your domain passes these initial checks, we’ll verify that it meets all our requirements after you complete the rest of this form.

- +

+ After you enter your domain, we'll make sure it's available and that it meets some of our naming requirements. + If your domain passes these initial checks, we'll verify that it meets all our requirements after you complete the rest of this form. +

{% with attr_aria_labelledby="domain_instructions domain_instructions2" attr_aria_describedby="id_dotgov_domain-requested_domain--toast" %} {# attr_validate / validate="domain" invokes code in getgov.min.js #} {% with append_gov=True attr_validate="domain" add_label_class="usa-sr-only" %} @@ -63,10 +64,9 @@

Alternative domains (optional)

- -

Are there other domains you’d like if we can’t give - you your first choice?

- +

+ Are there other domains you'd like if we can't give you your first choice? +

{% with attr_aria_labelledby="alt_domain_instructions" %} {# Will probably want to remove blank-ok and do related cleanup when we implement delete #} {% with attr_validate="domain" append_gov=True add_label_class="usa-sr-only" add_class="blank-ok alternate-domain-input" %} @@ -79,13 +79,12 @@ {% endfor %} {% endwith %} {% endwith %} - -
- +

+ If you're not sure this is the domain you want, that's ok. You can change the domain later. +

+ -

If you’re not sure this is the domain you want, that’s ok. You can change the domain later.

+ {{ forms.2.management_form }} + {{ forms.3.management_form }} - + {% if is_feb %} +
+ +

Does this submission meet each domain naming requirement?

+
+

+ OMB will review each request against the domain + + naming requirements for executive branch agencies + . + Agency submissions are expected to meet each requirement. +

+ {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.2.feb_naming_requirements %} + {% endwith %} + + {# Conditional Details Field – only shown when the executive naming requirements radio is "False" #} + +
+ {% endif %} {% endblock %} diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 3248c1368..83662505f 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -466,6 +466,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): "requested_domain__name": requested_domain_name, } context["domain_request_id"] = self.domain_request.id + context["is_executive"] = self.domain_request.is_federal() and self.domain_request.federal_type == "Executive" return context def get_step_list(self) -> list: @@ -652,14 +653,52 @@ class CurrentSites(DomainRequestWizard): class DotgovDomain(DomainRequestWizard): template_name = "domain_request_dotgov_domain.html" - forms = [forms.DotGovDomainForm, forms.AlternativeDomainFormSet] + forms = [ + forms.DotGovDomainForm, + forms.AlternativeDomainFormSet, + forms.ExecutiveNamingRequirementsYesNoForm, + forms.ExecutiveNamingRequirementsDetailsForm, + ] def get_context_data(self): context = super().get_context_data() context["generic_org_type"] = self.domain_request.generic_org_type - context["federal_type"] = self.domain_request.federal_type + context["is_feb"] = self.domain_request.is_feb() return context + def is_valid(self, forms_list: list) -> bool: + """ + Expected order of forms_list: + 0: DotGovDomainForm + 1: AlternativeDomainFormSet + 2: ExecutiveNamingRequirementsYesNoForm + 3: ExecutiveNamingRequirementsDetailsForm + """ + # If not a federal executive branch agency, mark executive-related forms for deletion. + if not (self.domain_request.is_feb()): + forms_list[2].mark_form_for_deletion() + forms_list[3].mark_form_for_deletion() + return all(form.is_valid() for form in forms_list) + + valid = True + yesno_form = forms_list[2] + details_form = forms_list[3] + + if yesno_form.cleaned_data.get("feb_naming_requirements") == "yes": + # If the user selects "yes", no details are needed. + details_form.mark_form_for_deletion() + valid = all( + form.is_valid() for i, form in enumerate(forms_list) if i != 3 + ) + else: + # "No" was selected – details are required. + valid = ( + yesno_form.is_valid() and + details_form.is_valid() and + all(form.is_valid() for i, form in enumerate(forms_list) if i not in [2, 3]) + ) + return valid + class Purpose(DomainRequestWizard): template_name = "domain_request_purpose.html" @@ -711,9 +750,7 @@ class OtherContacts(DomainRequestWizard): class AdditionalDetails(DomainRequestWizard): - template_name = "domain_request_additional_details.html" - forms = [ forms.CisaRepresentativeYesNoForm, forms.CisaRepresentativeForm, From 292c08902a01daf43c69148ff7190d5cd41b0d22 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Fri, 21 Feb 2025 15:28:50 -0600 Subject: [PATCH 02/70] add new FEB questions to gov domain request wizard page --- src/registrar/assets/src/js/getgov/main.js | 2 +- src/registrar/forms/domain_request_wizard.py | 29 ++++++++++++++++++-- src/registrar/models/domain_request.py | 13 ++++----- src/registrar/views/domain_request.py | 22 +++++++++------ 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 67db06376..796e6f815 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -25,7 +25,7 @@ nameserversFormListener(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); -hookupYesNoListener("feb_naming_requirements", "", "domain-naming-requirements-details-container"); +hookupYesNoListener("dotgov_domain-feb_naming_requirements", null, "domain-naming-requirements-details-container"); initializeUrbanizationToggle(); diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 91c8ccb9b..98ccc9122 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -625,8 +625,11 @@ class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm): def clean(self): # Skip validation if this form is not applicable. if not (self.domain_request.is_federal() and self.domain_request.federal_type == "Executive"): - # If not executive, default to "yes" - self.cleaned_data["feb_naming_requirements"] = "yes" + # Initialize cleaned_data if it doesn't exist + if not hasattr(self, 'cleaned_data'): + self.cleaned_data = {} + # If not executive, default to None + self.cleaned_data["feb_naming_requirements"] = None return self.cleaned_data # Only validate the yes/no field here; details are handled by the separate details form. @@ -639,7 +642,7 @@ class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm): """ if not self.is_valid(): return - obj.feb_naming_requirements = (self.cleaned_data["feb_naming_requirements"] == "yes") + obj.feb_naming_requirements = (self.cleaned_data.get("feb_naming_requirements", None) == "yes") obj.save() @classmethod @@ -660,6 +663,7 @@ class ExecutiveNamingRequirementsDetailsForm(BaseDeletableRegistrarForm): widget=forms.Textarea(attrs={'maxlength': '2000'}), max_length=2000, required=True, + error_messages={"required": ("This field is required.")}, label="", help_text="Maximum 2000 characters allowed.", ) @@ -670,6 +674,25 @@ class ExecutiveNamingRequirementsDetailsForm(BaseDeletableRegistrarForm): obj.feb_naming_requirements_details = self.cleaned_data["feb_naming_requirements_details"] obj.save() + def is_valid(self): + """ + Validate that details are provided when required. + If the form is marked for deletion, bypass validation. + """ + if self.form_data_marked_for_deletion: + return True + + is_valid = super().is_valid() + if not is_valid: + return False + + # Check if the details field has content + details = self.cleaned_data.get('feb_naming_requirements_details', '').strip() + if not details: + return False + + return True + class PurposeForm(RegistrarForm): purpose = forms.CharField( label="Purpose", diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index ceaa19e77..e90edfa72 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -1401,13 +1401,12 @@ class DomainRequest(TimeStampedModel): def is_feb(self) -> bool: """Is this domain request for a Federal Executive Branch agency?""" - # if not self.generic_org_type: - # # generic_org_type is either blank or None, assume no - # return False - # if self.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL: - # return self.federal_type == DomainRequest.FederalChoices.EXECUTIVE - # return False - return True # TODO: this is for testing, remove before merging + if not self.generic_org_type: + # generic_org_type is either blank or None, assume no + return False + if self.generic_org_type == DomainRequest.OrganizationChoices.FEDERAL: + return self.federal_type == DomainRequest.FederalChoices.EXECUTIVE + return False def is_federal(self) -> Union[bool, None]: """Is this domain request for a federal agency? diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 83662505f..fce15af24 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -674,27 +674,33 @@ class DotgovDomain(DomainRequestWizard): 2: ExecutiveNamingRequirementsYesNoForm 3: ExecutiveNamingRequirementsDetailsForm """ + logger.debug("Validating dotgov domain form") # If not a federal executive branch agency, mark executive-related forms for deletion. if not (self.domain_request.is_feb()): forms_list[2].mark_form_for_deletion() forms_list[3].mark_form_for_deletion() return all(form.is_valid() for form in forms_list) - valid = True - yesno_form = forms_list[2] - details_form = forms_list[3] + if not forms_list[2].is_valid(): + logger.debug("Dotgov domain form is invalid") + if forms_list[2].cleaned_data.get("feb_naming_requirements", None) != "no": + forms_list[3].mark_form_for_deletion() + return False + + logger.debug(f"feb_naming_requirements: {forms_list[2].cleaned_data.get('feb_naming_requirements', None)}") - if yesno_form.cleaned_data.get("feb_naming_requirements") == "yes": - # If the user selects "yes", no details are needed. - details_form.mark_form_for_deletion() + if forms_list[2].cleaned_data.get("feb_naming_requirements", None) != "no": + logger.debug("Marking details form for deletion") + # If the user selects "yes" or has made no selection, no details are needed. + forms_list[3].mark_form_for_deletion() valid = all( form.is_valid() for i, form in enumerate(forms_list) if i != 3 ) else: # "No" was selected – details are required. valid = ( - yesno_form.is_valid() and - details_form.is_valid() and + forms_list[2].is_valid() and + forms_list[3].is_valid() and all(form.is_valid() for i, form in enumerate(forms_list) if i not in [2, 3]) ) return valid From 3f2add63afcffe8f2cf7df44c0ff22e7394ea7cf Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 24 Feb 2025 15:03:59 -0700 Subject: [PATCH 03/70] Light refactor of search and export component for tables --- .../assets/src/sass/_theme/_base.scss | 2 - .../includes/domain_requests_table.html | 46 ++--------------- .../templates/includes/domains_table.html | 48 ++++-------------- .../templates/includes/members_table.html | 44 ++-------------- .../templates/includes/search_table.html | 50 +++++++++++++++++++ 5 files changed, 69 insertions(+), 121 deletions(-) create mode 100644 src/registrar/templates/includes/search_table.html diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 9ca1355c3..1c0e9b124 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -99,9 +99,7 @@ body { } .section-outlined__search { flex-grow: 4; - // Align right max-width: 383px; - margin-left: auto; } } } diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 8adc0929a..38f392f20 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -5,53 +5,17 @@
-
+
{% if not portfolio %}

Domain requests

{% else %} {% endif %} - - - + + {% with label_text=portfolio|yesno:"Search by domain name or creator,Search by domain name" item_id_prefix="domain-requests" aria_label_text="Domain requests search component" %} + {% include "includes/search_table.html" %} + {% endwith %}
{% if portfolio %} diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 3cf04a830..e172a1065 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -26,54 +26,24 @@ {% endif %}
-
+
{% if not portfolio %}

Domains

{% else %} {% endif %} - + {% if user_domain_count and user_domain_count > 0 %} - + {% with label_text="Search by domain name" item_id_prefix="domains" aria_label_text="Domains search component" with_export="true" export_aria="Domains report component" export_url='export_data_type_user' %} + {% include "includes/search_table.html" %} + {% endwith %} + {% else %} + {% with label_text="Search by domain name" item_id_prefix="domains" aria_label_text="Domains search component"%} + {% include "includes/search_table.html" %} + {% endwith %} {% endif %}
- {% if num_expiring_domains > 0 and not portfolio %}
diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index be1715f30..d09e6de05 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -7,45 +7,11 @@
- - - -
+ + {% with label_text="Search by member name" item_id_prefix="members" aria_label_text="Members search component" with_export="true" export_aria="Members report component" export_url='export_members_portfolio'%} + {% include "includes/search_table.html" %} + {% endwith %} +
- {% if original_object.rejection_reason_email %} {% else %} diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 93a0a16b5..5fe2f3b3b 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -105,6 +105,7 @@ class AutocompleteSelectWithPlaceholder(AutocompleteSelect): 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 def __init__(self, field, admin_site, attrs=None, choices=(), using=None): From 8250a72f7357c1612d521f1a0ece200209822c83 Mon Sep 17 00:00:00 2001 From: CocoByte Date: Wed, 26 Feb 2025 18:51:05 -0700 Subject: [PATCH 09/70] updates --- .../assets/src/js/getgov-admin/andi.js | 66 +++++++++++-------- .../admin/includes/detail_table_fieldset.html | 7 +- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/registrar/assets/src/js/getgov-admin/andi.js b/src/registrar/assets/src/js/getgov-admin/andi.js index 416c4f05f..9be03fa89 100644 --- a/src/registrar/assets/src/js/getgov-admin/andi.js +++ b/src/registrar/assets/src/js/getgov-admin/andi.js @@ -1,36 +1,50 @@ +/* +This function intercepts all select2 dropdowns and adds aria content. +It relies on an override in detail_table_fieldset.html that provides +a span with a corresponding id for aria-describedby content. + +This allows us to avoid overriding aria-label, which is used by select2 +to send the current dropdown selection to ANDI +*/ export function initAriaInjections() { console.log("FIRED") document.addEventListener('DOMContentLoaded', function () { - // Find all spans with "--aria-description" in their id - const descriptionSpans = document.querySelectorAll('span[id*="--aria-description"]'); + // Set timeout so this fires after select2.js finishes adding to the DOM + setTimeout(function () { + // Find all spans with "--aria-description" in their id + const descriptionSpans = document.querySelectorAll('span[id*="--aria-description"]'); - // Iterate through each span to add aria-describedby - descriptionSpans.forEach(function(span) { - // Extract the base ID from the span's id (remove "--aria-description" part) - const fieldId = span.id.replace('--aria-description', ''); + // Iterate through each span to add aria-describedby + descriptionSpans.forEach(function(span) { + // Extract the base ID from the span's id (remove "--aria-description" part) + const fieldId = span.id.replace('--aria-description', ''); - // Find the field element with the corresponding ID - const field = document.getElementById(fieldId); + // Find the field element with the corresponding ID + const field = document.getElementById(fieldId); - // If the field exists, set the aria-describedby attribute - if (field) { - let select2ElementDetected = false - if (field.classList.contains('admin-autocomplete')) { - console.log("select2---> select2-"+${fieldId}+"-container") - // If it's a Select2 component, find the rendered span inside Select2 - const select2Span = field.querySelector('.select2-selection'); - if (select2Span) { - console.log("set select2 aria") - select2Span.setAttribute('aria-describedby', span.id); - select2ElementDetected=true + // If the field exists, set the aria-describedby attribute + if (field) { + let select2ElementDetected = false + if (field.classList.contains('admin-autocomplete')) { + const select2Id="select2-"+fieldId+"-container" + console.log("select2---> "+select2Id) + // If it's a Select2 component, find the rendered span inside Select2 + const select2SpanThatTriggersAria = document.querySelector("span[aria-labelledby='"+select2Id+"']"); + const select2SpanThatHoldsSelection = document.getElementById(select2Id) + if (select2SpanThatTriggersAria) { + console.log("set select2 aria") + select2SpanThatTriggersAria.setAttribute('aria-describedby', span.id); + // select2SpanThatTriggersAria.setAttribute('aria-labelledby', select2Id); + select2ElementDetected=true + } + } + if (!select2ElementDetected) + { + // Otherwise, set aria-describedby directly on the field + field.setAttribute('aria-describedby', span.id); } - } - if (!select2ElementDetected) - { - // Otherwise, set aria-describedby directly on the field - field.setAttribute('aria-describedby', span.id); } - } - }); + }); + }, 500); }); } \ No newline at end of file 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 bf58853eb..3ca6a3b66 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -160,8 +160,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endblock field_readonly %} {% block field_other %} - - {% if "related_widget_wrapper" in field.field.field.widget.template_name or field.field.field.widget.input_type == 'select' %} + {% comment %} + .gov override - add Aria messages for select2 dropdowns. These messages are hooked-up to their respective DOM + elements via javascript (see andi.js) + {% endcomment %} + {% if "related_widget_wrapper" in field.field.field.widget.template_name %} {{ field.field.label }}, combo-box, collapsed, edit, has autocomplete. To set the value, use the arrow keys or type the text. From fb066839820178095475b2c14848e8e76003a3ea Mon Sep 17 00:00:00 2001 From: Matt-Spence Date: Thu, 27 Feb 2025 13:03:50 -0500 Subject: [PATCH 10/70] pushing to another device --- .../templates/domain_request_purpose.html | 127 +++++++++--------- src/registrar/tests/test_forms.py | 4 +- src/registrar/views/domain_request.py | 6 +- 3 files changed, 67 insertions(+), 70 deletions(-) diff --git a/src/registrar/templates/domain_request_purpose.html b/src/registrar/templates/domain_request_purpose.html index d0fb65042..bef221c4b 100644 --- a/src/registrar/templates/domain_request_purpose.html +++ b/src/registrar/templates/domain_request_purpose.html @@ -5,88 +5,85 @@

.Gov domains are intended for public use. Domains will not be given to organizations that only want to reserve a domain name (defensive registration) or that only intend to use the domain internally (as for an intranet).

Read about activities that are prohibited on .gov domains.

What is the purpose of your requested domain?

-

{{requires_feb_questions}}

{% endblock %} -{% if requires_feb_questions %} - {% block feb_fields %} -
- {{forms.0.management_form}} - {{forms.1.management_form}} - {{forms.2.management_form}} - {{forms.3.management_form}} - {{forms.4.management_form}} - {{forms.5.management_form}} -

- Select One * -

- {% with add_label_class="usa-sr-only" attr_required="required" maxlength="2000" %} - {% input_with_errors forms.0.feb_purpose_choice %} - {% endwith %} +{% block form_required_fields_help_text %} +{# commented out so it does not appear on this page #} +{% endblock %} - - -

Do you have a target time frame for launching this domain?

-
-

- Select One * -

- {% with add_label_class="usa-sr-only" attr_required="required" maxlength="2000" %} - {% input_with_errors forms.2.has_timeframe %} - {% endwith %} + - - -

Will the domain name be used for an interagency initiative?

-
-

- Select One * -

- {% with add_label_class="usa-sr-only" attr_required="required" maxlength="2000" %} - {% input_with_errors forms.4.is_interagency_initiative %} - {% endwith %} + - -
- {% endblock %} -{% else %} -

Describe how you’ll use your .gov domain. Will it be used for a website, email, or something else?

- {% block form_required_fields_help_text %} - {# commented out so it does not appear on this page #} - {% endblock %} + + + {% else %} +

Describe how you’ll use your .gov domain. Will it be used for a website, email, or something else?

- {% block form_fields %} - {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} - {% input_with_errors forms.1.purpose %} - {% endwith %} - {% endblock %} -{% endif %} + {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% input_with_errors forms.1.purpose %} + {% endwith %} + {% endif %} +{% endblock %} diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index 35a7d76ac..4a43e70d8 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -14,7 +14,7 @@ from registrar.forms.domain_request_wizard import ( OtherContactsForm, RequirementsForm, TribalGovernmentForm, - PurposeForm, + PurposeDetailsForm, AnythingElseForm, AboutYourOrganizationForm, ) @@ -257,7 +257,7 @@ class TestFormValidation(MockEppLib): @less_console_noise_decorator def test_purpose_form_character_count_invalid(self): """Response must be less than 2000 characters.""" - form = PurposeForm( + form = PurposeDetailsForm( data={ "purpose": "Bacon ipsum dolor amet fatback strip steak pastrami" "shankle, drumstick doner chicken landjaeger turkey andouille." diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 372861c07..2d755c636 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -184,7 +184,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): def requires_feb_questions(self) -> bool: # TODO: this is for testing, revert later - return False + return True # return self.domain_request.is_feb() and flag_is_active_for_user(self.request.user, "organization_feature") @property @@ -754,7 +754,7 @@ class Purpose(DomainRequestWizard): # we only care about the purpose form in this case since it's used in both instances return purpose_details_form.is_valid() - if not feb_purpose_options_form.id_valid(): + if not feb_purpose_options_form.is_valid(): # Ensure details form doesn't throw errors if it's not showing purpose_details_form.mark_form_for_deletion() @@ -764,7 +764,7 @@ class Purpose(DomainRequestWizard): if not feb_timeframe_yes_no_form.is_valid() or not feb_initiative_yes_no_form.cleaned_data.get("is_interagency_initiative"): # Ensure details form doesn't throw errors if it's not showing - feb_timeframe_details_form.mark_form_for_delation() + feb_timeframe_details_form.mark_form_for_deletion() valid = all(form.is_valid() for form in forms_list if not form.form_data_marked_for_deletion) From 8d2a24767405de7b36cef3a301b301b8b5129e4c Mon Sep 17 00:00:00 2001 From: CuriousX Date: Fri, 28 Feb 2025 11:10:53 -0700 Subject: [PATCH 11/70] Update src/registrar/assets/src/js/getgov-admin/andi.js Co-authored-by: zandercymatics <141044360+zandercymatics@users.noreply.github.com> --- src/registrar/assets/src/js/getgov-admin/andi.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/assets/src/js/getgov-admin/andi.js b/src/registrar/assets/src/js/getgov-admin/andi.js index 9be03fa89..8c17b2d07 100644 --- a/src/registrar/assets/src/js/getgov-admin/andi.js +++ b/src/registrar/assets/src/js/getgov-admin/andi.js @@ -7,7 +7,6 @@ This allows us to avoid overriding aria-label, which is used by select2 to send the current dropdown selection to ANDI */ export function initAriaInjections() { - console.log("FIRED") document.addEventListener('DOMContentLoaded', function () { // Set timeout so this fires after select2.js finishes adding to the DOM setTimeout(function () { From c821b20e8eaed6b53d0e902483d233560e581e76 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Fri, 28 Feb 2025 12:54:51 -0600 Subject: [PATCH 12/70] push remote for demo --- .../src/js/getgov/domain-purpose-form.js | 44 +++++++++++++++ src/registrar/assets/src/js/getgov/main.js | 10 +++- src/registrar/assets/src/js/getgov/radios.js | 56 ++++++++++++++++++- .../forms/domainrequestwizard/purpose.py | 7 --- .../templates/domain_request_purpose.html | 40 ++++++------- src/registrar/views/domain_request.py | 21 +++++-- 6 files changed, 138 insertions(+), 40 deletions(-) create mode 100644 src/registrar/assets/src/js/getgov/domain-purpose-form.js diff --git a/src/registrar/assets/src/js/getgov/domain-purpose-form.js b/src/registrar/assets/src/js/getgov/domain-purpose-form.js new file mode 100644 index 000000000..fa13305b6 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/domain-purpose-form.js @@ -0,0 +1,44 @@ +import { showElement } from './helpers.js'; + +export const domain_purpose_choice_callbacks = { + 'new': { + callback: function(value, element) { + console.log("Callback for new") + //show the purpose details container + showElement(element); + // change just the text inside the em tag + const labelElement = element.querySelector('.usa-label em'); + labelElement.innerHTML = 'Explain why a new domain is required and why a ' + + 'subdomain of an existing domain doesn\'t meet your needs.' + + '

' + // Adding double line break for spacing + 'Include any data that supports a clear public benefit or ' + + 'evidence user need for this new domain. ' + + '*'; + }, + element: document.getElementById('domain-purpose-details-container') + }, + 'redirect': { + callback: function(value, element) { + console.log("Callback for redirect") + // show the purpose details container + showElement(element); + // change just the text inside the em tag + const labelElement = element.querySelector('.usa-label em'); + labelElement.innerHTML = 'Explain why a redirect is necessary. ' + + '*'; + }, + element: document.getElementById('domain-purpose-details-container') + }, + 'other': { + callback: function(value, element) { + console.log("Callback for other") + // Show the purpose details container + showElement(element); + // change just the text inside the em tag + const labelElement = element.querySelector('.usa-label em'); + labelElement.innerHTML = 'Describe how this domain will be used. ' + + '*'; + }, + element: document.getElementById('domain-purpose-details-container') + } +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 796e6f815..184fd05cb 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -1,4 +1,4 @@ -import { hookupYesNoListener, hookupRadioTogglerListener } from './radios.js'; +import { hookupYesNoListener, hookupCallbacksToRadioToggler } from './radios.js'; import { initDomainValidators } from './domain-validators.js'; import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js'; import { initializeUrbanizationToggle } from './urbanization.js'; @@ -15,7 +15,7 @@ import { initDomainManagersPage } from './domain-managers.js'; import { initDomainDSData } from './domain-dsdata.js'; import { initDomainDNSSEC } from './domain-dnssec.js'; import { initFormErrorHandling } from './form-errors.js'; - +import { domain_purpose_choice_callbacks } from './domain-purpose-form.js'; initDomainValidators(); initFormsetsForms(); @@ -27,6 +27,12 @@ hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); hookupYesNoListener("dotgov_domain-feb_naming_requirements", null, "domain-naming-requirements-details-container"); +hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choice_callbacks); + +hookupYesNoListener("purpose-has_timeframe", "domain-timeframe-details-container", null); +hookupYesNoListener("purpose-is_interagency_initiative", "domain-interagency-initaitive-details-container", null); + + initializeUrbanizationToggle(); userProfileListener(); diff --git a/src/registrar/assets/src/js/getgov/radios.js b/src/registrar/assets/src/js/getgov/radios.js index 055bdf621..d5feb05d5 100644 --- a/src/registrar/assets/src/js/getgov/radios.js +++ b/src/registrar/assets/src/js/getgov/radios.js @@ -17,7 +17,7 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme 'False': elementIdToShowIfNo }); } - + /** * Hookup listeners for radio togglers in form fields. * @@ -75,3 +75,57 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { handleRadioButtonChange(); } } + +/** + * Hookup listeners for radio togglers in form fields. + * + * Parameters: + * - radioButtonName: The "name=" value for the radio buttons being used as togglers + * - valueToCallbackMap: An object where keys are the values of the radio buttons, + * and values are dictionaries containing a 'callback' key and an optional 'element' key. + * If provided, the element will be passed in as the second argument to the callback function. + * + * Usage Example: + * Assuming you have radio buttons with values 'option1', 'option2', and 'option3', + * and corresponding callback functions 'function1', 'function2', 'function3' that will + * apply to elements 'element1', 'element2', 'element3' respectively. + * + * hookupCallbacksToRadioToggler('exampleRadioGroup', { + * 'option1': {callback: function1, element: element1}, + * 'option2': {callback: function2, element: element2}, + * 'option3': {callback: function3} // No element provided + * }); + * + * Picking the 'option1' radio button will call function1('option1', element1). + * Picking the 'option3' radio button will call function3('option3') without a second parameter. + **/ +export function hookupCallbacksToRadioToggler(radioButtonName, valueToCallbackMap) { + // Get the radio buttons + let radioButtons = document.querySelectorAll(`input[name="${radioButtonName}"]`); + + function handleRadioButtonChange() { + // Find the checked radio button + let radioButtonChecked = document.querySelector(`input[name="${radioButtonName}"]:checked`); + let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; + + // Execute the callback function for the selected value + if (selectedValue && valueToCallbackMap[selectedValue]) { + const entry = valueToCallbackMap[selectedValue]; + if ('element' in entry) { + entry.callback(selectedValue, entry.element); + } else { + entry.callback(selectedValue); + } + } + } + + if (radioButtons && radioButtons.length) { + // Add event listener to each radio button + radioButtons.forEach(function (radioButton) { + radioButton.addEventListener('change', handleRadioButtonChange); + }); + + // Initialize by checking the current state + handleRadioButtonChange(); + } +} \ No newline at end of file diff --git a/src/registrar/forms/domainrequestwizard/purpose.py b/src/registrar/forms/domainrequestwizard/purpose.py index 23e5c28e9..52946a5e6 100644 --- a/src/registrar/forms/domainrequestwizard/purpose.py +++ b/src/registrar/forms/domainrequestwizard/purpose.py @@ -20,13 +20,6 @@ class FEBPurposeOptionsForm(BaseDeletableRegistrarForm): class PurposeDetailsForm(BaseDeletableRegistrarForm): - labels = { - "new": "Explain why a new domain is required and why a subdomain of an existing domain doesn't meet your needs. \ - Include any data that supports a clear public benefit or evident user need for this new domain.", - "redirect": "Explain why a redirect is necessary", - "other": "Describe how this domain will be used", - } - field_name="purpose" purpose = forms.CharField( diff --git a/src/registrar/templates/domain_request_purpose.html b/src/registrar/templates/domain_request_purpose.html index bef221c4b..a7d5ddbfd 100644 --- a/src/registrar/templates/domain_request_purpose.html +++ b/src/registrar/templates/domain_request_purpose.html @@ -7,10 +7,6 @@

What is the purpose of your requested domain?

{% endblock %} -{% block form_required_fields_help_text %} -{# commented out so it does not appear on this page #} -{% endblock %} - {% block form_fields %} {% if requires_feb_questions %}
@@ -21,60 +17,56 @@ {{forms.4.management_form}} {{forms.5.management_form}}

- Select One * + Select One *

{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} {% input_with_errors forms.0.feb_purpose_choice %} {% endwith %} -
{% else %} diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 2d755c636..9ecd66191 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -745,26 +745,35 @@ class Purpose(DomainRequestWizard): feb_initiative_details_form = forms_list[5] if not self.requires_feb_questions(): - # if FEB questions don't apply, mark all other forms for deletion + # if FEB questions don't apply, mark those forms for deletion feb_purpose_options_form.mark_form_for_deletion() feb_timeframe_yes_no_form.mark_form_for_deletion() feb_timeframe_details_form.mark_form_for_deletion() feb_initiative_yes_no_form.mark_form_for_deletion() feb_initiative_details_form.mark_form_for_deletion() - # we only care about the purpose form in this case since it's used in both instances + # we only care about the purpose details form in this case since it's used in both instances return purpose_details_form.is_valid() if not feb_purpose_options_form.is_valid(): # Ensure details form doesn't throw errors if it's not showing purpose_details_form.mark_form_for_deletion() + + feb_timeframe_valid = feb_timeframe_yes_no_form.is_valid() + feb_initiative_valid = feb_initiative_yes_no_form.is_valid() + + logger.debug(f"feb timeframe yesno: {feb_timeframe_yes_no_form.cleaned_data.get('has_timeframe')}") + logger.debug(f"FEB initiative yesno: {feb_initiative_yes_no_form.cleaned_data.get('is_interagency_initiative')}") - if not feb_initiative_yes_no_form.is_valid() or not feb_timeframe_yes_no_form.cleaned_data.get("has_timeframe"): + if not feb_timeframe_valid or not feb_timeframe_yes_no_form.cleaned_data.get("has_timeframe"): + # Ensure details form doesn't throw errors if it's not showing + feb_timeframe_details_form.mark_form_for_deletion() + + if not feb_initiative_valid or not feb_initiative_yes_no_form.cleaned_data.get("is_interagency_initiative"): # Ensure details form doesn't throw errors if it's not showing feb_initiative_details_form.mark_form_for_deletion() - if not feb_timeframe_yes_no_form.is_valid() or not feb_initiative_yes_no_form.cleaned_data.get("is_interagency_initiative"): - # Ensure details form doesn't throw errors if it's not showing - feb_timeframe_details_form.mark_form_for_deletion() + for i, form in enumerate(forms_list): + logger.debug(f"Form {i} is marked for deletion: {form.form_data_marked_for_deletion}") valid = all(form.is_valid() for form in forms_list if not form.form_data_marked_for_deletion) From c65b67d508056a5a918e06a8efcc0dc3f14edcac Mon Sep 17 00:00:00 2001 From: CocoByte Date: Mon, 3 Mar 2025 11:38:42 -0700 Subject: [PATCH 13/70] linted --- src/registrar/utility/admin_helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 5fe2f3b3b..93a0a16b5 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -105,7 +105,6 @@ class AutocompleteSelectWithPlaceholder(AutocompleteSelect): 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 def __init__(self, field, admin_site, attrs=None, choices=(), using=None): From 4004a2f73566b5d2e2c1b1d3207b904dfebbab6c Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Mon, 3 Mar 2025 16:11:50 -0600 Subject: [PATCH 14/70] add FEB purpose questions --- .../src/js/getgov/domain-purpose-form.js | 9 +- src/registrar/assets/src/js/getgov/main.js | 6 +- .../forms/domainrequestwizard/purpose.py | 24 ++-- src/registrar/models/domain_request.py | 4 +- .../templates/domain_request_purpose.html | 38 +++--- src/registrar/tests/test_forms.py | 3 +- src/registrar/tests/test_views_request.py | 119 ++++++++++++++++++ src/registrar/views/domain_request.py | 49 ++++---- 8 files changed, 191 insertions(+), 61 deletions(-) diff --git a/src/registrar/assets/src/js/getgov/domain-purpose-form.js b/src/registrar/assets/src/js/getgov/domain-purpose-form.js index fa13305b6..7cde5bc35 100644 --- a/src/registrar/assets/src/js/getgov/domain-purpose-form.js +++ b/src/registrar/assets/src/js/getgov/domain-purpose-form.js @@ -3,7 +3,6 @@ import { showElement } from './helpers.js'; export const domain_purpose_choice_callbacks = { 'new': { callback: function(value, element) { - console.log("Callback for new") //show the purpose details container showElement(element); // change just the text inside the em tag @@ -15,11 +14,10 @@ export const domain_purpose_choice_callbacks = { 'evidence user need for this new domain. ' + '*'; }, - element: document.getElementById('domain-purpose-details-container') + element: document.getElementById('purpose-details-container') }, 'redirect': { callback: function(value, element) { - console.log("Callback for redirect") // show the purpose details container showElement(element); // change just the text inside the em tag @@ -27,11 +25,10 @@ export const domain_purpose_choice_callbacks = { labelElement.innerHTML = 'Explain why a redirect is necessary. ' + '*'; }, - element: document.getElementById('domain-purpose-details-container') + element: document.getElementById('purpose-details-container') }, 'other': { callback: function(value, element) { - console.log("Callback for other") // Show the purpose details container showElement(element); // change just the text inside the em tag @@ -39,6 +36,6 @@ export const domain_purpose_choice_callbacks = { labelElement.innerHTML = 'Describe how this domain will be used. ' + '*'; }, - element: document.getElementById('domain-purpose-details-container') + element: document.getElementById('purpose-details-container') } } \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 184fd05cb..139c8484a 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -29,8 +29,8 @@ hookupYesNoListener("dotgov_domain-feb_naming_requirements", null, "domain-namin hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choice_callbacks); -hookupYesNoListener("purpose-has_timeframe", "domain-timeframe-details-container", null); -hookupYesNoListener("purpose-is_interagency_initiative", "domain-interagency-initaitive-details-container", null); +hookupYesNoListener("purpose-has_timeframe", "purpose-timeframe-details-container", null); +hookupYesNoListener("purpose-is_interagency_initiative", "purpose-interagency-initaitive-details-container", null); initializeUrbanizationToggle(); @@ -56,4 +56,4 @@ initFormErrorHandling(); // Init the portfolio new member page initPortfolioMemberPageRadio(); initPortfolioNewMemberPageToggle(); -initAddNewMemberPageListeners(); +initAddNewMemberPageListeners(); \ No newline at end of file diff --git a/src/registrar/forms/domainrequestwizard/purpose.py b/src/registrar/forms/domainrequestwizard/purpose.py index 52946a5e6..43dd62290 100644 --- a/src/registrar/forms/domainrequestwizard/purpose.py +++ b/src/registrar/forms/domainrequestwizard/purpose.py @@ -1,12 +1,17 @@ from django import forms from django.core.validators import MaxLengthValidator -from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm, RegistrarForm +from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm + class FEBPurposeOptionsForm(BaseDeletableRegistrarForm): field_name = "feb_purpose_choice" - form_choices = (("new", "Used for a new website"), ("redirect", "Used as a redirect for an existing website"), ("other", "Not for a website")) + form_choices = ( + ("new", "Used for a new website"), + ("redirect", "Used as a redirect for an existing website"), + ("other", "Not for a website"), + ) feb_purpose_choice = forms.ChoiceField( required=True, @@ -15,12 +20,13 @@ class FEBPurposeOptionsForm(BaseDeletableRegistrarForm): error_messages={ "required": "This question is required.", }, - label = "Select one" + label="Select one", ) + class PurposeDetailsForm(BaseDeletableRegistrarForm): - field_name="purpose" + field_name = "purpose" purpose = forms.CharField( label="Purpose", @@ -39,6 +45,7 @@ class PurposeDetailsForm(BaseDeletableRegistrarForm): error_messages={"required": "Describe how you’ll use the .gov domain you’re requesting."}, ) + class FEBTimeFrameYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm): """ Form for determining whether the domain request comes with a target timeframe for launch. @@ -73,6 +80,7 @@ class FEBTimeFrameDetailsForm(BaseDeletableRegistrarForm): error_messages={"required": "Provide details on your target timeframe."}, ) + class FEBInteragencyInitiativeYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm): """ Form for determining whether the domain request is part of an interagency initative. @@ -92,11 +100,7 @@ class FEBInteragencyInitiativeYesNoForm(BaseDeletableRegistrarForm, BaseYesNoFor class FEBInteragencyInitiativeDetailsForm(BaseDeletableRegistrarForm): interagency_initiative_details = forms.CharField( label="interagency_initiative_details", - widget=forms.Textarea( - attrs={ - "aria-label": "Name the agencies that will be involved in this initiative." - } - ), + widget=forms.Textarea(attrs={"aria-label": "Name the agencies that will be involved in this initiative."}), validators=[ MaxLengthValidator( 2000, @@ -104,4 +108,4 @@ class FEBInteragencyInitiativeDetailsForm(BaseDeletableRegistrarForm): ) ], error_messages={"required": "Name the agencies that will be involved in this initiative."}, - ) \ No newline at end of file + ) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index b45767598..b7aaff65d 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -53,7 +53,7 @@ class DomainRequest(TimeStampedModel): def get_status_label(cls, status_name: str): """Returns the associated label for a given status name""" return cls(status_name).label if status_name else None - + class FEBPurposeChoices(models.TextChoices): WEBSITE = "website" REDIRECT = "redirect" @@ -558,8 +558,6 @@ class DomainRequest(TimeStampedModel): help_text="Other domain names the creator provided for consideration", ) - - other_contacts = models.ManyToManyField( "registrar.Contact", blank=True, diff --git a/src/registrar/templates/domain_request_purpose.html b/src/registrar/templates/domain_request_purpose.html index a7d5ddbfd..fb5145476 100644 --- a/src/registrar/templates/domain_request_purpose.html +++ b/src/registrar/templates/domain_request_purpose.html @@ -7,6 +7,10 @@

What is the purpose of your requested domain?

{% endblock %} +{% block form_required_fields_help_text %} +{# empty this block so it doesn't show on this page #} +{% endblock %} + {% block form_fields %} {% if requires_feb_questions %}
@@ -16,54 +20,54 @@ {{forms.3.management_form}} {{forms.4.management_form}} {{forms.5.management_form}} -

- Select One * +

+ Select one. *

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

What is the purpose of your requested domain?

Describe how you’ll use your .gov domain. Will it be used for a website, email, or something else?

{% with attr_maxlength=2000 add_label_class="usa-sr-only" %} From 0bc6595a17321739e5748d6e828a904ff8b1b216 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 11 Mar 2025 15:16:48 -0700 Subject: [PATCH 62/70] Fix the manage url issue for action needed emails --- .../templates/emails/transition_domain_invitation.txt | 2 +- src/registrar/utility/admin_helpers.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/emails/transition_domain_invitation.txt b/src/registrar/templates/emails/transition_domain_invitation.txt index 14dd626dd..35947eb72 100644 --- a/src/registrar/templates/emails/transition_domain_invitation.txt +++ b/src/registrar/templates/emails/transition_domain_invitation.txt @@ -57,7 +57,7 @@ THANK YOU The .gov team .Gov blog -Domain management <{{ manage_url }}}> +Domain management <{{ manage_url }}> Get.gov The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 93a0a16b5..adbc182d0 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -1,4 +1,5 @@ from registrar.models.domain_request import DomainRequest +from django.conf import settings from django.template.loader import get_template from django.utils.html import format_html from django.urls import reverse @@ -35,8 +36,13 @@ def _get_default_email(domain_request, file_path, reason, excluded_reasons=None) return None recipient = domain_request.creator + env_base_url = settings.BASE_URL + # If NOT in prod, update instances of "manage.get.gov" links to point to + # current environment, ie "getgov-rh.app.cloud.gov" + manage_url = env_base_url if not settings.IS_PRODUCTION else "https://manage.get.gov" + # Return the context of the rendered views - context = {"domain_request": domain_request, "recipient": recipient, "reason": reason} + context = {"domain_request": domain_request, "recipient": recipient, "reason": reason, "manage_url": manage_url} email_body_text = get_template(file_path).render(context=context) email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None From 266e0b7f53a7838dcca67f097c86db69d4f54968 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 12 Mar 2025 10:32:03 -0500 Subject: [PATCH 63/70] fix migrations --- ...equest_feb_naming_requirements_and_more.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/registrar/migrations/0142_domainrequest_feb_naming_requirements_and_more.py diff --git a/src/registrar/migrations/0142_domainrequest_feb_naming_requirements_and_more.py b/src/registrar/migrations/0142_domainrequest_feb_naming_requirements_and_more.py new file mode 100644 index 000000000..5a8dbeec8 --- /dev/null +++ b/src/registrar/migrations/0142_domainrequest_feb_naming_requirements_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.17 on 2025-03-10 19:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0141_alter_portfolioinvitation_additional_permissions_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="domainrequest", + name="feb_naming_requirements", + field=models.BooleanField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="feb_naming_requirements_details", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="feb_purpose_choice", + field=models.CharField( + blank=True, choices=[("website", "Website"), ("redirect", "Redirect"), ("other", "Other")], null=True + ), + ), + migrations.AddField( + model_name="domainrequest", + name="has_timeframe", + field=models.BooleanField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="interagency_initiative_details", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="is_interagency_initiative", + field=models.BooleanField(blank=True, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="time_frame_details", + field=models.TextField(blank=True, null=True), + ), + ] From 47c795cc9f0cc6e09a6d8b6c59036d6c2135973a Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 12 Mar 2025 10:39:02 -0500 Subject: [PATCH 64/70] migration fix --- ...equest_feb_naming_requirements_and_more.py | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 src/registrar/migrations/0141_domainrequest_feb_naming_requirements_and_more.py diff --git a/src/registrar/migrations/0141_domainrequest_feb_naming_requirements_and_more.py b/src/registrar/migrations/0141_domainrequest_feb_naming_requirements_and_more.py deleted file mode 100644 index ad29e57d0..000000000 --- a/src/registrar/migrations/0141_domainrequest_feb_naming_requirements_and_more.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 4.2.17 on 2025-03-10 19:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("registrar", "0140_alter_portfolioinvitation_additional_permissions_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="domainrequest", - name="feb_naming_requirements", - field=models.BooleanField(blank=True, null=True), - ), - migrations.AddField( - model_name="domainrequest", - name="feb_naming_requirements_details", - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name="domainrequest", - name="feb_purpose_choice", - field=models.CharField( - blank=True, choices=[("website", "Website"), ("redirect", "Redirect"), ("other", "Other")], null=True - ), - ), - migrations.AddField( - model_name="domainrequest", - name="has_timeframe", - field=models.BooleanField(blank=True, null=True), - ), - migrations.AddField( - model_name="domainrequest", - name="interagency_initiative_details", - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name="domainrequest", - name="is_interagency_initiative", - field=models.BooleanField(blank=True, null=True), - ), - migrations.AddField( - model_name="domainrequest", - name="time_frame_details", - field=models.TextField(blank=True, null=True), - ), - ] From 0dd3aa5433d626543138f9456d9b98082f7e99f2 Mon Sep 17 00:00:00 2001 From: matthewswspence Date: Wed, 12 Mar 2025 10:58:51 -0500 Subject: [PATCH 65/70] minor import fix --- src/registrar/tests/test_forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index 03f20c862..1c8b127ae 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -17,7 +17,7 @@ from registrar.forms.domain_request_wizard import ( AnythingElseForm, AboutYourOrganizationForm, ) -from registrar.forms.domainrequestwizard.purpose import PurposeDetailsForm +from registrar.forms import PurposeDetailsForm from registrar.forms.domain import ContactForm from registrar.forms.portfolio import ( From 585569a006420b713f416c88a5402645b38ca514 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 12 Mar 2025 10:29:38 -0600 Subject: [PATCH 66/70] bug fix --- src/registrar/forms/portfolio.py | 10 +++- .../models/utility/portfolio_helper.py | 60 +++++++++++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index b83e718cb..2ee174050 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -22,6 +22,7 @@ from registrar.models.utility.portfolio_helper import ( get_domains_display, get_members_description_display, get_members_display, + get_portfolio_invitation_associations, ) logger = logging.getLogger(__name__) @@ -459,7 +460,14 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): if hasattr(e, "code"): field = "email" if "email" in self.fields else None if e.code == "has_existing_permissions": - self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") + existing_permissions, existing_invitations = ( + get_portfolio_invitation_associations(self.instance) + ) + + same_portfolio_for_permissions = existing_permissions.exclude(portfolio=self.instance.portfolio) + same_portfolio_for_invitations = existing_invitations.exclude(portfolio=self.instance.portfolio) + if same_portfolio_for_permissions.exists() or same_portfolio_for_invitations.exists(): + self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") override_error = True elif e.code == "has_existing_invitations": self.add_error( diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 009ea3c26..707dfcf54 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -286,8 +286,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): # == Validate the multiple_porfolios flag. == # if not flag_is_active_for_user(user_portfolio_permission.user, "multiple_portfolios"): - existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( - user=user_portfolio_permission.user + existing_permissions, existing_invitations = ( + get_user_portfolio_permission_associations(user_portfolio_permission) ) if existing_permissions.exists(): raise ValidationError( @@ -296,10 +296,6 @@ def validate_user_portfolio_permission(user_portfolio_permission): code="has_existing_permissions", ) - existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude( - Q(portfolio=user_portfolio_permission.portfolio) - | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) - ) if existing_invitations.exists(): raise ValidationError( "This user is already assigned to a portfolio invitation. " @@ -307,6 +303,29 @@ def validate_user_portfolio_permission(user_portfolio_permission): code="has_existing_invitations", ) +def get_user_portfolio_permission_associations(user_portfolio_permission): + """ + Retrieves the associations for a user portfolio invitation. + + Returns: + A tuple: + (existing_permissions, existing_invitations) + where: + - existing_permissions: UserPortfolioPermission objects excluding the current permission. + - existing_invitations: PortfolioInvitation objects for the user email excluding + the current invitation and those with status RETRIEVED. + """ + PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( + user=user_portfolio_permission.user + ) + existing_invitations = PortfolioInvitation.objects.filter(email__iexact=user_portfolio_permission.user.email).exclude( + Q(portfolio=user_portfolio_permission.portfolio) + | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + ) + return (existing_permissions, existing_invitations) + def validate_portfolio_invitation(portfolio_invitation): """ @@ -351,17 +370,14 @@ def validate_portfolio_invitation(portfolio_invitation): ) # == Validate the multiple_porfolios flag. == # - user = User.objects.filter(email=portfolio_invitation.email).first() + user = User.objects.filter(email__iexact=portfolio_invitation.email).first() # If user returns None, then we check for global assignment of multiple_portfolios. # Otherwise we just check on the user. if not flag_is_active_for_user(user, "multiple_portfolios"): - existing_permissions = UserPortfolioPermission.objects.filter(user=user) - - existing_invitations = PortfolioInvitation.objects.filter(email=portfolio_invitation.email).exclude( - Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + existing_permissions, existing_invitations = ( + get_portfolio_invitation_associations(portfolio_invitation) ) - if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " @@ -376,6 +392,26 @@ def validate_portfolio_invitation(portfolio_invitation): code="has_existing_invitations", ) +def get_portfolio_invitation_associations(portfolio_invitation): + """ + Retrieves the associations for a portfolio invitation. + + Returns: + A tuple: + (existing_permissions, existing_invitations) + where: + - existing_permissions: UserPortfolioPermission objects matching the email. + - existing_invitations: PortfolioInvitation objects for the email excluding + the current invitation and those with status RETRIEVED. + """ + PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + existing_permissions = UserPortfolioPermission.objects.filter(user__email__iexact=portfolio_invitation.email) + existing_invitations = PortfolioInvitation.objects.filter(email__iexact=portfolio_invitation.email).exclude( + Q(id=portfolio_invitation.id) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) + ) + return (existing_permissions, existing_invitations) + def cleanup_after_portfolio_member_deletion(portfolio, email, user=None): """ From beb4e722b377c3297ad5f0d142e4a98597dac07d Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:44:57 -0600 Subject: [PATCH 67/70] Fix unrelated test failure --- src/registrar/forms/portfolio.py | 8 ++++---- .../models/utility/portfolio_helper.py | 18 ++++++++++-------- src/registrar/tests/test_views_portfolio.py | 8 +++++--- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 2ee174050..db1f58d88 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -460,14 +460,14 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): if hasattr(e, "code"): field = "email" if "email" in self.fields else None if e.code == "has_existing_permissions": - existing_permissions, existing_invitations = ( - get_portfolio_invitation_associations(self.instance) - ) + existing_permissions, existing_invitations = get_portfolio_invitation_associations(self.instance) same_portfolio_for_permissions = existing_permissions.exclude(portfolio=self.instance.portfolio) same_portfolio_for_invitations = existing_invitations.exclude(portfolio=self.instance.portfolio) if same_portfolio_for_permissions.exists() or same_portfolio_for_invitations.exists(): - self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") + self.add_error( + field, f"{self.instance.email} is already a member of another .gov organization." + ) override_error = True elif e.code == "has_existing_invitations": self.add_error( diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 707dfcf54..98be6cc87 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -286,8 +286,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): # == Validate the multiple_porfolios flag. == # if not flag_is_active_for_user(user_portfolio_permission.user, "multiple_portfolios"): - existing_permissions, existing_invitations = ( - get_user_portfolio_permission_associations(user_portfolio_permission) + existing_permissions, existing_invitations = get_user_portfolio_permission_associations( + user_portfolio_permission ) if existing_permissions.exists(): raise ValidationError( @@ -303,6 +303,7 @@ def validate_user_portfolio_permission(user_portfolio_permission): code="has_existing_invitations", ) + def get_user_portfolio_permission_associations(user_portfolio_permission): """ Retrieves the associations for a user portfolio invitation. @@ -312,7 +313,7 @@ def get_user_portfolio_permission_associations(user_portfolio_permission): (existing_permissions, existing_invitations) where: - existing_permissions: UserPortfolioPermission objects excluding the current permission. - - existing_invitations: PortfolioInvitation objects for the user email excluding + - existing_invitations: PortfolioInvitation objects for the user email excluding the current invitation and those with status RETRIEVED. """ PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") @@ -320,7 +321,9 @@ def get_user_portfolio_permission_associations(user_portfolio_permission): existing_permissions = UserPortfolioPermission.objects.exclude(id=user_portfolio_permission.id).filter( user=user_portfolio_permission.user ) - existing_invitations = PortfolioInvitation.objects.filter(email__iexact=user_portfolio_permission.user.email).exclude( + existing_invitations = PortfolioInvitation.objects.filter( + email__iexact=user_portfolio_permission.user.email + ).exclude( Q(portfolio=user_portfolio_permission.portfolio) | Q(status=PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED) ) @@ -375,9 +378,7 @@ def validate_portfolio_invitation(portfolio_invitation): # If user returns None, then we check for global assignment of multiple_portfolios. # Otherwise we just check on the user. if not flag_is_active_for_user(user, "multiple_portfolios"): - existing_permissions, existing_invitations = ( - get_portfolio_invitation_associations(portfolio_invitation) - ) + existing_permissions, existing_invitations = get_portfolio_invitation_associations(portfolio_invitation) if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " @@ -392,6 +393,7 @@ def validate_portfolio_invitation(portfolio_invitation): code="has_existing_invitations", ) + def get_portfolio_invitation_associations(portfolio_invitation): """ Retrieves the associations for a portfolio invitation. @@ -401,7 +403,7 @@ def get_portfolio_invitation_associations(portfolio_invitation): (existing_permissions, existing_invitations) where: - existing_permissions: UserPortfolioPermission objects matching the email. - - existing_invitations: PortfolioInvitation objects for the email excluding + - existing_invitations: PortfolioInvitation objects for the email excluding the current invitation and those with status RETRIEVED. """ PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 2065c2d35..bb99e875f 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -3930,17 +3930,19 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest): response = self.client.post( reverse("new-member"), { - "role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, - "domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "email": self.user.email, }, + follow=True ) self.assertEqual(response.status_code, 200) + with open("debug_response.html", "w") as f: + f.write(response.content.decode('utf-8')) # Verify messages self.assertContains( response, - f"{self.user.email} is already a member of another .gov organization.", + "User is already a member of this portfolio.", ) # Validate Database has not changed From 98de052c2ecf748b58c54af1c5dd957a80df3063 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 13 Mar 2025 08:30:58 -0600 Subject: [PATCH 68/70] linting --- src/registrar/models/utility/portfolio_helper.py | 4 ---- src/registrar/tests/test_views_portfolio.py | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 98be6cc87..669985725 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -257,9 +257,6 @@ def validate_user_portfolio_permission(user_portfolio_permission): Raises: ValidationError: If any of the validation rules are violated. """ - PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") - UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") - has_portfolio = bool(user_portfolio_permission.portfolio_id) portfolio_permissions = set(user_portfolio_permission._get_portfolio_permissions()) @@ -346,7 +343,6 @@ def validate_portfolio_invitation(portfolio_invitation): Raises: ValidationError: If any of the validation rules are violated. """ - PortfolioInvitation = apps.get_model("registrar.PortfolioInvitation") UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") User = get_user_model() diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index bb99e875f..13a7a3bc6 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -3933,11 +3933,11 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest): "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, "email": self.user.email, }, - follow=True + follow=True, ) self.assertEqual(response.status_code, 200) with open("debug_response.html", "w") as f: - f.write(response.content.decode('utf-8')) + f.write(response.content.decode("utf-8")) # Verify messages self.assertContains( From 9362de496761fccdd92dde7dca80e3eea5d4d773 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 14 Mar 2025 08:51:03 -0600 Subject: [PATCH 69/70] Fix bug and add unit test --- src/registrar/admin.py | 4 +-- src/registrar/tests/test_views_portfolio.py | 40 +++++++++++++++++++++ src/registrar/views/portfolios.py | 2 +- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 09d0eaa81..28f5abf57 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1846,7 +1846,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): requested_user = get_requested_user(requested_email) permission_exists = UserPortfolioPermission.objects.filter( - user__email=requested_email, portfolio=portfolio, user__email__isnull=False + user__email__iexact=requested_email, portfolio=portfolio, user__email__isnull=False ).exists() if not permission_exists: # if permission does not exist for a user with requested_email, send email @@ -1857,7 +1857,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): is_admin_invitation=is_admin_invitation, ): messages.warning( - self.request, "Could not send email notification to existing organization admins." + request, "Could not send email notification to existing organization admins." ) # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index 13a7a3bc6..114c066b3 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -3952,6 +3952,46 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest): # assert that send_portfolio_invitation_email is not called mock_send_email.assert_not_called() + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + @patch("registrar.views.portfolios.send_portfolio_invitation_email") + def test_member_invite_for_existing_member_uppercase(self, mock_send_email): + """Tests the member invitation flow for existing portfolio member with a different case.""" + self.client.force_login(self.user) + + # Simulate a session to ensure continuity + session_id = self.client.session.session_key + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + invite_count_before = PortfolioInvitation.objects.count() + + # Simulate submission of member invite for user who has already been invited + response = self.client.post( + reverse("new-member"), + { + "role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + "email": self.user.email.upper(), + }, + follow=True, + ) + self.assertEqual(response.status_code, 200) + with open("debug_response.html", "w") as f: + f.write(response.content.decode("utf-8")) + + # Verify messages + self.assertContains( + response, + "User is already a member of this portfolio.", + ) + + # Validate Database has not changed + invite_count_after = PortfolioInvitation.objects.count() + self.assertEqual(invite_count_after, invite_count_before) + + # assert that send_portfolio_invitation_email is not called + mock_send_email.assert_not_called() + @less_console_noise_decorator @override_flag("organization_feature", active=True) @override_flag("organization_members", active=True) diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index c2ec44b9e..7fa421eaa 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -970,7 +970,7 @@ class PortfolioAddMemberView(DetailView, FormMixin): portfolio = form.cleaned_data["portfolio"] is_admin_invitation = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in form.cleaned_data["roles"] - requested_user = User.objects.filter(email=requested_email).first() + requested_user = User.objects.filter(email__iexact=requested_email).first() permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=portfolio).exists() try: if not requested_user or not permission_exists: From 127eb38259e478032b2e0de5a36d5a37d3f637ee Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 14 Mar 2025 13:28:05 -0600 Subject: [PATCH 70/70] lint --- src/registrar/admin.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 28f5abf57..343624915 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1856,9 +1856,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): portfolio=portfolio, is_admin_invitation=is_admin_invitation, ): - messages.warning( - request, "Could not send email notification to existing organization admins." - ) + messages.warning(request, "Could not send email notification to existing organization admins.") # if user exists for email, immediately retrieve portfolio invitation upon creation if requested_user is not None: obj.retrieve()