This commit is contained in:
Rachid Mrad 2024-04-25 16:34:17 -04:00
commit 68bc920010
No known key found for this signature in database
20 changed files with 1018 additions and 312 deletions

View file

@ -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/

View file

@ -1229,7 +1229,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",
@ -1302,6 +1312,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
"no_other_contacts_rationale",
"anything_else",
"is_policy_acknowledged",
"cisa_representative_email",
]
autocomplete_fields = [
"approved_domain",

View file

@ -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')

View file

@ -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,57 +771,40 @@ 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);
}
}
if (radioButtons.length) {
// Add event listener to each radio button
radioButtons.forEach(function (radioButton) {
radioButton.addEventListener('change', handleRadioButtonChange);
/**
* 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");
});
// 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)
})();

View file

@ -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),
]:

View file

@ -93,6 +93,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",
@ -169,6 +175,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",

View file

@ -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. (Well 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. (Well 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 representatives 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,7 +679,20 @@ class AnythingElseForm(RegistrarForm):
message="Response must be less than 2000 characters.",
)
],
error_messages={
"required": (
"Provide additional details youd 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):

View file

@ -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

View file

@ -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"),
),
]

View file

@ -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(

View file

@ -651,6 +651,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(
@ -705,8 +731,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:
@ -1045,6 +1096,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?

View file

@ -0,0 +1,55 @@
{% extends 'domain_request_form.html' %}
{% load static field_helpers %}
{% block form_instructions %}
<em>These questions are required (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
{% endblock %}
{% block form_required_fields_help_text %}
{# commented out so it does not appear at this point on this page #}
{% endblock %}
<!-- TODO-NL: (refactor) Breakup into two separate components-->
{% block form_fields %}
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Are you working with a CISA regional representative on your domain request?</h2>
<p>.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has <a href="https://www.cisa.gov/about/regions" target="_blank">10 regions</a> that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.</p>
</legend>
<!-- Toggle -->
{% 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 #}
<!-- TODO-NL: Hookup forms.0 to yes/no form for cisa representative (backend def)-->
</fieldset>
<div id="cisa-representative" class="cisa-representative-form">
{% input_with_errors forms.1.cisa_representative_email %}
{# forms.1 is a form for inputting the e-mail of a cisa representative #}
<!-- TODO-NL: Hookup forms.1 to cisa representative form (backend def) -->
</div>
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Is there anything else youd like us to know about your domain request?</h2>
</legend>
<!-- Toggle -->
{% 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 #}
<!-- TODO-NL: Hookup forms.2 to yes/no form for anything else form (backend def)-->
</fieldset>
<div id="anything-else">
{% 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 #}
<!-- TODO-NL: Hookup forms.3 to anything else form (backend def) -->
</div>
{% endblock %}

View file

@ -1,19 +0,0 @@
{% extends 'domain_request_form.html' %}
{% load field_helpers %}
{% block form_instructions %}
<h2>Is there anything else youd like us to know about your domain request?</h2>
<p>This question is optional.</p>
{% 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 %}

View file

@ -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 %}
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if domain_request.anything_else %}
{{domain_request.anything_else}}
{% else %}
No
{% endif %}
</ul>
{% endif %}

View file

@ -116,7 +116,18 @@
{% 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 %}
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.anything_else %}
{{DomainRequest.anything_else}}
{% else %}
No
{% endif %}
</ul>
{% endif %}
{% endwith %}
</div>

View file

@ -20,6 +20,9 @@
</{{ heading_level }}>
{% else %}
</h2>
{% endif %}
{% if sub_header_text %}
<h3 class="register-form-review-header">{{ sub_header_text }}</h3>
{% endif %}
{% if address %}
{% include "includes/organization_address.html" with organization=value %}
@ -39,6 +42,10 @@
</dd>
{% endfor %}
</dl>
{% elif custom_text_for_value_none %}
<p>
{{ custom_text_for_value_none }}
</p>
{% else %}
<p>
None
@ -92,6 +99,8 @@
<p class="margin-top-0 margin-bottom-0">
{% if value %}
{{ value }}
{% elif custom_text_for_value_none %}
{{ custom_text_for_value_none }}
{% else %}
None
{% endif %}

View file

@ -2034,6 +2034,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",
@ -2065,6 +2068,7 @@ class TestDomainRequestAdmin(MockEppLib):
"no_other_contacts_rationale",
"anything_else",
"is_policy_acknowledged",
"cisa_representative_email",
]
self.assertEqual(readonly_fields, expected_fields)

View file

@ -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."

View file

@ -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 youd 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"""

View file

@ -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):