diff --git a/src/docker-compose.yml b/src/docker-compose.yml index c69c21192..1a9064ac8 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -108,7 +108,7 @@ services: - pa11y owasp: - image: owasp/zap2docker-stable + image: ghcr.io/zaproxy/zaproxy:stable command: zap-baseline.py -t http://app:8080 -c zap.conf -I -r zap_report.html volumes: - .:/zap/wrk/ diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d07551a01..b7a5a0503 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1234,7 +1234,17 @@ class DomainRequestAdmin(ListHeaderAdmin): }, ), (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), - ("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}), + ( + "Contacts", + { + "fields": [ + "authorizing_official", + "other_contacts", + "no_other_contacts_rationale", + "cisa_representative_email", + ] + }, + ), ("Background info", {"fields": ["purpose", "anything_else", "current_websites"]}), ( "Type of organization", @@ -1307,6 +1317,7 @@ class DomainRequestAdmin(ListHeaderAdmin): "no_other_contacts_rationale", "anything_else", "is_policy_acknowledged", + "cisa_representative_email", ] autocomplete_fields = [ "approved_domain", diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 2909a48be..126ab0a2a 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -457,7 +457,7 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, } /** An IIFE for admin in DjangoAdmin to listen to changes on the domain request - * status select amd to show/hide the rejection reason + * status select and to show/hide the rejection reason */ (function (){ let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index b4c41ecf1..e7260ee21 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -193,6 +193,65 @@ function clearValidators(el) { toggleInputValidity(el, true); } +/** Hookup listeners for yes/no togglers for form fields + * Parameters: + * - radioButtonName: The "name=" value for the radio buttons being used as togglers + * - elementIdToShowIfYes: The Id of the element (eg. a div) to show if selected value of the given + * radio button is true (hides this element if false) + * - elementIdToShowIfNo: The Id of the element (eg. a div) to show if selected value of the given + * radio button is false (hides this element if true) + * **/ +function HookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToShowIfNo) { + // Get the radio buttons + let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); + + function handleRadioButtonChange() { + // Check the value of the selected radio button + // Attempt to find the radio button element that is checked + let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); + + // Check if the element exists before accessing its value + let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; + + switch (selectedValue) { + case 'True': + toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 1); + break; + + case 'False': + toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 2); + break; + + default: + toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 0); + } + } + + if (radioButtons.length) { + // Add event listener to each radio button + radioButtons.forEach(function (radioButton) { + radioButton.addEventListener('change', handleRadioButtonChange); + }); + + // initialize + handleRadioButtonChange(); + } +} + +// A generic display none/block toggle function that takes an integer param to indicate how the elements toggle +function toggleTwoDomElements(ele1, ele2, index) { + let element1 = document.getElementById(ele1); + let element2 = document.getElementById(ele2); + if (element1 || element2) { + // Toggle display based on the index + if (element1) {element1.style.display = index === 1 ? 'block' : 'none';} + if (element2) {element2.style.display = index === 2 ? 'block' : 'none';} + } + else { + console.error('Unable to find elements to toggle'); + } +} + // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Event handlers. @@ -712,58 +771,41 @@ function hideDeletedForms() { } })(); -// A generic display none/block toggle function that takes an integer param to indicate how the elements toggle -function toggleTwoDomElements(ele1, ele2, index) { - let element1 = document.getElementById(ele1); - let element2 = document.getElementById(ele2); - if (element1 && element2) { - // Toggle display based on the index - element1.style.display = index === 1 ? 'block' : 'none'; - element2.style.display = index === 2 ? 'block' : 'none'; - } else { - console.error('One or both elements not found.'); - } -} /** * An IIFE that listens to the other contacts radio form on DAs and toggles the contacts/no other contacts forms * */ (function otherContactsFormListener() { - // Get the radio buttons - let radioButtons = document.querySelectorAll('input[name="other_contacts-has_other_contacts"]'); + HookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees') +})(); - function handleRadioButtonChange() { - // Check the value of the selected radio button - // Attempt to find the radio button element that is checked - let radioButtonChecked = document.querySelector('input[name="other_contacts-has_other_contacts"]:checked'); - // Check if the element exists before accessing its value - let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; +/** + * An IIFE that listens to the yes/no radio buttons on the anything else form and toggles form field visibility accordingly + * + */ +(function anythingElseFormListener() { + HookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null) +})(); - switch (selectedValue) { - case 'True': - toggleTwoDomElements('other-employees', 'no-other-employees', 1); - break; - - case 'False': - toggleTwoDomElements('other-employees', 'no-other-employees', 2); - break; - - default: - toggleTwoDomElements('other-employees', 'no-other-employees', 0); +/** + * An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms + * + */ +(function nameserversFormListener() { + let isNameserversForm = document.querySelector(".nameservers-form"); + if (isNameserversForm) { + let forms = document.querySelectorAll(".repeatable-form"); + if (forms.length < 3) { + // Hide the delete buttons on the 2 nameservers + forms.forEach((form) => { + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.setAttribute("disabled", "true"); + }); + }); } } - - if (radioButtons.length) { - // Add event listener to each radio button - radioButtons.forEach(function (radioButton) { - radioButton.addEventListener('change', handleRadioButtonChange); - }); - - // initialize - handleRadioButtonChange(); - } })(); /** @@ -784,3 +826,11 @@ function toggleTwoDomElements(ele1, ele2, index) { } } })(); + +/** + * An IIFE that listens to the yes/no radio buttons on the CISA representatives form and toggles form field visibility accordingly + * + */ +(function cisaRepresentativesFormListener() { + HookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null) +})(); diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss index 0d58b5878..5dc69e149 100644 --- a/src/registrar/assets/sass/_theme/_tables.scss +++ b/src/registrar/assets/sass/_theme/_tables.scss @@ -108,12 +108,51 @@ padding: units(2) units(2) units(2) 0; } - th:first-of-type { - padding-left: 0; - } - thead tr:first-child th:first-child { border-top: none; } } } +@media (min-width: 1040px){ + .dotgov-table__domain-requests { + th:nth-of-type(1) { + width: 200px; + } + + th:nth-of-type(2) { + width: 158px; + } + + th:nth-of-type(3) { + width: 120px; + } + + th:nth-of-type(4) { + width: 95px; + } + + th:nth-of-type(5) { + width: 85px; + } + } +} + +@media (min-width: 1040px){ + .dotgov-table__registered-domains { + th:nth-of-type(1) { + width: 200px; + } + + th:nth-of-type(2) { + width: 158px; + } + + th:nth-of-type(3) { + width: 215px; + } + + th:nth-of-type(4) { + width: 95px; + } + } +} \ No newline at end of file diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 3918fa087..720034150 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -46,7 +46,7 @@ for step, view in [ (Step.PURPOSE, views.Purpose), (Step.YOUR_CONTACT, views.YourContact), (Step.OTHER_CONTACTS, views.OtherContacts), - (Step.ANYTHING_ELSE, views.AnythingElse), + (Step.ADDITIONAL_DETAILS, views.AdditionalDetails), (Step.REQUIREMENTS, views.Requirements), (Step.REVIEW, views.Review), ]: diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 9669ce071..7f991fa0e 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -94,6 +94,12 @@ class UserFixture: "last_name": "Chin", "email": "szu.chin@associates.cisa.dhs.gov", }, + { + "username": "66bb1a5a-a091-4d7f-a6cf-4d772b4711c7", + "first_name": "Christina", + "last_name": "Burnett", + "email": "christina.burnett@cisa.dhs.gov", + }, { "username": "012f844d-8a0f-4225-9d82-cbf87bff1d3e", "first_name": "Riley", @@ -170,6 +176,12 @@ class UserFixture: "last_name": "Chin-Analyst", "email": "szu.chin@ecstech.com", }, + { + "username": "22f88aa5-3b54-4b1f-9c57-201fb02ddba7", + "first_name": "Christina-Analyst", + "last_name": "Burnett-Analyst", + "email": "christina.burnett@gwe.cisa.dhs.gov", + }, { "username": "d9839768-0c17-4fa2-9c8e-36291eef5c11", "first_name": "Alex-Analyst", diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index c3ac3b4c2..8d74f6f35 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -1,15 +1,18 @@ from __future__ import annotations # allows forward references in annotations -from itertools import zip_longest import logging -from typing import Callable from api.views import DOMAIN_API_MESSAGES from phonenumber_field.formfields import PhoneNumberField # type: ignore from django import forms from django.core.validators import RegexValidator, MaxLengthValidator from django.utils.safestring import mark_safe -from django.db.models.fields.related import ForeignObjectRel +from registrar.forms.utility.wizard_form_helper import ( + RegistrarForm, + RegistrarFormSet, + BaseYesNoForm, + BaseDeletableRegistrarForm, +) from registrar.models import Contact, DomainRequest, DraftDomain, Domain from registrar.templatetags.url_helpers import public_site_url from registrar.utility.enums import ValidationReturnType @@ -17,157 +20,6 @@ from registrar.utility.enums import ValidationReturnType logger = logging.getLogger(__name__) -class RegistrarForm(forms.Form): - """ - A common set of methods and configuration. - - The registrar's domain request is several pages of "steps". - Each step is an HTML form containing one or more Django "forms". - - Subclass this class to create new forms. - """ - - def __init__(self, *args, **kwargs): - kwargs.setdefault("label_suffix", "") - # save a reference to a domain request object - self.domain_request = kwargs.pop("domain_request", None) - super(RegistrarForm, self).__init__(*args, **kwargs) - - def to_database(self, obj: DomainRequest | Contact): - """ - Adds this form's cleaned data to `obj` and saves `obj`. - - Does nothing if form is not valid. - """ - if not self.is_valid(): - return - for name, value in self.cleaned_data.items(): - setattr(obj, name, value) - obj.save() - - @classmethod - def from_database(cls, obj: DomainRequest | Contact | None): - """Returns a dict of form field values gotten from `obj`.""" - if obj is None: - return {} - return {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore - - -class RegistrarFormSet(forms.BaseFormSet): - """ - As with RegistrarForm, a common set of methods and configuration. - - Subclass this class to create new formsets. - """ - - def __init__(self, *args, **kwargs): - # save a reference to an domain_request object - self.domain_request = kwargs.pop("domain_request", None) - super(RegistrarFormSet, self).__init__(*args, **kwargs) - # quick workaround to ensure that the HTML `required` - # attribute shows up on required fields for any forms - # in the formset which have data already (stated another - # way: you can leave a form in the formset blank, but - # if you opt to fill it out, you must fill it out _right_) - for index in range(self.initial_form_count()): - self.forms[index].use_required_attribute = True - - def should_delete(self, cleaned): - """Should this entry be deleted from the database?""" - raise NotImplementedError - - def pre_update(self, db_obj, cleaned): - """Code to run before an item in the formset is saved.""" - for key, value in cleaned.items(): - setattr(db_obj, key, value) - - def pre_create(self, db_obj, cleaned): - """Code to run before an item in the formset is created in the database.""" - return cleaned - - def to_database(self, obj: DomainRequest): - """ - Adds this form's cleaned data to `obj` and saves `obj`. - - Does nothing if form is not valid. - - Hint: Subclass should call `self._to_database(...)`. - """ - raise NotImplementedError - - def _to_database( - self, - obj: DomainRequest, - join: str, - should_delete: Callable, - pre_update: Callable, - pre_create: Callable, - ): - """ - Performs the actual work of saving. - - Has hooks such as `should_delete` and `pre_update` by which the - subclass can control behavior. Add more hooks whenever needed. - """ - if not self.is_valid(): - return - obj.save() - - query = getattr(obj, join).order_by("created_at").all() # order matters - - # get the related name for the join defined for the db_obj for this form. - # the related name will be the reference on a related object back to db_obj - related_name = "" - field = obj._meta.get_field(join) - if isinstance(field, ForeignObjectRel) and callable(field.related_query_name): - related_name = field.related_query_name() - elif hasattr(field, "related_query_name") and callable(field.related_query_name): - related_name = field.related_query_name() - - # the use of `zip` pairs the forms in the formset with the - # related objects gotten from the database -- there should always be - # at least as many forms as database entries: extra forms means new - # entries, but fewer forms is _not_ the correct way to delete items - # (likely a client-side error or an attempt at data tampering) - for db_obj, post_data in zip_longest(query, self.forms, fillvalue=None): - cleaned = post_data.cleaned_data if post_data is not None else {} - - # matching database object exists, update it - if db_obj is not None and cleaned: - if should_delete(cleaned): - if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name): - # Remove the specific relationship without deleting the object - getattr(db_obj, related_name).remove(self.domain_request) - else: - # If there are no other relationships, delete the object - db_obj.delete() - else: - if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name): - # create a new db_obj and disconnect existing one - getattr(db_obj, related_name).remove(self.domain_request) - kwargs = pre_create(db_obj, cleaned) - getattr(obj, join).create(**kwargs) - else: - pre_update(db_obj, cleaned) - db_obj.save() - - # no matching database object, create it - # make sure not to create a database object if cleaned has 'delete' attribute - elif db_obj is None and cleaned and not cleaned.get("DELETE", False): - kwargs = pre_create(db_obj, cleaned) - getattr(obj, join).create(**kwargs) - - @classmethod - def on_fetch(cls, query): - """Code to run when fetching formset's objects from the database.""" - return query.values() - - @classmethod - def from_database(cls, obj: DomainRequest, join: str, on_fetch: Callable): - """Returns a dict of form field values gotten from `obj`.""" - return on_fetch(getattr(obj, join).order_by("created_at")) # order matters - - class OrganizationTypeForm(RegistrarForm): generic_org_type = forms.ChoiceField( # use the long names in the domain request form @@ -588,28 +440,24 @@ class YourContactForm(RegistrarForm): ) -class OtherContactsYesNoForm(RegistrarForm): - def __init__(self, *args, **kwargs): - """Extend the initialization of the form from RegistrarForm __init__""" - super().__init__(*args, **kwargs) - # set the initial value based on attributes of domain request - if self.domain_request and self.domain_request.has_other_contacts(): - initial_value = True - elif self.domain_request and self.domain_request.has_rationale(): - initial_value = False +class OtherContactsYesNoForm(BaseYesNoForm): + """The yes/no field for the OtherContacts form.""" + + form_choices = ((True, "Yes, I can name other employees."), (False, "No. (We’ll ask you to explain why.)")) + field_name = "has_other_contacts" + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form based on the domain_request's attributes. + """ + if self.domain_request.has_other_contacts(): + return True + elif self.domain_request.has_rationale(): + return False else: # No pre-selection for new domain requests - initial_value = None - - self.fields["has_other_contacts"] = forms.TypedChoiceField( - coerce=lambda x: x.lower() == "true" if x is not None else None, # coerce strings to bool, excepting None - choices=((True, "Yes, I can name other employees."), (False, "No. (We’ll ask you to explain why.)")), - initial=initial_value, - widget=forms.RadioSelect, - error_messages={ - "required": "This question is required.", - }, - ) + return None class OtherContactsForm(RegistrarForm): @@ -779,7 +627,7 @@ OtherContactsFormSet = forms.formset_factory( ) -class NoOtherContactsForm(RegistrarForm): +class NoOtherContactsForm(BaseDeletableRegistrarForm): no_other_contacts_rationale = forms.CharField( required=True, # label has to end in a space to get the label_suffix to show @@ -794,59 +642,35 @@ class NoOtherContactsForm(RegistrarForm): error_messages={"required": ("Rationale for no other employees is required.")}, ) - def __init__(self, *args, **kwargs): - self.form_data_marked_for_deletion = False - super().__init__(*args, **kwargs) - def mark_form_for_deletion(self): - """Marks no_other_contacts form for deletion. - This changes behavior of validity checks and to_database - methods.""" - self.form_data_marked_for_deletion = True - - def clean(self): - """ - This method overrides the default behavior for forms. - This cleans the form after field validation has already taken place. - In this override, remove errors associated with the form if form data - is marked for deletion. - """ - - if self.form_data_marked_for_deletion: - # clear any errors raised by the form fields - # (before this clean() method is run, each field - # performs its own clean, which could result in - # errors that we wish to ignore at this point) - # - # NOTE: we cannot just clear() the errors list. - # That causes problems. - for field in self.fields: - if field in self.errors: - del self.errors[field] - - return self.cleaned_data - - def to_database(self, obj): - """ - This method overrides the behavior of RegistrarForm. - If form data is marked for deletion, set relevant fields - to None before saving. - Do nothing if form is not valid. - """ - if not self.is_valid(): - return - if self.form_data_marked_for_deletion: - for field_name, _ in self.fields.items(): - setattr(obj, field_name, None) - else: - for name, value in self.cleaned_data.items(): - setattr(obj, name, value) - obj.save() +class CisaRepresentativeForm(BaseDeletableRegistrarForm): + cisa_representative_email = forms.EmailField( + required=True, + max_length=None, + label="Your representative’s email", + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + error_messages={ + "invalid": ("Enter your email address in the required format, like name@example.com."), + "required": ("Enter the email address of your CISA regional representative."), + }, + ) -class AnythingElseForm(RegistrarForm): +class CisaRepresentativeYesNoForm(BaseYesNoForm): + """Yes/no toggle for the CISA regions question on additional details""" + + form_is_checked = property(lambda self: self.domain_request.has_cisa_representative) # type: ignore + field_name = "has_cisa_representative" + + +class AdditionalDetailsForm(BaseDeletableRegistrarForm): anything_else = forms.CharField( - required=False, + required=True, label="Anything else?", widget=forms.Textarea(), validators=[ @@ -855,9 +679,22 @@ class AnythingElseForm(RegistrarForm): message="Response must be less than 2000 characters.", ) ], + error_messages={ + "required": ( + "Provide additional details you’d like us to know. " "If you have nothing to add, select “No.”" + ) + }, ) +class AdditionalDetailsYesNoForm(BaseYesNoForm): + """Yes/no toggle for the anything else question on additional details""" + + # Note that these can be set as functions/init if you need more fine-grained control. + form_is_checked = property(lambda self: self.domain_request.has_anything_else_text) # type: ignore + field_name = "has_anything_else_text" + + class RequirementsForm(RegistrarForm): is_policy_acknowledged = forms.BooleanField( label="I read and agree to the requirements for operating a .gov domain.", diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py new file mode 100644 index 000000000..2ae50f908 --- /dev/null +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -0,0 +1,280 @@ +"""Containers helpers and base classes for the domain_request_wizard.py file""" + +from itertools import zip_longest +from typing import Callable +from django.db.models.fields.related import ForeignObjectRel +from django import forms + +from registrar.models import DomainRequest, Contact + + +class RegistrarForm(forms.Form): + """ + A common set of methods and configuration. + + The registrar's domain request is several pages of "steps". + Each step is an HTML form containing one or more Django "forms". + + Subclass this class to create new forms. + """ + + def __init__(self, *args, **kwargs): + kwargs.setdefault("label_suffix", "") + # save a reference to a domain request object + self.domain_request = kwargs.pop("domain_request", None) + super(RegistrarForm, self).__init__(*args, **kwargs) + + def to_database(self, obj: DomainRequest | Contact): + """ + Adds this form's cleaned data to `obj` and saves `obj`. + + Does nothing if form is not valid. + """ + if not self.is_valid(): + return + for name, value in self.cleaned_data.items(): + setattr(obj, name, value) + obj.save() + + @classmethod + def from_database(cls, obj: DomainRequest | Contact | None): + """Returns a dict of form field values gotten from `obj`.""" + if obj is None: + return {} + return {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore + + +class RegistrarFormSet(forms.BaseFormSet): + """ + As with RegistrarForm, a common set of methods and configuration. + + Subclass this class to create new formsets. + """ + + def __init__(self, *args, **kwargs): + # save a reference to an domain_request object + self.domain_request = kwargs.pop("domain_request", None) + super(RegistrarFormSet, self).__init__(*args, **kwargs) + # quick workaround to ensure that the HTML `required` + # attribute shows up on required fields for any forms + # in the formset which have data already (stated another + # way: you can leave a form in the formset blank, but + # if you opt to fill it out, you must fill it out _right_) + for index in range(self.initial_form_count()): + self.forms[index].use_required_attribute = True + + def should_delete(self, cleaned): + """Should this entry be deleted from the database?""" + raise NotImplementedError + + def pre_update(self, db_obj, cleaned): + """Code to run before an item in the formset is saved.""" + for key, value in cleaned.items(): + setattr(db_obj, key, value) + + def pre_create(self, db_obj, cleaned): + """Code to run before an item in the formset is created in the database.""" + return cleaned + + def to_database(self, obj: DomainRequest): + """ + Adds this form's cleaned data to `obj` and saves `obj`. + + Does nothing if form is not valid. + + Hint: Subclass should call `self._to_database(...)`. + """ + raise NotImplementedError + + def _to_database( + self, + obj: DomainRequest, + join: str, + should_delete: Callable, + pre_update: Callable, + pre_create: Callable, + ): + """ + Performs the actual work of saving. + + Has hooks such as `should_delete` and `pre_update` by which the + subclass can control behavior. Add more hooks whenever needed. + """ + if not self.is_valid(): + return + obj.save() + + query = getattr(obj, join).order_by("created_at").all() # order matters + + # get the related name for the join defined for the db_obj for this form. + # the related name will be the reference on a related object back to db_obj + related_name = "" + field = obj._meta.get_field(join) + if isinstance(field, ForeignObjectRel) and callable(field.related_query_name): + related_name = field.related_query_name() + elif hasattr(field, "related_query_name") and callable(field.related_query_name): + related_name = field.related_query_name() + + # the use of `zip` pairs the forms in the formset with the + # related objects gotten from the database -- there should always be + # at least as many forms as database entries: extra forms means new + # entries, but fewer forms is _not_ the correct way to delete items + # (likely a client-side error or an attempt at data tampering) + for db_obj, post_data in zip_longest(query, self.forms, fillvalue=None): + cleaned = post_data.cleaned_data if post_data is not None else {} + + # matching database object exists, update it + if db_obj is not None and cleaned: + if should_delete(cleaned): + if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name): + # Remove the specific relationship without deleting the object + getattr(db_obj, related_name).remove(self.domain_request) + else: + # If there are no other relationships, delete the object + db_obj.delete() + else: + if hasattr(db_obj, "has_more_than_one_join") and db_obj.has_more_than_one_join(related_name): + # create a new db_obj and disconnect existing one + getattr(db_obj, related_name).remove(self.domain_request) + kwargs = pre_create(db_obj, cleaned) + getattr(obj, join).create(**kwargs) + else: + pre_update(db_obj, cleaned) + db_obj.save() + + # no matching database object, create it + # make sure not to create a database object if cleaned has 'delete' attribute + elif db_obj is None and cleaned and not cleaned.get("DELETE", False): + kwargs = pre_create(db_obj, cleaned) + getattr(obj, join).create(**kwargs) + + @classmethod + def on_fetch(cls, query): + """Code to run when fetching formset's objects from the database.""" + return query.values() + + @classmethod + def from_database(cls, obj: DomainRequest, join: str, on_fetch: Callable): + """Returns a dict of form field values gotten from `obj`.""" + return on_fetch(getattr(obj, join).order_by("created_at")) # order matters + + +class BaseDeletableRegistrarForm(RegistrarForm): + """Adds special validation and delete functionality. + Used by forms that are tied to a Yes/No form.""" + + def __init__(self, *args, **kwargs): + self.form_data_marked_for_deletion = False + super().__init__(*args, **kwargs) + + def mark_form_for_deletion(self): + """Marks this form for deletion. + This changes behavior of validity checks and to_database + methods.""" + self.form_data_marked_for_deletion = True + + def clean(self): + """ + This method overrides the default behavior for forms. + This cleans the form after field validation has already taken place. + In this override, remove errors associated with the form if form data + is marked for deletion. + """ + + if self.form_data_marked_for_deletion: + # clear any errors raised by the form fields + # (before this clean() method is run, each field + # performs its own clean, which could result in + # errors that we wish to ignore at this point) + # + # NOTE: we cannot just clear() the errors list. + # That causes problems. + for field in self.fields: + if field in self.errors: + del self.errors[field] + + return self.cleaned_data + + def to_database(self, obj): + """ + This method overrides the behavior of RegistrarForm. + If form data is marked for deletion, set relevant fields + to None before saving. + Do nothing if form is not valid. + """ + if not self.is_valid(): + return + if self.form_data_marked_for_deletion: + for field_name, _ in self.fields.items(): + setattr(obj, field_name, None) + else: + for name, value in self.cleaned_data.items(): + setattr(obj, name, value) + obj.save() + + +class BaseYesNoForm(RegistrarForm): + """ + Base class used for forms with a yes/no form with a hidden input on toggle. + Use this class when you need something similar to the AdditionalDetailsYesNoForm. + + Attributes: + form_is_checked (bool): Determines the default state (checked or not) of the Yes/No toggle. + field_name (str): Specifies the form field name that the Yes/No toggle controls. + required_error_message (str): Custom error message displayed when the field is required but not provided. + form_choices (tuple): Defines the choice options for the form field, defaulting to Yes/No choices. + + Usage: + Subclass this form to implement specific Yes/No fields in various parts of the application, customizing + `form_is_checked` and `field_name` as necessary for the context. + """ + + form_is_checked: bool + + # What field does the yes/no button hook to? + # For instance, this could be "has_other_contacts" + field_name: str + + required_error_message = "This question is required." + + # Default form choice mapping. Default is suitable for most cases. + form_choices = ((True, "Yes"), (False, "No")) + + def __init__(self, *args, **kwargs): + """Extend the initialization of the form from RegistrarForm __init__""" + super().__init__(*args, **kwargs) + + self.fields[self.field_name] = self.get_typed_choice_field() + + def get_typed_choice_field(self): + """ + Creates a TypedChoiceField for the form with specified initial value and choices. + Returns: + TypedChoiceField: A Django form field specifically configured for selecting between + predefined choices with type coercion and custom error messages. + """ + choice_field = forms.TypedChoiceField( + coerce=lambda x: x.lower() == "true" if x is not None else None, + choices=self.form_choices, + initial=self.get_initial_value(), + widget=forms.RadioSelect, + error_messages={ + "required": self.required_error_message, + }, + ) + + return choice_field + + def get_initial_value(self): + """ + Determines the initial value for TypedChoiceField. + More directly, this controls the "initial" field on forms.TypedChoiceField. + + Returns: + bool | None: The initial value for the form field. If the domain request is set, + this will always return the value of self.form_is_checked. + Otherwise, None will be returned as a new domain request can't start out checked. + """ + # No pre-selection for new domain requests + initial_value = self.form_is_checked if self.domain_request else None + return initial_value diff --git a/src/registrar/migrations/0088_domaininformation_cisa_representative_email_and_more.py b/src/registrar/migrations/0088_domaininformation_cisa_representative_email_and_more.py new file mode 100644 index 000000000..95450fb3d --- /dev/null +++ b/src/registrar/migrations/0088_domaininformation_cisa_representative_email_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.10 on 2024-04-25 16:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0087_alter_domain_deleted_alter_domain_expiration_date_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="domaininformation", + name="cisa_representative_email", + field=models.EmailField(blank=True, max_length=320, null=True, verbose_name="CISA regional representative"), + ), + migrations.AddField( + model_name="domainrequest", + name="cisa_representative_email", + field=models.EmailField(blank=True, max_length=320, null=True, verbose_name="CISA regional representative"), + ), + migrations.AddField( + model_name="domainrequest", + name="has_anything_else_text", + field=models.BooleanField( + blank=True, help_text="Determines if the user has a anything_else or not", null=True + ), + ), + migrations.AddField( + model_name="domainrequest", + name="has_cisa_representative", + field=models.BooleanField( + blank=True, help_text="Determines if the user has a representative email or not", null=True + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="anything_else", + field=models.TextField(blank=True, null=True, verbose_name="Additional details"), + ), + migrations.AlterField( + model_name="domainrequest", + name="anything_else", + field=models.TextField(blank=True, null=True, verbose_name="Additional details"), + ), + ] diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 238b658f8..c724423ce 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -212,6 +212,14 @@ class DomainInformation(TimeStampedModel): anything_else = models.TextField( null=True, blank=True, + verbose_name="Additional details", + ) + + cisa_representative_email = models.EmailField( + null=True, + blank=True, + verbose_name="CISA regional representative", + max_length=320, ) is_policy_acknowledged = models.BooleanField( diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 9ed35f489..17bd9a100 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -642,6 +642,32 @@ class DomainRequest(TimeStampedModel): anything_else = models.TextField( null=True, blank=True, + verbose_name="Additional details", + ) + + # This is a drop-in replacement for a has_anything_else_text() function. + # In order to track if the user has clicked the yes/no field (while keeping a none default), we need + # a tertiary state. We should not display this in /admin. + has_anything_else_text = models.BooleanField( + null=True, + blank=True, + help_text="Determines if the user has a anything_else or not", + ) + + cisa_representative_email = models.EmailField( + null=True, + blank=True, + verbose_name="CISA regional representative", + max_length=320, + ) + + # This is a drop-in replacement for an has_cisa_representative() function. + # In order to track if the user has clicked the yes/no field (while keeping a none default), we need + # a tertiary state. We should not display this in /admin. + has_cisa_representative = models.BooleanField( + null=True, + blank=True, + help_text="Determines if the user has a representative email or not", ) is_policy_acknowledged = models.BooleanField( @@ -696,8 +722,33 @@ class DomainRequest(TimeStampedModel): def save(self, *args, **kwargs): """Save override for custom properties""" self.sync_organization_type() + self.sync_yes_no_form_fields() + super().save(*args, **kwargs) + def sync_yes_no_form_fields(self): + """Some yes/no forms use a db field to track whether it was checked or not. + We handle that here for def save(). + """ + + # This ensures that if we have prefilled data, the form is prepopulated + if self.cisa_representative_email is not None: + self.has_cisa_representative = self.cisa_representative_email != "" + + # This check is required to ensure that the form doesn't start out checked + if self.has_cisa_representative is not None: + self.has_cisa_representative = ( + self.cisa_representative_email != "" and self.cisa_representative_email is not None + ) + + # This ensures that if we have prefilled data, the form is prepopulated + if self.anything_else is not None: + self.has_anything_else_text = self.anything_else != "" + + # This check is required to ensure that the form doesn't start out checked. + if self.has_anything_else_text is not None: + self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None + def __str__(self): try: if self.requested_domain and self.requested_domain.name: @@ -1036,6 +1087,16 @@ class DomainRequest(TimeStampedModel): """Does this domain request have other contacts listed?""" return self.other_contacts.exists() + def has_additional_details(self) -> bool: + """Combines the has_anything_else_text and has_cisa_representative fields, + then returns if this domain request has either of them.""" + # Split out for linter + has_details = False + if self.has_anything_else_text or self.has_cisa_representative: + has_details = True + + return has_details + def is_federal(self) -> Union[bool, None]: """Is this domain request for a federal agency? diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html new file mode 100644 index 000000000..e13d3c7ee --- /dev/null +++ b/src/registrar/templates/domain_request_additional_details.html @@ -0,0 +1,55 @@ +{% extends 'domain_request_form.html' %} +{% load static field_helpers %} + +{% block form_instructions %} + These questions are required (*). +{% endblock %} + +{% block form_required_fields_help_text %} +{# commented out so it does not appear at this point on this page #} +{% endblock %} + + +{% block form_fields %} +
+ +

Are you working with a CISA regional representative on your domain request?

+

.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

+
+ + + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.0.has_cisa_representative %} + {% endwith %} + {# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #} + +
+ +
+ {% input_with_errors forms.1.cisa_representative_email %} + {# forms.1 is a form for inputting the e-mail of a cisa representative #} + +
+ + +
+ +

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

+
+ + + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.2.has_anything_else_text %} + {% endwith %} + {# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #} + +
+ +
+ {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% input_with_errors forms.3.anything_else %} + {% endwith %} + {# forms.3 is a form for inputting the e-mail of a cisa representative #} + +
+{% endblock %} diff --git a/src/registrar/templates/domain_request_anything_else.html b/src/registrar/templates/domain_request_anything_else.html deleted file mode 100644 index dbeb10cac..000000000 --- a/src/registrar/templates/domain_request_anything_else.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'domain_request_form.html' %} -{% load field_helpers %} - -{% block form_instructions %} -

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

- -

This question is optional.

-{% endblock %} - -{% block form_required_fields_help_text %} -{# commented out so it does not appear on this page #} -{% endblock %} - - -{% block form_fields %} - {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} - {% input_with_errors forms.0.anything_else %} - {% endwith %} -{% endblock %} diff --git a/src/registrar/templates/domain_request_review.html b/src/registrar/templates/domain_request_review.html index 227fc0cea..5f359e95f 100644 --- a/src/registrar/templates/domain_request_review.html +++ b/src/registrar/templates/domain_request_review.html @@ -155,11 +155,20 @@ {% endif %} - {% if step == Step.ANYTHING_ELSE %} + {% if step == Step.ADDITIONAL_DETAILS %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.anything_else|default:"No" %} - {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} + {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete" %} + {% include "includes/summary_item.html" with title=title sub_header_text='CISA regional representative' value=domain_request.cisa_representative_email heading_level=heading_level editable=True edit_link=domain_request_url custom_text_for_value_none='No' %} {% endwith %} + +

Anything else

+ {% endif %} diff --git a/src/registrar/templates/domain_request_status.html b/src/registrar/templates/domain_request_status.html index d3c1eab6d..0ea16e3a3 100644 --- a/src/registrar/templates/domain_request_status.html +++ b/src/registrar/templates/domain_request_status.html @@ -115,8 +115,19 @@ {% else %} {% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} {% endif %} - - {% include "includes/summary_item.html" with title='Anything else?' value=DomainRequest.anything_else|default:"No" heading_level=heading_level %} + + {# We always show this field even if None #} + {% if DomainRequest %} + {% include "includes/summary_item.html" with title='Additional details' sub_header_text='CISA regional representative' value=DomainRequest.cisa_representative_email custom_text_for_value_none='No' heading_level=heading_level %} +

Anything else

+ + {% endif %} {% endwith %} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index ea9276b9f..5c1bc893a 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -26,7 +26,7 @@

Domains

{% if domains %} - +
@@ -104,7 +104,7 @@

Domain requests

{% if domain_requests %} -
Your registered domains
+
diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index 7c0d801ad..a2f328e1f 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -21,6 +21,9 @@ {% else %} {% endif %} + {% if sub_header_text %} +

{{ sub_header_text }}

+ {% endif %} {% if address %} {% include "includes/organization_address.html" with organization=value %} {% elif contact %} @@ -39,6 +42,10 @@ {% endfor %} + {% elif custom_text_for_value_none %} +

+ {{ custom_text_for_value_none }} +

{% else %}

None @@ -92,6 +99,8 @@

{% if value %} {{ value }} + {% elif custom_text_for_value_none %} + {{ custom_text_for_value_none }} {% else %} None {% endif %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index cff4455fb..eeda99efb 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1994,6 +1994,9 @@ class TestDomainRequestAdmin(MockEppLib): "purpose", "no_other_contacts_rationale", "anything_else", + "has_anything_else_text", + "cisa_representative_email", + "has_cisa_representative", "is_policy_acknowledged", "submission_date", "notes", @@ -2025,6 +2028,7 @@ class TestDomainRequestAdmin(MockEppLib): "no_other_contacts_rationale", "anything_else", "is_policy_acknowledged", + "cisa_representative_email", ] self.assertEqual(readonly_fields, expected_fields) diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index 4b8904f0c..c72c70e98 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -15,7 +15,7 @@ from registrar.forms.domain_request_wizard import ( RequirementsForm, TribalGovernmentForm, PurposeForm, - AnythingElseForm, + AdditionalDetailsForm, AboutYourOrganizationForm, ) from registrar.forms.domain import ContactForm @@ -274,7 +274,7 @@ class TestFormValidation(MockEppLib): def test_anything_else_form_about_your_organization_character_count_invalid(self): """Response must be less than 2000 characters.""" - form = AnythingElseForm( + form = AdditionalDetailsForm( data={ "anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami" "shankle, drumstick doner chicken landjaeger turkey andouille." diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index a4cb210bc..19be5ce74 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -356,33 +356,39 @@ class DomainRequestTests(TestWithUser, WebTest): # the post request should return a redirect to the next form in # the domain request page self.assertEqual(other_contacts_result.status_code, 302) - self.assertEqual(other_contacts_result["Location"], "/request/anything_else/") + self.assertEqual(other_contacts_result["Location"], "/request/additional_details/") num_pages_tested += 1 - # ---- ANYTHING ELSE PAGE ---- + # ---- ADDITIONAL DETAILS PAGE ---- # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - anything_else_page = other_contacts_result.follow() - anything_else_form = anything_else_page.forms[0] + additional_details_page = other_contacts_result.follow() + additional_details_form = additional_details_page.forms[0] - anything_else_form["anything_else-anything_else"] = "Nothing else." + # load inputs with test data + + additional_details_form["additional_details-has_cisa_representative"] = "True" + additional_details_form["additional_details-has_anything_else_text"] = "True" + additional_details_form["additional_details-cisa_representative_email"] = "FakeEmail@gmail.com" + additional_details_form["additional_details-anything_else"] = "Nothing else." # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - anything_else_result = anything_else_form.submit() + additional_details_result = additional_details_form.submit() # validate that data from this step are being saved domain_request = DomainRequest.objects.get() # there's only one + self.assertEqual(domain_request.cisa_representative_email, "FakeEmail@gmail.com") self.assertEqual(domain_request.anything_else, "Nothing else.") # the post request should return a redirect to the next form in # the domain request page - self.assertEqual(anything_else_result.status_code, 302) - self.assertEqual(anything_else_result["Location"], "/request/requirements/") + self.assertEqual(additional_details_result.status_code, 302) + self.assertEqual(additional_details_result["Location"], "/request/requirements/") num_pages_tested += 1 # ---- REQUIREMENTS PAGE ---- # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - requirements_page = anything_else_result.follow() + requirements_page = additional_details_result.follow() requirements_form = requirements_page.forms[0] requirements_form["requirements-is_policy_acknowledged"] = True @@ -434,6 +440,7 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(review_page, "Another Tester") self.assertContains(review_page, "testy2@town.com") self.assertContains(review_page, "(201) 555-5557") + self.assertContains(review_page, "FakeEmail@gmail.com") self.assertContains(review_page, "Nothing else.") # We can't test the modal itself as it relies on JS for init and triggering, @@ -717,13 +724,25 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertContains(contact_page, self.TITLES[Step.ABOUT_YOUR_ORGANIZATION]) - def test_yes_no_form_inits_blank_for_new_domain_request(self): + def test_yes_no_contact_form_inits_blank_for_new_domain_request(self): """On the Other Contacts page, the yes/no form gets initialized with nothing selected for new domain requests""" other_contacts_page = self.app.get(reverse("domain-request:other_contacts")) other_contacts_form = other_contacts_page.forms[0] self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, None) + def test_yes_no_additional_form_inits_blank_for_new_domain_request(self): + """On the Additional Details page, the yes/no form gets initialized with nothing selected for + new domain requests""" + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + additional_form = additional_details_page.forms[0] + + # Check the cisa representative yes/no field + self.assertEquals(additional_form["additional_details-has_cisa_representative"].value, None) + + # Check the anything else yes/no field + self.assertEquals(additional_form["additional_details-has_anything_else_text"].value, None) + def test_yes_no_form_inits_yes_for_domain_request_with_other_contacts(self): """On the Other Contacts page, the yes/no form gets initialized with YES selected if the domain request has other contacts""" @@ -744,6 +763,38 @@ class DomainRequestTests(TestWithUser, WebTest): other_contacts_form = other_contacts_page.forms[0] self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, "True") + def test_yes_no_form_inits_yes_for_cisa_representative_and_anything_else(self): + """On the Additional Details page, the yes/no form gets initialized with YES selected + for both yes/no radios if the domain request has a value for cisa_representative and + anything_else""" + + domain_request = completed_domain_request(user=self.user, has_anything_else=True) + domain_request.cisa_representative_email = "test@igorville.gov" + domain_request.anything_else = "1234" + domain_request.save() + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Check the cisa representative yes/no field + yes_no_cisa = additional_details_form["additional_details-has_cisa_representative"].value + self.assertEquals(yes_no_cisa, "True") + + # Check the anything else yes/no field + yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value + self.assertEquals(yes_no_anything_else, "True") + def test_yes_no_form_inits_no_for_domain_request_with_no_other_contacts_rationale(self): """On the Other Contacts page, the yes/no form gets initialized with NO selected if the domain request has no other contacts""" @@ -766,6 +817,230 @@ class DomainRequestTests(TestWithUser, WebTest): other_contacts_form = other_contacts_page.forms[0] self.assertEquals(other_contacts_form["other_contacts-has_other_contacts"].value, "False") + def test_yes_no_form_for_domain_request_with_no_cisa_representative_and_anything_else(self): + """On the Additional details page, the form preselects "no" when has_cisa_representative + and anything_else is no""" + + domain_request = completed_domain_request(user=self.user, has_anything_else=False) + + # Unlike the other contacts form, the no button is tracked with these boolean fields. + # This means that we should expect this to correlate with the no button. + domain_request.has_anything_else_text = False + domain_request.has_cisa_representative = False + domain_request.save() + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Check the cisa representative yes/no field + yes_no_cisa = additional_details_form["additional_details-has_cisa_representative"].value + self.assertEquals(yes_no_cisa, "False") + + # Check the anything else yes/no field + yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value + self.assertEquals(yes_no_anything_else, "False") + + def test_submitting_additional_details_deletes_cisa_representative_and_anything_else(self): + """When a user submits the Additional Details form with no selected for all fields, + the domain request's data gets wiped when submitted""" + domain_request = completed_domain_request(name="nocisareps.gov", user=self.user) + domain_request.cisa_representative_email = "fake@faketown.gov" + domain_request.save() + + # Make sure we have the data we need for the test + self.assertEqual(domain_request.anything_else, "There is more") + self.assertEqual(domain_request.cisa_representative_email, "fake@faketown.gov") + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Check the cisa representative yes/no field + yes_no_cisa = additional_details_form["additional_details-has_cisa_representative"].value + self.assertEquals(yes_no_cisa, "True") + + # Check the anything else yes/no field + yes_no_anything_else = additional_details_form["additional_details-has_anything_else_text"].value + self.assertEquals(yes_no_anything_else, "True") + + # Set fields to false + additional_details_form["additional_details-has_cisa_representative"] = "False" + additional_details_form["additional_details-has_anything_else_text"] = "False" + + # Submit the form + additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + # Verify that the anything_else and cisa_representative have been deleted from the DB + domain_request = DomainRequest.objects.get(requested_domain__name="nocisareps.gov") + + # Check that our data has been cleared + self.assertEqual(domain_request.anything_else, None) + self.assertEqual(domain_request.cisa_representative_email, None) + + # Double check the yes/no fields + self.assertEqual(domain_request.has_anything_else_text, False) + self.assertEqual(domain_request.has_cisa_representative, False) + + def test_submitting_additional_details_populates_cisa_representative_and_anything_else(self): + """When a user submits the Additional Details form, + the domain request's data gets submitted""" + domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False) + + # Make sure we have the data we need for the test + self.assertEqual(domain_request.anything_else, None) + self.assertEqual(domain_request.cisa_representative_email, None) + + # These fields should not be selected at all, since we haven't initialized the form yet + self.assertEqual(domain_request.has_anything_else_text, None) + self.assertEqual(domain_request.has_cisa_representative, None) + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Set fields to true, and set data on those fields + additional_details_form["additional_details-has_cisa_representative"] = "True" + additional_details_form["additional_details-has_anything_else_text"] = "True" + additional_details_form["additional_details-cisa_representative_email"] = "test@faketest.gov" + additional_details_form["additional_details-anything_else"] = "redandblue" + + # Submit the form + additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + # Verify that the anything_else and cisa_representative exist in the db + domain_request = DomainRequest.objects.get(requested_domain__name="cisareps.gov") + + self.assertEqual(domain_request.anything_else, "redandblue") + self.assertEqual(domain_request.cisa_representative_email, "test@faketest.gov") + + self.assertEqual(domain_request.has_cisa_representative, True) + self.assertEqual(domain_request.has_anything_else_text, True) + + def test_if_cisa_representative_yes_no_form_is_yes_then_field_is_required(self): + """Applicants with a cisa representative must provide a value""" + domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False) + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Set fields to true, and set data on those fields + additional_details_form["additional_details-has_cisa_representative"] = "True" + additional_details_form["additional_details-has_anything_else_text"] = "False" + + # Submit the form + response = additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + self.assertContains(response, "Enter the email address of your CISA regional representative.") + + def test_if_anything_else_yes_no_form_is_yes_then_field_is_required(self): + """Applicants with a anything else must provide a value""" + domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False) + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Set fields to true, and set data on those fields + additional_details_form["additional_details-has_cisa_representative"] = "False" + additional_details_form["additional_details-has_anything_else_text"] = "True" + + # Submit the form + response = additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + expected_message = "Provide additional details you’d like us to know. If you have nothing to add, select “No.”" + self.assertContains(response, expected_message) + + def test_additional_details_form_fields_required(self): + """When a user submits the Additional Details form without checking the + has_cisa_representative and has_anything_else_text fields, the form should deny this action""" + domain_request = completed_domain_request(name="cisareps.gov", user=self.user, has_anything_else=False) + + self.assertEqual(domain_request.has_anything_else_text, None) + self.assertEqual(domain_request.has_cisa_representative, None) + + # prime the form by visiting /edit + self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})) + # django-webtest does not handle cookie-based sessions well because it keeps + # resetting the session key on each new request, thus destroying the concept + # of a "session". We are going to do it manually, saving the session ID here + # and then setting the cookie on each request. + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_page = self.app.get(reverse("domain-request:additional_details")) + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + additional_details_form = additional_details_page.forms[0] + + # Submit the form + response = additional_details_form.submit() + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + + # We expect to see this twice for both fields. This results in a count of 4 + # due to screen reader information / html. + self.assertContains(response, "This question is required.", count=4) + def test_submitting_other_contacts_deletes_no_other_contacts_rationale(self): """When a user submits the Other Contacts form with other contacts selected, the domain request's no other contacts rationale gets deleted""" diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 244b47602..f93976138 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -45,7 +45,7 @@ class Step(StrEnum): PURPOSE = "purpose" YOUR_CONTACT = "your_contact" OTHER_CONTACTS = "other_contacts" - ANYTHING_ELSE = "anything_else" + ADDITIONAL_DETAILS = "additional_details" REQUIREMENTS = "requirements" REVIEW = "review" @@ -91,7 +91,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): Step.PURPOSE: _("Purpose of your domain"), Step.YOUR_CONTACT: _("Your contact information"), Step.OTHER_CONTACTS: _("Other employees from your organization"), - Step.ANYTHING_ELSE: _("Anything else?"), + Step.ADDITIONAL_DETAILS: _("Additional details"), Step.REQUIREMENTS: _("Requirements for operating a .gov domain"), Step.REVIEW: _("Review and submit your domain request"), } @@ -365,8 +365,9 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): self.domain_request.other_contacts.exists() or self.domain_request.no_other_contacts_rationale is not None ), - "anything_else": ( - self.domain_request.anything_else is not None or self.domain_request.is_policy_acknowledged is not None + "additional_details": ( + (self.domain_request.anything_else is not None and self.domain_request.cisa_representative_email) + or self.domain_request.is_policy_acknowledged is not None ), "requirements": self.domain_request.is_policy_acknowledged is not None, "review": self.domain_request.is_policy_acknowledged is not None, @@ -581,9 +582,64 @@ class OtherContacts(DomainRequestWizard): return all_forms_valid -class AnythingElse(DomainRequestWizard): - template_name = "domain_request_anything_else.html" - forms = [forms.AnythingElseForm] +class AdditionalDetails(DomainRequestWizard): + + template_name = "domain_request_additional_details.html" + + forms = [ + forms.CisaRepresentativeYesNoForm, + forms.CisaRepresentativeForm, + forms.AdditionalDetailsYesNoForm, + forms.AdditionalDetailsForm, + ] + + def is_valid(self, forms: list) -> bool: + + # Validate Cisa Representative + """Overrides default behavior defined in DomainRequestWizard. + Depending on value in yes_no forms, marks corresponding data + for deletion. Then validates all forms. + """ + cisa_representative_email_yes_no_form = forms[0] + cisa_representative_email_form = forms[1] + anything_else_yes_no_form = forms[2] + anything_else_form = forms[3] + + # ------- Validate cisa representative ------- + cisa_rep_portion_is_valid = True + # test first for yes_no_form validity + if cisa_representative_email_yes_no_form.is_valid(): + # test for existing data + if not cisa_representative_email_yes_no_form.cleaned_data.get("has_cisa_representative"): + # mark the cisa_representative_email_form for deletion + cisa_representative_email_form.mark_form_for_deletion() + else: + cisa_rep_portion_is_valid = cisa_representative_email_form.is_valid() + else: + # if yes no form is invalid, no choice has been made + # mark the cisa_representative_email_form for deletion + cisa_representative_email_form.mark_form_for_deletion() + cisa_rep_portion_is_valid = False + + # ------- Validate anything else ------- + anything_else_portion_is_valid = True + # test first for yes_no_form validity + if anything_else_yes_no_form.is_valid(): + # test for existing data + if not anything_else_yes_no_form.cleaned_data.get("has_anything_else_text"): + # mark the anything_else_form for deletion + anything_else_form.mark_form_for_deletion() + else: + anything_else_portion_is_valid = anything_else_form.is_valid() + else: + # if yes no form is invalid, no choice has been made + # mark the anything_else_form for deletion + anything_else_form.mark_form_for_deletion() + anything_else_portion_is_valid = False + + # ------- Return combined validation result ------- + all_forms_valid = cisa_rep_portion_is_valid and anything_else_portion_is_valid + return all_forms_valid class Requirements(DomainRequestWizard):

Your domain requests