diff --git a/src/Pipfile b/src/Pipfile index 07b1db715..819481fa7 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -django = "4.2.17" +django = "4.2.20" cfenv = "*" django-cors-headers = "*" pycryptodomex = "*" @@ -34,6 +34,7 @@ tblib = "*" django-admin-multiple-choice-list-filter = "*" django-import-export = "*" django-waffle = "*" +cryptography = "*" [dev-packages] django-debug-toolbar = "*" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 6cec6c03a..914a217d0 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "07f7bc9bda4099f96b18f8f063b487b121b82ae01de06a7f2e9013d56098a421" + "sha256": "c854531923af84e93b0b26e64a0bf3b9d9c12870c4795b1afb667569ea740e2b" }, "pipfile-spec": 6, "requires": {}, @@ -281,6 +281,7 @@ "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308" ], + "index": "pypi", "markers": "python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'", "version": "==44.0.2" }, @@ -316,12 +317,12 @@ }, "django": { "hashes": [ - "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0", - "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc" + "sha256:213381b6e4405f5c8703fffc29cd719efdf189dec60c67c04f76272b3dc845b9", + "sha256:92bac5b4432a64532abb73b2ac27203f485e40225d2640a7fbef2b62b876e789" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.2.17" + "version": "==4.2.20" }, "django-admin-multiple-choice-list-filter": { "hashes": [ @@ -1389,11 +1390,11 @@ }, "botocore-stubs": { "hashes": [ - "sha256:937c9b787e4f784019f321fa1d88a505965c25f425e810bde45e23b7ca564282", - "sha256:bb9a9e7cd2f48ecb429a7d0df0387f63399db8fb363bdfa38eba285854d622a2" + "sha256:b9c3a1e8fb57fb70b49aa5380cabefab32ec028d8a1d8f5ac83dd836c5b429a8", + "sha256:c6cb18979a86db311a365448b67e4a492a530c3f4fb313432d41deaee6268b95" ], "markers": "python_version >= '3.8'", - "version": "==1.37.18" + "version": "==1.37.17" }, "click": { "hashes": [ @@ -1405,12 +1406,12 @@ }, "django": { "hashes": [ - "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0", - "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc" + "sha256:213381b6e4405f5c8703fffc29cd719efdf189dec60c67c04f76272b3dc845b9", + "sha256:92bac5b4432a64532abb73b2ac27203f485e40225d2640a7fbef2b62b876e789" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.2.17" + "version": "==4.2.20" }, "django-debug-toolbar": { "hashes": [ diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index f077448aa..933fe2757 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -28,6 +28,8 @@ initFormNameservers(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); +hookupYesNoListener("portfolio_additional_details-working_with_eop", "eop-contact-container", null); +hookupYesNoListener("portfolio_additional_details-has_anything_else_text", 'anything-else-details-container', null); hookupYesNoListener("dotgov_domain-feb_naming_requirements", null, "domain-naming-requirements-details-container"); hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choice_callbacks); @@ -35,7 +37,6 @@ hookupCallbacksToRadioToggler("purpose-feb_purpose_choice", domain_purpose_choic hookupYesNoListener("purpose-has_timeframe", "purpose-timeframe-details-container", null); hookupYesNoListener("purpose-is_interagency_initiative", "purpose-interagency-initaitive-details-container", null); - initializeUrbanizationToggle(); userProfileListener(); diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 779100f73..a7d487b40 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -615,7 +615,8 @@ class PurposeDetailsForm(BaseDeletableRegistrarForm): label="Purpose", widget=forms.Textarea( attrs={ - "aria-label": "What is the purpose of your requested domain? Describe how you’ll use your .gov domain. \ + "aria-label": "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?" } ), @@ -921,6 +922,7 @@ class AnythingElseYesNoForm(BaseYesNoForm): class RequirementsForm(RegistrarForm): + is_policy_acknowledged = forms.BooleanField( label="I read and agree to the requirements for operating a .gov domain.", error_messages={ diff --git a/src/registrar/forms/feb.py b/src/registrar/forms/feb.py index 44fd417c2..2dabbff0d 100644 --- a/src/registrar/forms/feb.py +++ b/src/registrar/forms/feb.py @@ -121,3 +121,83 @@ class FEBInteragencyInitiativeDetailsForm(BaseDeletableRegistrarForm): ], error_messages={"required": "Name the agencies that will be involved in this initiative."}, ) + + +class WorkingWithEOPYesNoForm(BaseDeletableRegistrarForm, BaseYesNoForm): + """ + Form for determining if the Federal Executive Branch (FEB) agency is working with the + Executive Office of the President (EOP) on the domain request. + """ + + field_name = "working_with_eop" + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form based on the domain_request's attributes. + """ + return self.domain_request.working_with_eop + + +class EOPContactForm(BaseDeletableRegistrarForm): + """ + Form for contact information of the representative of the + Executive Office of the President (EOP) that the Federal + Executive Branch (FEB) agency is working with. + """ + + first_name = forms.CharField( + label="First name / given name", + error_messages={"required": "Enter the first name / given name of this contact."}, + required=True, + ) + last_name = forms.CharField( + label="Last name / family name", + error_messages={"required": "Enter the last name / family name of this contact."}, + required=True, + ) + email = forms.EmailField( + label="Email", + max_length=None, + error_messages={ + "required": ("Enter an email address in the required format, like name@example.com."), + "invalid": ("Enter an email address in the required format, like name@example.com."), + }, + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + required=True, + help_text="Enter an email address in the required format, like name@example.com.", + ) + + @classmethod + def from_database(cls, obj): + return { + "first_name": obj.eop_stakeholder_first_name, + "last_name": obj.eop_stakeholder_last_name, + "email": obj.eop_stakeholder_email, + } + + def to_database(self, obj): + # This function overrides the behavior of the BaseDeletableRegistrarForm. + # in order to preserve deletable functionality, we need to call the + # superclass's to_database method if the form is marked for deletion. + if self.form_data_marked_for_deletion: + super().to_database(obj) + return + if not self.is_valid(): + return + obj.eop_stakeholder_first_name = self.cleaned_data["first_name"] + obj.eop_stakeholder_last_name = self.cleaned_data["last_name"] + obj.eop_stakeholder_email = self.cleaned_data["email"] + obj.save() + + +class FEBAnythingElseYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm): + """Yes/no toggle for the anything else question on additional details""" + + form_is_checked = property(lambda self: self.domain_request.has_anything_else_text) # type: ignore + field_name = "has_anything_else_text" diff --git a/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py b/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py new file mode 100644 index 000000000..b23b6c107 --- /dev/null +++ b/src/registrar/migrations/0144_domainrequest_eop_stakeholder_email_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.17 on 2025-03-17 20:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0143_create_groups_v18"), + ] + + operations = [ + migrations.AddField( + model_name="domainrequest", + name="eop_stakeholder_email", + field=models.EmailField(blank=True, max_length=254, null=True, verbose_name="EOP Stakeholder Email"), + ), + migrations.AddField( + model_name="domainrequest", + name="eop_stakeholder_first_name", + field=models.CharField(blank=True, null=True, verbose_name="EOP Stakeholder First Name"), + ), + migrations.AddField( + model_name="domainrequest", + name="eop_stakeholder_last_name", + field=models.CharField(blank=True, null=True, verbose_name="EOP Stakeholder Last Name"), + ), + migrations.AddField( + model_name="domainrequest", + name="working_with_eop", + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 2a4541542..d68a29ab1 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -523,6 +523,29 @@ class DomainRequest(TimeStampedModel): choices=FEBPurposeChoices.choices, ) + working_with_eop = models.BooleanField( + null=True, + blank=True, + ) + + eop_stakeholder_first_name = models.CharField( + null=True, + blank=True, + verbose_name="EOP Stakeholder First Name", + ) + + eop_stakeholder_last_name = models.CharField( + null=True, + blank=True, + verbose_name="EOP Stakeholder Last Name", + ) + + eop_stakeholder_email = models.EmailField( + null=True, + blank=True, + verbose_name="EOP Stakeholder Email", + ) + # This field is alternately used for generic domain purpose explanations # and for explanations of the specific purpose chosen with feb_purpose_choice # by a Federal Executive Branch agency. diff --git a/src/registrar/templates/domain_request_requirements.html b/src/registrar/templates/domain_request_requirements.html index 115c3ee69..4d49b235e 100644 --- a/src/registrar/templates/domain_request_requirements.html +++ b/src/registrar/templates/domain_request_requirements.html @@ -4,7 +4,7 @@ {% block form_instructions %}

Please read this page. Check the box at the bottom to show that you agree to the requirements for operating a .gov domain.

-

The .gov domain space exists to support a broad diversity of government missions. Generally, we don’t review or audit how government organizations use their registered domains. However, misuse of a .gov domain can reflect upon the integrity of the entire .gov space. There are categories of misuse that are statutorily prohibited or abusive in nature.

+

The .gov domain space exists to support a broad diversity of government missions. Generally, we don’t review or audit how government organizations use their registered domains. However, misuse of a .gov domain can reflect upon the integrity of the entire .gov space. There are categories of misuse that are statutorily prohibited or abusive in nature.

What you can’t do with a .gov domain

@@ -52,20 +52,41 @@

.Gov domains are registered for a one-year period. To renew your domain, you'll be asked to verify your organization’s eligibility and your contact information.

Though a domain may expire, it will not automatically be put on hold or deleted. We’ll make extensive efforts to contact your organization before holding or deleting a domain.

+ {% endblock %} -{% 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 %} +

Required and prohibited activities

+

Prohibitions on non-governmental use

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

Acknowledgement of .gov domain requirements

-
+

Agencies may not use a .gov domain name: +

+

+

Compliance with the 21st Century IDEA Act is required

+

As required by the DOTGOV Act, agencies must ensure + that any website or digital service that uses a .gov + domain name is in compliance with the + 21st Century Integrated Digital Experience Act. + and + Guidance for Agencies. +

+

Acknowledgement of .gov domain requirements

{% input_with_errors forms.0.is_policy_acknowledged %} + {% else %} +
+ +

Acknowledgement of .gov domain requirements

+
-
+ {% input_with_errors forms.0.is_policy_acknowledged %} +
+ {% endif %} {% endblock %} diff --git a/src/registrar/templates/portfolio_domain_request_additional_details.html b/src/registrar/templates/portfolio_domain_request_additional_details.html index 5bc529243..d7d53dd1a 100644 --- a/src/registrar/templates/portfolio_domain_request_additional_details.html +++ b/src/registrar/templates/portfolio_domain_request_additional_details.html @@ -6,16 +6,60 @@ {% endblock %} {% block form_fields %} + {% if requires_feb_questions %} + {{forms.2.management_form}} + {{forms.3.management_form}} + {{forms.4.management_form}} + {{forms.5.management_form}} +
+

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

+ +

+ Select one. * +

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

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

+

+ Select one. * +

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

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

-
+
-
-

This question is optional.

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

This question is optional.

+ {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% input_with_errors forms.0.anything_else %} + {% endwith %} +
+ {% endif %} {% endblock %} diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 8256cb188..e6ad2ef3e 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -2060,6 +2060,10 @@ class TestDomainRequestAdmin(MockEppLib): "feb_naming_requirements", "feb_naming_requirements_details", "feb_purpose_choice", + "working_with_eop", + "eop_stakeholder_first_name", + "eop_stakeholder_last_name", + "eop_stakeholder_email", "purpose", "has_timeframe", "time_frame_details", diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 119076b58..062c81f45 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -2550,7 +2550,7 @@ class DomainRequestTests(TestWithUser, WebTest): # @less_console_noise_decorator @override_flag("organization_feature", active=True) - def test_domain_request_dotgov_domain_FEB_questions(self): + def test_domain_request_FEB_questions(self): """ Test that for a member of a federal executive branch portfolio with org feature on, the dotgov domain page contains additional questions for OMB. @@ -2612,7 +2612,6 @@ class DomainRequestTests(TestWithUser, WebTest): # separate out these tests for readability self.feb_dotgov_domain_tests(dotgov_page) - # Now proceed with the actual test domain_form = dotgov_page.forms[0] domain = "test.gov" domain_form["dotgov_domain-requested_domain"] = domain @@ -2630,6 +2629,36 @@ class DomainRequestTests(TestWithUser, WebTest): self.feb_purpose_page_tests(purpose_page) + purpose_form = purpose_page.forms[0] + purpose_form["purpose-feb_purpose_choice"] = "redirect" + purpose_form["purpose-purpose"] = "test" + purpose_form["purpose-has_timeframe"] = "True" + purpose_form["purpose-time_frame_details"] = "test" + purpose_form["purpose-is_interagency_initiative"] = "True" + purpose_form["purpose-interagency_initiative_details"] = "test" + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + purpose_result = purpose_form.submit() + + # ---- ADDITIONAL DETAILS PAGE ---- + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + additional_details_page = purpose_result.follow() + self.feb_additional_details_page_tests(additional_details_page) + + additional_details_form = additional_details_page.forms[0] + additional_details_form["portfolio_additional_details-working_with_eop"] = "True" + additional_details_form["portfolio_additional_details-first_name"] = "Testy" + additional_details_form["portfolio_additional_details-last_name"] = "Tester" + additional_details_form["portfolio_additional_details-email"] = "testy@town.com" + additional_details_form["portfolio_additional_details-has_anything_else_text"] = "True" + additional_details_form["portfolio_additional_details-anything_else"] = "test" + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + additional_details_result = additional_details_form.submit() + + # ---- REQUIREMENTS PAGE ---- + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + requirements_page = additional_details_result.follow() + self.feb_requirements_page_tests(requirements_page) + def feb_purpose_page_tests(self, purpose_page): self.assertContains(purpose_page, "What is the purpose of your requested domain?") @@ -2670,6 +2699,40 @@ class DomainRequestTests(TestWithUser, WebTest): # Check that the details form was included self.assertContains(dotgov_page, "feb_naming_requirements_details") + def feb_additional_details_page_tests(self, additional_details_page): + test_text = "Are you working with someone in the Executive Office of the President (EOP) on this request?" + self.assertContains(additional_details_page, test_text) + + # Make sure the EOP form is present + self.assertContains(additional_details_page, "working_with_eop") + + # Make sure the EOP contact form is present + self.assertContains(additional_details_page, "eop-contact-container") + self.assertContains(additional_details_page, "additional_details-first_name") + self.assertContains(additional_details_page, "additional_details-last_name") + self.assertContains(additional_details_page, "additional_details-email") + + # Make sure the additional details form is present + self.assertContains(additional_details_page, "additional_details-has_anything_else_text") + self.assertContains(additional_details_page, "additional_details-anything_else") + + def feb_requirements_page_tests(self, requirements_page): + # Check for the 21st Century IDEA Act links + self.assertContains( + requirements_page, "https://digital.gov/resources/delivering-digital-first-public-experience-act/" + ) + self.assertContains( + requirements_page, + "https://bidenwhitehouse.gov/wp-content/uploads/2023/09/M-23-22-Delivering-a-Digital-First-Public-Experience.pdf", # noqa + ) + + # Check for the policy acknowledgement form + self.assertContains(requirements_page, "is_policy_acknowledged") + self.assertContains( + requirements_page, + "I read and understand the guidance outlined in the DOTGOV Act for operating a .gov domain.", + ) + @less_console_noise_decorator def test_domain_request_formsets(self): """Users are able to add more than one of some fields.""" diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 4e9eaff4d..6e27b9ed4 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -603,7 +603,49 @@ class RequestingEntity(DomainRequestWizard): class PortfolioAdditionalDetails(DomainRequestWizard): template_name = "portfolio_domain_request_additional_details.html" - forms = [forms.PortfolioAnythingElseForm] + forms = [ + feb.WorkingWithEOPYesNoForm, + feb.EOPContactForm, + feb.FEBAnythingElseYesNoForm, + forms.PortfolioAnythingElseForm, + ] + + def get_context_data(self): + context = super().get_context_data() + context["requires_feb_questions"] = self.requires_feb_questions() + return context + + def is_valid(self, forms: list) -> bool: + """ + Validates the forms for portfolio additional details. + + Expected order of forms_list: + 0: WorkingWithEOPYesNoForm + 1: EOPContactForm + 2: FEBAnythingElseYesNoForm + 3: PortfolioAnythingElseForm + """ + eop_forms_valid = True + if not forms[0].is_valid(): + # If the user isn't working with EOP, don't validate the EOP contact form + forms[1].mark_form_for_deletion() + eop_forms_valid = False + if forms[0].cleaned_data.get("working_with_eop"): + eop_forms_valid = forms[1].is_valid() + else: + forms[1].mark_form_for_deletion() + anything_else_forms_valid = True + if not forms[2].is_valid(): + forms[3].mark_form_for_deletion() + anything_else_forms_valid = False + if forms[2].cleaned_data.get("has_anything_else_text"): + forms[3].fields["anything_else"].required = True + forms[3].fields["anything_else"].error_messages[ + "required" + ] = "Please provide additional details you'd like us to know. \ + If you have nothing to add, select 'No'." + anything_else_forms_valid = forms[3].is_valid() + return eop_forms_valid and anything_else_forms_valid # Non-portfolio pages @@ -887,6 +929,29 @@ class Requirements(DomainRequestWizard): template_name = "domain_request_requirements.html" forms = [forms.RequirementsForm] + def get_context_data(self): + context = super().get_context_data() + context["requires_feb_questions"] = self.requires_feb_questions() + return context + + # Override the get_forms method to set the policy acknowledgement label conditionally based on feb status + def get_forms(self, step=None, use_post=False, use_db=False, files=None): + forms_list = super().get_forms(step, use_post, use_db, files) + + # Pass the is_federal context to the form + for form in forms_list: + if isinstance(form, forms.RequirementsForm): + if self.requires_feb_questions(): + form.fields["is_policy_acknowledged"].label = ( + "I read and understand the guidance outlined in the DOTGOV Act for operating a .gov domain." # noqa: E501 + ) + else: + form.fields["is_policy_acknowledged"].label = ( + "I read and agree to the requirements for operating a .gov domain." # noqa: E501 + ) + + return forms_list + class Review(DomainRequestWizard): template_name = "domain_request_review.html" @@ -899,6 +964,7 @@ class Review(DomainRequestWizard): context = super().get_context_data() context["Step"] = self.get_step_enum().__members__ context["domain_request"] = self.domain_request + context["requires_feb_questions"] = self.requires_feb_questions() return context def goto_next_step(self): diff --git a/src/requirements.txt b/src/requirements.txt index 01babf2d4..8518f6655 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -13,7 +13,7 @@ defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, diff-match-patch==20241021; python_version >= '3.7' dj-database-url==2.3.0 dj-email-url==1.0.6 -django==4.2.17; python_version >= '3.8' +django==4.2.20; python_version >= '3.8' django-admin-multiple-choice-list-filter==0.1.1 django-allow-cidr==0.7.1 django-auditlog==3.0.0; python_version >= '3.8'