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