diff --git a/src/api/views.py b/src/api/views.py index 3071712a7..f9fa2d1ea 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -1,10 +1,11 @@ """Internal API views""" from django.apps import apps from django.views.decorators.http import require_http_methods -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse from django.utils.safestring import mark_safe from registrar.templatetags.url_helpers import public_site_url +from registrar.utility.enums import ValidationReturnType from registrar.utility.errors import GenericError, GenericErrorCodes import requests @@ -71,6 +72,7 @@ def check_domain_available(domain): a match. If check fails, throws a RegistryError. """ Domain = apps.get_model("registrar.Domain") + if domain.endswith(".gov"): return Domain.available(domain) else: @@ -86,22 +88,14 @@ def available(request, domain=""): Response is a JSON dictionary with the key "available" and value true or false. """ + Domain = apps.get_model("registrar.Domain") domain = request.GET.get("domain", "") - DraftDomain = apps.get_model("registrar.DraftDomain") - # validate that the given domain could be a domain name and fail early if - # not. - if not (DraftDomain.string_could_be_domain(domain) or DraftDomain.string_could_be_domain(domain + ".gov")): - return JsonResponse({"available": False, "code": "invalid", "message": DOMAIN_API_MESSAGES["invalid"]}) - # a domain is available if it is NOT in the list of current domains - try: - if check_domain_available(domain): - return JsonResponse({"available": True, "code": "success", "message": DOMAIN_API_MESSAGES["success"]}) - else: - return JsonResponse( - {"available": False, "code": "unavailable", "message": DOMAIN_API_MESSAGES["unavailable"]} - ) - except Exception: - return JsonResponse({"available": False, "code": "error", "message": DOMAIN_API_MESSAGES["error"]}) + + _, json_response = Domain.validate_and_handle_errors( + domain=domain, + return_type=ValidationReturnType.JSON_RESPONSE, + ) + return json_response @require_http_methods(["GET"]) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 11ba49aa9..3995e975c 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -134,10 +134,19 @@ function _checkDomainAvailability(el) { const callback = (response) => { toggleInputValidity(el, (response && response.available), msg=response.message); announce(el.id, response.message); + + // Determines if we ignore the field if it is just blank + ignore_blank = el.classList.contains("blank-ok") if (el.validity.valid) { el.classList.add('usa-input--success'); // use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration inlineToast(el.parentElement, el.id, SUCCESS, response.message); + } else if (ignore_blank && response.code == "required"){ + // Visually remove the error + error = "usa-input--error" + if (el.classList.contains(error)){ + el.classList.remove(error) + } } else { inlineToast(el.parentElement, el.id, ERROR, response.message); } @@ -229,99 +238,203 @@ function handleValidationClick(e) { } })(); +/** + * Delete method for formsets that diff in the view and delete in the model (Nameservers, DS Data) + * + */ +function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ + let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); + let formToRemove = e.target.closest(".repeatable-form"); + formToRemove.remove(); + let forms = document.querySelectorAll(".repeatable-form"); + totalForms.setAttribute('value', `${forms.length}`); + + let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g'); + let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); + // For the example on Nameservers + let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g'); + + forms.forEach((form, index) => { + // Iterate over child nodes of the current element + Array.from(form.querySelectorAll('label, input, select')).forEach((node) => { + // Iterate through the attributes of the current node + Array.from(node.attributes).forEach((attr) => { + // Check if the attribute value matches the regex + if (formNumberRegex.test(attr.value)) { + // Replace the attribute value with the updated value + attr.value = attr.value.replace(formNumberRegex, `form-${index}-`); + } + }); + }); + + // h2 and legend for DS form, label for nameservers + Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => { + + // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required) + // inject the USWDS required markup and make sure the INPUT is required + if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) { + // Create a new element + const newElement = document.createElement('abbr'); + newElement.textContent = '*'; + newElement.setAttribute("title", "required"); + newElement.classList.add("usa-hint", "usa-hint--required"); + + // Append the new element to the label + node.appendChild(newElement); + // Find the next sibling that is an input element + let nextInputElement = node.nextElementSibling; + + while (nextInputElement) { + if (nextInputElement.tagName === 'INPUT') { + // Found the next input element + nextInputElement.setAttribute("required", "") + break; + } + nextInputElement = nextInputElement.nextElementSibling; + } + nextInputElement.required = true; + } + + let innerSpan = node.querySelector('span') + if (innerSpan) { + innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); + } else { + node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); + node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`); + } + }); + + // Display the add more button if we have less than 13 forms + if (isNameserversForm && forms.length <= 13) { + addButton.removeAttribute("disabled"); + } + + if (isNameserversForm && forms.length < 3) { + // Hide the delete buttons on the remaining nameservers + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.setAttribute("disabled", "true"); + }); + } + + }); +} /** - * Prepare the namerservers and DS data forms delete buttons - * We will call this on the forms init, and also every time we add a form + * Delete method for formsets using the DJANGO DELETE widget (Other Contacts) + * + */ +function markForm(e, formLabel){ + // Unlike removeForm, we only work with the visible forms when using DJANGO's DELETE widget + let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; + + if (totalShownForms == 1) { + // toggle the radio buttons + let radioButton = document.querySelector('input[name="other_contacts-has_other_contacts"][value="False"]'); + radioButton.checked = true; + // Trigger the change event + let event = new Event('change'); + radioButton.dispatchEvent(event); + } else { + + // Grab the hidden delete input and assign a value DJANGO will look for + let formToRemove = e.target.closest(".repeatable-form"); + if (formToRemove) { + let deleteInput = formToRemove.querySelector('input[class="deletion"]'); + if (deleteInput) { + deleteInput.value = 'on'; + } + } + + // Set display to 'none' + formToRemove.style.display = 'none'; + } + + // Update h2s on the visible forms only. We won't worry about the forms' identifiers + let shownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`); + let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); + shownForms.forEach((form, index) => { + // Iterate over child nodes of the current element + Array.from(form.querySelectorAll('h2')).forEach((node) => { + node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); + }); + }); +} + +/** + * Prepare the namerservers, DS data and Other Contacts formsets' delete button + * for the last added form. We call this from the Add function + * + */ +function prepareNewDeleteButton(btn, formLabel) { + let formIdentifier = "form" + let isNameserversForm = document.querySelector(".nameservers-form"); + let isOtherContactsForm = document.querySelector(".other-contacts-form"); + let addButton = document.querySelector("#add-form"); + + if (isOtherContactsForm) { + formIdentifier = "other_contacts"; + // We will mark the forms for deletion + btn.addEventListener('click', function(e) { + markForm(e, formLabel); + }); + } else { + // We will remove the forms and re-order the formset + btn.addEventListener('click', function(e) { + removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + }); + } +} + +/** + * Prepare the namerservers, DS data and Other Contacts formsets' delete buttons + * We will call this on the forms init * */ function prepareDeleteButtons(formLabel) { + let formIdentifier = "form" let deleteButtons = document.querySelectorAll(".delete-record"); - let totalForms = document.querySelector("#id_form-TOTAL_FORMS"); - let isNameserversForm = document.title.includes("DNS name servers |"); + let isNameserversForm = document.querySelector(".nameservers-form"); + let isOtherContactsForm = document.querySelector(".other-contacts-form"); let addButton = document.querySelector("#add-form"); - + if (isOtherContactsForm) { + formIdentifier = "other_contacts"; + } + // Loop through each delete button and attach the click event listener deleteButtons.forEach((deleteButton) => { - deleteButton.addEventListener('click', removeForm); + if (isOtherContactsForm) { + // We will mark the forms for deletion + deleteButton.addEventListener('click', function(e) { + markForm(e, formLabel); + }); + } else { + // We will remove the forms and re-order the formset + deleteButton.addEventListener('click', function(e) { + removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + }); + } }); +} - function removeForm(e){ - let formToRemove = e.target.closest(".repeatable-form"); - formToRemove.remove(); - let forms = document.querySelectorAll(".repeatable-form"); - totalForms.setAttribute('value', `${forms.length}`); +/** + * DJANGO formset's DELETE widget + * On form load, hide deleted forms, ie. those forms with hidden input of class 'deletion' + * with value='on' + */ +function hideDeletedForms() { + let hiddenDeleteButtonsWithValueOn = document.querySelectorAll('input[type="hidden"].deletion[value="on"]'); - let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g'); - let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); - // For the example on Nameservers - let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g'); - - forms.forEach((form, index) => { - // Iterate over child nodes of the current element - Array.from(form.querySelectorAll('label, input, select')).forEach((node) => { - // Iterate through the attributes of the current node - Array.from(node.attributes).forEach((attr) => { - // Check if the attribute value matches the regex - if (formNumberRegex.test(attr.value)) { - // Replace the attribute value with the updated value - attr.value = attr.value.replace(formNumberRegex, `form-${index}-`); - } - }); - }); - - // h2 and legend for DS form, label for nameservers - Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => { - - // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required) - // inject the USWDS required markup and make sure the INPUT is required - if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) { - // Create a new element - const newElement = document.createElement('abbr'); - newElement.textContent = '*'; - newElement.setAttribute("title", "required"); - newElement.classList.add("usa-hint", "usa-hint--required"); - - // Append the new element to the label - node.appendChild(newElement); - // Find the next sibling that is an input element - let nextInputElement = node.nextElementSibling; - - while (nextInputElement) { - if (nextInputElement.tagName === 'INPUT') { - // Found the next input element - nextInputElement.setAttribute("required", "") - break; - } - nextInputElement = nextInputElement.nextElementSibling; - } - nextInputElement.required = true; - } - - let innerSpan = node.querySelector('span') - if (innerSpan) { - innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); - } else { - node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); - node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`); - } - }); - - // Display the add more button if we have less than 13 forms - if (isNameserversForm && forms.length <= 13) { - console.log('remove disabled'); - addButton.removeAttribute("disabled"); + // Iterating over the NodeList of hidden inputs + hiddenDeleteButtonsWithValueOn.forEach(function(hiddenInput) { + // Finding the closest parent element with class "repeatable-form" for each hidden input + var repeatableFormToHide = hiddenInput.closest('.repeatable-form'); + + // Checking if a matching parent element is found for each hidden input + if (repeatableFormToHide) { + // Setting the display property to "none" for each matching parent element + repeatableFormToHide.style.display = 'none'; } - - if (isNameserversForm && forms.length < 3) { - // Hide the delete buttons on the remaining nameservers - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.setAttribute("disabled", "true"); - }); - } - - }); - } + }); } /** @@ -331,25 +444,38 @@ function prepareDeleteButtons(formLabel) { * it everywhere. */ (function prepareFormsetsForms() { + let formIdentifier = "form" let repeatableForm = document.querySelectorAll(".repeatable-form"); let container = document.querySelector("#form-container"); let addButton = document.querySelector("#add-form"); - let totalForms = document.querySelector("#id_form-TOTAL_FORMS"); let cloneIndex = 0; let formLabel = ''; - let isNameserversForm = document.title.includes("DNS name servers |"); + let isNameserversForm = document.querySelector(".nameservers-form"); + let isOtherContactsForm = document.querySelector(".other-contacts-form"); + let isDsDataForm = document.querySelector(".ds-data-form"); + // The Nameservers formset features 2 required and 11 optionals if (isNameserversForm) { cloneIndex = 2; formLabel = "Name server"; - } else if ((document.title.includes("DS Data |")) || (document.title.includes("Key Data |"))) { - formLabel = "DS Data record"; + // DNSSEC: DS Data + } else if (isDsDataForm) { + formLabel = "DS data record"; + // The Other Contacts form + } else if (isOtherContactsForm) { + formLabel = "Organization contact"; + container = document.querySelector("#other-employees"); + formIdentifier = "other_contacts" } + let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); // On load: Disable the add more button if we have 13 forms if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) { addButton.setAttribute("disabled", "true"); } + // Hide forms which have previously been deleted + hideDeletedForms() + // Attach click event listener on the delete buttons of the existing forms prepareDeleteButtons(formLabel); @@ -360,7 +486,7 @@ function prepareDeleteButtons(formLabel) { let forms = document.querySelectorAll(".repeatable-form"); let formNum = forms.length; let newForm = repeatableForm[cloneIndex].cloneNode(true); - let formNumberRegex = RegExp(`form-(\\d){1}-`,'g'); + let formNumberRegex = RegExp(`${formIdentifier}-(\\d){1}-`,'g'); let formLabelRegex = RegExp(`${formLabel} (\\d){1}`, 'g'); // For the eample on Nameservers let formExampleRegex = RegExp(`ns(\\d){1}`, 'g'); @@ -393,16 +519,27 @@ function prepareDeleteButtons(formLabel) { } formNum++; - newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum-1}-`); - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); + + newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`); + // For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden, + // since the form on the backend employs Django's DELETE widget. For the other formsets, we delete the form + // in JS (completely remove from teh DOM) so we update the headers/labels based on total number of forms. + if (isOtherContactsForm) { + let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`); + } else { + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); + } newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`); container.insertBefore(newForm, addButton); + newForm.style.display = 'block'; + let inputs = newForm.querySelectorAll("input"); // Reset the values of each input to blank inputs.forEach((input) => { input.classList.remove("usa-input--error"); - if (input.type === "text" || input.type === "number" || input.type === "password") { + if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") { input.value = ""; // Set the value to an empty string } else if (input.type === "checkbox" || input.type === "radio") { @@ -439,7 +576,8 @@ function prepareDeleteButtons(formLabel) { totalForms.setAttribute('value', `${formNum}`); // Attach click event listener on the delete buttons of the new form - prepareDeleteButtons(formLabel); + let newDeleteButton = newForm.querySelector(".delete-record"); + prepareNewDeleteButton(newDeleteButton, formLabel); // Disable the add more button if we have 13 forms if (isNameserversForm && formNum == 13) { @@ -484,6 +622,7 @@ function prepareDeleteButtons(formLabel) { } })(); +// 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); diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 1d936a255..b6d13cee3 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -4,6 +4,10 @@ .sr-only { @include sr-only; } + +.clear-both { + clear: both; +} * { -webkit-font-smoothing: antialiased; diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index d0bfbee67..94407f88d 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -31,3 +31,10 @@ padding-left: 0; border-left: none; } + +legend.float-left-tablet + button.float-right-tablet { + margin-top: .5rem; + @include at-media('tablet') { + margin-top: 1rem; + } +} diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss index 4fc2bb819..cc0d39a5b 100644 --- a/src/registrar/assets/sass/_theme/_typography.scss +++ b/src/registrar/assets/sass/_theme/_typography.scss @@ -22,3 +22,9 @@ h2 { margin: units(4) 0 units(1); color: color('primary-darker'); } + +// Normalize typography in forms +.usa-form, +.usa-form fieldset { + font-size: 1rem; +} diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 157d4b234..36ff408c2 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -2,18 +2,17 @@ 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, OneToOneField - -from api.views import DOMAIN_API_MESSAGES +from django.db.models.fields.related import ForeignObjectRel from registrar.models import Contact, DomainApplication, DraftDomain, Domain from registrar.templatetags.url_helpers import public_site_url -from registrar.utility import errors +from registrar.utility.enums import ValidationReturnType logger = logging.getLogger(__name__) @@ -96,39 +95,10 @@ class RegistrarFormSet(forms.BaseFormSet): """ raise NotImplementedError - def has_more_than_one_join(self, db_obj, rel, related_name): - """Helper for finding whether an object is joined more than once.""" - # threshold is the number of related objects that are acceptable - # when determining if related objects exist. threshold is 0 for most - # relationships. if the relationship is related_name, we know that - # there is already exactly 1 acceptable relationship (the one we are - # attempting to delete), so the threshold is 1 - threshold = 1 if rel == related_name else 0 - - # Raise a KeyError if rel is not a defined field on the db_obj model - # This will help catch any errors in reverse_join config on forms - if rel not in [field.name for field in db_obj._meta.get_fields()]: - raise KeyError(f"{rel} is not a defined field on the {db_obj._meta.model_name} model.") - - # if attr rel in db_obj is not None, then test if reference object(s) exist - if getattr(db_obj, rel) is not None: - field = db_obj._meta.get_field(rel) - if isinstance(field, OneToOneField): - # if the rel field is a OneToOne field, then we have already - # determined that the object exists (is not None) - return True - elif isinstance(field, ForeignObjectRel): - # if the rel field is a ManyToOne or ManyToMany, then we need - # to determine if the count of related objects is greater than - # the threshold - return getattr(db_obj, rel).count() > threshold - return False - def _to_database( self, obj: DomainApplication, join: str, - reverse_joins: list, should_delete: Callable, pre_update: Callable, pre_create: Callable, @@ -165,19 +135,25 @@ class RegistrarFormSet(forms.BaseFormSet): # matching database object exists, update it if db_obj is not None and cleaned: if should_delete(cleaned): - if any(self.has_more_than_one_join(db_obj, rel, related_name) for rel in reverse_joins): + 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.application) else: # If there are no other relationships, delete the object db_obj.delete() else: - pre_update(db_obj, cleaned) - db_obj.save() + 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.application) + 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): + elif db_obj is None and cleaned and not cleaned.get("DELETE", False): kwargs = pre_create(db_obj, cleaned) getattr(obj, join).create(**kwargs) @@ -213,7 +189,7 @@ class TribalGovernmentForm(RegistrarForm): ) tribe_name = forms.CharField( - label="What is the name of the tribe you represent?", + label="Name of tribe", error_messages={"required": "Enter the tribe you represent."}, ) @@ -351,13 +327,18 @@ class AboutYourOrganizationForm(RegistrarForm): class AuthorizingOfficialForm(RegistrarForm): + JOIN = "authorizing_official" + def to_database(self, obj): if not self.is_valid(): return contact = getattr(obj, "authorizing_official", None) - if contact is not None: + if contact is not None and not contact.has_more_than_one_join("authorizing_official"): + # if contact exists in the database and is not joined to other entities super().to_database(contact) else: + # no contact exists OR contact exists which is joined also to other entities; + # in either case, create a new contact and update it contact = Contact() super().to_database(contact) obj.authorizing_official = contact @@ -411,7 +392,7 @@ class BaseCurrentSitesFormSet(RegistrarFormSet): def to_database(self, obj: DomainApplication): # If we want to test against multiple joins for a website object, replace the empty array # and change the JOIN in the models to allow for reverse references - self._to_database(obj, self.JOIN, [], self.should_delete, self.pre_update, self.pre_create) + self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create) @classmethod def from_database(cls, obj): @@ -429,17 +410,12 @@ CurrentSitesFormSet = forms.formset_factory( class AlternativeDomainForm(RegistrarForm): def clean_alternative_domain(self): """Validation code for domain names.""" - try: - requested = self.cleaned_data.get("alternative_domain", None) - validated = DraftDomain.validate(requested, blank_ok=True) - except errors.ExtraDotsError: - raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots") - except errors.DomainUnavailableError: - raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable") - except errors.RegistrySystemError: - raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error") - except ValueError: - raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid") + requested = self.cleaned_data.get("alternative_domain", None) + validated, _ = DraftDomain.validate_and_handle_errors( + domain=requested, + return_type=ValidationReturnType.FORM_VALIDATION_ERROR, + blank_ok=True, + ) return validated alternative_domain = forms.CharField( @@ -470,7 +446,7 @@ class BaseAlternativeDomainFormSet(RegistrarFormSet): def to_database(self, obj: DomainApplication): # If we want to test against multiple joins for a website object, replace the empty array and # change the JOIN in the models to allow for reverse references - self._to_database(obj, self.JOIN, [], self.should_delete, self.pre_update, self.pre_create) + self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create) @classmethod def on_fetch(cls, query): @@ -517,22 +493,19 @@ class DotGovDomainForm(RegistrarForm): def clean_requested_domain(self): """Validation code for domain names.""" - try: - requested = self.cleaned_data.get("requested_domain", None) - validated = DraftDomain.validate(requested) - except errors.BlankValueError: - raise forms.ValidationError(DOMAIN_API_MESSAGES["required"], code="required") - except errors.ExtraDotsError: - raise forms.ValidationError(DOMAIN_API_MESSAGES["extra_dots"], code="extra_dots") - except errors.DomainUnavailableError: - raise forms.ValidationError(DOMAIN_API_MESSAGES["unavailable"], code="unavailable") - except errors.RegistrySystemError: - raise forms.ValidationError(DOMAIN_API_MESSAGES["error"], code="error") - except ValueError: - raise forms.ValidationError(DOMAIN_API_MESSAGES["invalid"], code="invalid") + requested = self.cleaned_data.get("requested_domain", None) + validated, _ = DraftDomain.validate_and_handle_errors( + domain=requested, + return_type=ValidationReturnType.FORM_VALIDATION_ERROR, + ) return validated - requested_domain = forms.CharField(label="What .gov domain do you want?") + requested_domain = forms.CharField( + label="What .gov domain do you want?", + error_messages={ + "required": DOMAIN_API_MESSAGES["required"], + }, + ) class PurposeForm(RegistrarForm): @@ -550,13 +523,18 @@ class PurposeForm(RegistrarForm): class YourContactForm(RegistrarForm): + JOIN = "submitter" + def to_database(self, obj): if not self.is_valid(): return contact = getattr(obj, "submitter", None) - if contact is not None: + if contact is not None and not contact.has_more_than_one_join("submitted_applications"): + # if contact exists in the database and is not joined to other entities super().to_database(contact) else: + # no contact exists OR contact exists which is joined also to other entities; + # in either case, create a new contact and update it contact = Contact() super().to_database(contact) obj.submitter = contact @@ -610,9 +588,12 @@ class OtherContactsYesNoForm(RegistrarForm): 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).")), + 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.", + }, ) @@ -639,7 +620,10 @@ class OtherContactsForm(RegistrarForm): ) email = forms.EmailField( label="Email", - error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")}, + error_messages={ + "required": ("Enter an email address in the required format, like name@example.com."), + "invalid": ("Enter an email address in the required format, like name@example.com."), + }, ) phone = PhoneNumberField( label="Phone", @@ -650,8 +634,17 @@ class OtherContactsForm(RegistrarForm): ) def __init__(self, *args, **kwargs): + """ + Override the __init__ method for RegistrarForm. + Set form_data_marked_for_deletion to false. + Empty_permitted set to False, as this is overridden in certain circumstances by + Django's BaseFormSet, and results in empty forms being allowed and field level + errors not appropriately raised. This works with code in the view which appropriately + displays required attributes on fields. + """ self.form_data_marked_for_deletion = False super().__init__(*args, **kwargs) + self.empty_permitted = False def mark_form_for_deletion(self): self.form_data_marked_for_deletion = True @@ -660,12 +653,11 @@ class OtherContactsForm(RegistrarForm): """ This method overrides the default behavior for forms. This cleans the form after field validation has already taken place. - In this override, allow for a form which is empty to be considered - valid even though certain required fields have not passed field - validation + In this override, allow for a form which is deleted by user or marked for + deletion by formset to be considered valid even though certain required fields have + not passed field validation """ - - if self.form_data_marked_for_deletion: + if self.form_data_marked_for_deletion or self.cleaned_data.get("DELETE"): # clear any errors raised by the form fields # (before this clean() method is run, each field # performs its own clean, which could result in @@ -679,24 +671,34 @@ class OtherContactsForm(RegistrarForm): # return empty object with only 'delete' attribute defined. # this will prevent _to_database from creating an empty # database object - return {"delete": True} + return {"DELETE": True} return self.cleaned_data class BaseOtherContactsFormSet(RegistrarFormSet): + """ + FormSet for Other Contacts + + There are two conditions by which a form in the formset can be marked for deletion. + One is if the user clicks 'DELETE' button, and this is submitted in the form. The + other is if the YesNo form, which is submitted with this formset, is set to No; in + this case, all forms in formset are marked for deletion. Both of these conditions + must co-exist. + Also, other_contacts have db relationships to multiple db objects. When attempting + to delete an other_contact from an application, those db relationships must be + tested and handled. + """ + JOIN = "other_contacts" - REVERSE_JOINS = [ - "user", - "authorizing_official", - "submitted_applications", - "contact_applications", - "information_authorizing_official", - "submitted_applications_information", - "contact_applications_information", - ] + + def get_deletion_widget(self): + return forms.HiddenInput(attrs={"class": "deletion"}) def __init__(self, *args, **kwargs): + """ + Override __init__ for RegistrarFormSet. + """ self.formset_data_marked_for_deletion = False self.application = kwargs.pop("application", None) super(RegistrarFormSet, self).__init__(*args, **kwargs) @@ -707,11 +709,20 @@ class BaseOtherContactsFormSet(RegistrarFormSet): self.forms[index].use_required_attribute = True def should_delete(self, cleaned): - empty = (isinstance(v, str) and (v.strip() == "" or v is None) for v in cleaned.values()) - return all(empty) or self.formset_data_marked_for_deletion + """ + Implements should_delete method from BaseFormSet. + """ + return self.formset_data_marked_for_deletion or cleaned.get("DELETE", False) + + def pre_create(self, db_obj, cleaned): + """Code to run before an item in the formset is created in the database.""" + # remove DELETE from cleaned + if "DELETE" in cleaned: + cleaned.pop("DELETE") + return cleaned def to_database(self, obj: DomainApplication): - self._to_database(obj, self.JOIN, self.REVERSE_JOINS, self.should_delete, self.pre_update, self.pre_create) + self._to_database(obj, self.JOIN, self.should_delete, self.pre_update, self.pre_create) @classmethod def from_database(cls, obj): @@ -737,9 +748,10 @@ class BaseOtherContactsFormSet(RegistrarFormSet): OtherContactsFormSet = forms.formset_factory( OtherContactsForm, - extra=1, + extra=0, absolute_max=1500, # django default; use `max_num` to limit entries min_num=1, + can_delete=True, validate_min=True, formset=BaseOtherContactsFormSet, ) @@ -749,11 +761,7 @@ class NoOtherContactsForm(RegistrarForm): no_other_contacts_rationale = forms.CharField( required=True, # label has to end in a space to get the label_suffix to show - label=( - "You don’t need to provide names of other employees now, but it may " - "slow down our assessment of your eligibility. Describe why there are " - "no other employees who can help verify your request." - ), + label=("No other employees rationale"), widget=forms.Textarea(), validators=[ MaxLengthValidator( diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 17616df4b..1669774ae 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -210,6 +210,8 @@ class ContactForm(forms.ModelForm): class AuthorizingOfficialContactForm(ContactForm): """Form for updating authorizing official contacts.""" + JOIN = "authorizing_official" + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -230,6 +232,29 @@ class AuthorizingOfficialContactForm(ContactForm): self.fields["email"].error_messages = { "required": "Enter an email address in the required format, like name@example.com." } + self.domainInfo = None + + def set_domain_info(self, domainInfo): + """Set the domain information for the form. + The form instance is associated with the contact itself. In order to access the associated + domain information object, this needs to be set in the form by the view.""" + self.domainInfo = domainInfo + + def save(self, commit=True): + """Override the save() method of the BaseModelForm.""" + + # Get the Contact object from the db for the Authorizing Official + db_ao = Contact.objects.get(id=self.instance.id) + if self.domainInfo and db_ao.has_more_than_one_join("information_authorizing_official"): + # Handle the case where the domain information object is available and the AO Contact + # has more than one joined object. + # In this case, create a new Contact, and update the new Contact with form data. + # Then associate with domain information object as the authorizing_official + data = dict(self.cleaned_data.items()) + self.domainInfo.authorizing_official = Contact.objects.create(**data) + self.domainInfo.save() + else: + super().save() class DomainSecurityEmailForm(forms.Form): diff --git a/src/registrar/management/commands/utility/extra_transition_domain_helper.py b/src/registrar/management/commands/utility/extra_transition_domain_helper.py index 54f68d5c8..755c9b98a 100644 --- a/src/registrar/management/commands/utility/extra_transition_domain_helper.py +++ b/src/registrar/management/commands/utility/extra_transition_domain_helper.py @@ -11,6 +11,7 @@ import os import sys from typing import Dict, List from django.core.paginator import Paginator +from registrar.utility.enums import LogCode from registrar.models.transition_domain import TransitionDomain from registrar.management.commands.utility.load_organization_error import ( LoadOrganizationError, @@ -28,7 +29,8 @@ from .epp_data_containers import ( ) from .transition_domain_arguments import TransitionDomainArguments -from .terminal_helper import TerminalColors, TerminalHelper, LogCode +from .terminal_helper import TerminalColors, TerminalHelper + logger = logging.getLogger(__name__) diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index cb2152959..49ab89b9a 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -1,30 +1,12 @@ -from enum import Enum import logging import sys from django.core.paginator import Paginator from typing import List +from registrar.utility.enums import LogCode logger = logging.getLogger(__name__) -class LogCode(Enum): - """Stores the desired log severity - - Overview of error codes: - - 1 ERROR - - 2 WARNING - - 3 INFO - - 4 DEBUG - - 5 DEFAULT - """ - - ERROR = 1 - WARNING = 2 - INFO = 3 - DEBUG = 4 - DEFAULT = 5 - - class TerminalColors: """Colors for terminal outputs (makes reading the logs WAY easier)""" diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 06cf83887..ff7389780 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -54,6 +54,47 @@ class Contact(TimeStampedModel): db_index=True, ) + def _get_all_relations(self): + """Returns an array of all fields which are relations""" + return [f.name for f in self._meta.get_fields() if f.is_relation] + + def has_more_than_one_join(self, expected_relation): + """Helper for finding whether an object is joined more than once. + expected_relation is the one relation with one expected join""" + # all_relations is the list of all_relations (from contact) to be checked for existing joins + all_relations = self._get_all_relations() + return any(self._has_more_than_one_join_per_relation(rel, expected_relation) for rel in all_relations) + + def _has_more_than_one_join_per_relation(self, relation, expected_relation): + """Helper for finding whether an object is joined more than once.""" + # threshold is the number of related objects that are acceptable + # when determining if related objects exist. threshold is 0 for most + # relationships. if the relationship is expected_relation, we know that + # there is already exactly 1 acceptable relationship (the one we are + # attempting to delete), so the threshold is 1 + threshold = 1 if relation == expected_relation else 0 + + # Raise a KeyError if rel is not a defined field on the db_obj model + # This will help catch any errors in relation passed. + if relation not in [field.name for field in self._meta.get_fields()]: + raise KeyError(f"{relation} is not a defined field on the {self._meta.model_name} model.") + + # if attr rel in db_obj is not None, then test if reference object(s) exist + if getattr(self, relation) is not None: + field = self._meta.get_field(relation) + if isinstance(field, models.OneToOneField): + # if the rel field is a OneToOne field, then we have already + # determined that the object exists (is not None) + # so return True unless the relation being tested is the expected_relation + is_not_expected_relation = relation != expected_relation + return is_not_expected_relation + elif isinstance(field, models.ForeignObjectRel): + # if the rel field is a ManyToOne or ManyToMany, then we need + # to determine if the count of related objects is greater than + # the threshold + return getattr(self, relation).count() > threshold + return False + def get_formatted_name(self): """Returns the contact's name in Western order.""" names = [n for n in [self.first_name, self.middle_name, self.last_name] if n] diff --git a/src/registrar/models/utility/domain_helper.py b/src/registrar/models/utility/domain_helper.py index e43661b1d..a808ef803 100644 --- a/src/registrar/models/utility/domain_helper.py +++ b/src/registrar/models/utility/domain_helper.py @@ -1,8 +1,12 @@ import re -from api.views import check_domain_available +from django import forms +from django.http import JsonResponse + +from api.views import DOMAIN_API_MESSAGES, check_domain_available from registrar.utility import errors from epplibwrapper.errors import RegistryError +from registrar.utility.enums import ValidationReturnType class DomainHelper: @@ -23,21 +27,12 @@ class DomainHelper: return bool(cls.DOMAIN_REGEX.match(domain)) @classmethod - def validate(cls, domain: str | None, blank_ok=False) -> str: + def validate(cls, domain: str, blank_ok=False) -> str: """Attempt to determine if a domain name could be requested.""" - if domain is None: - raise errors.BlankValueError() - if not isinstance(domain, str): - raise ValueError("Domain name must be a string") - domain = domain.lower().strip() - if domain == "" and not blank_ok: - raise errors.BlankValueError() - if domain.endswith(".gov"): - domain = domain[:-4] - if "." in domain: - raise errors.ExtraDotsError() - if not DomainHelper.string_could_be_domain(domain + ".gov"): - raise ValueError() + + # Split into pieces for the linter + domain = cls._validate_domain_string(domain, blank_ok) + try: if not check_domain_available(domain): raise errors.DomainUnavailableError() @@ -45,6 +40,110 @@ class DomainHelper: raise errors.RegistrySystemError() from err return domain + @staticmethod + def _validate_domain_string(domain, blank_ok): + """Normalize the domain string, and check its content""" + if domain is None: + raise errors.BlankValueError() + + if not isinstance(domain, str): + raise errors.InvalidDomainError() + + domain = domain.lower().strip() + + if domain == "" and not blank_ok: + raise errors.BlankValueError() + elif domain == "": + # If blank ok is true, just return the domain + return domain + + if domain.endswith(".gov"): + domain = domain[:-4] + + if "." in domain: + raise errors.ExtraDotsError() + + if not DomainHelper.string_could_be_domain(domain + ".gov"): + raise errors.InvalidDomainError() + + return domain + + @classmethod + def validate_and_handle_errors(cls, domain, return_type, blank_ok=False): + """ + Validates a domain and returns an appropriate response based on the validation result. + + This method uses the `validate` method to validate the domain. If validation fails, it catches the exception, + maps it to a corresponding error code, and returns a response based on the `return_type` parameter. + + Args: + domain (str): The domain to validate. + return_type (ValidationReturnType): Determines the type of response (JSON or form validation error). + blank_ok (bool, optional): If True, blank input does not raise an exception. Defaults to False. + + Returns: + tuple: The validated domain (or None if validation failed), and the response (success or error). + """ # noqa + + # Map each exception to a corresponding error code + error_map = { + errors.BlankValueError: "required", + errors.ExtraDotsError: "extra_dots", + errors.DomainUnavailableError: "unavailable", + errors.RegistrySystemError: "error", + errors.InvalidDomainError: "invalid", + } + + validated = None + response = None + + try: + # Attempt to validate the domain + validated = cls.validate(domain, blank_ok) + + # Get a list of each possible exception, and the code to return + except tuple(error_map.keys()) as error: + # If an error is caught, get its type + error_type = type(error) + + # Generate the response based on the error code and return type + response = DomainHelper._return_form_error_or_json_response(return_type, code=error_map.get(error_type)) + else: + # For form validation, we do not need to display the success message + if return_type != ValidationReturnType.FORM_VALIDATION_ERROR: + response = DomainHelper._return_form_error_or_json_response(return_type, code="success", available=True) + + # Return the validated domain and the response (either error or success) + return (validated, response) + + @staticmethod + def _return_form_error_or_json_response(return_type: ValidationReturnType, code, available=False): + """ + Returns an error response based on the `return_type`. + + If `return_type` is `FORM_VALIDATION_ERROR`, raises a form validation error. + If `return_type` is `JSON_RESPONSE`, returns a JSON response with 'available', 'code', and 'message' fields. + If `return_type` is neither, raises a ValueError. + + Args: + return_type (ValidationReturnType): The type of error response. + code (str): The error code for the error message. + available (bool, optional): Availability, only used for JSON responses. Defaults to False. + + Returns: + A JSON response or a form validation error. + + Raises: + ValueError: If `return_type` is neither `FORM_VALIDATION_ERROR` nor `JSON_RESPONSE`. + """ # noqa + match return_type: + case ValidationReturnType.FORM_VALIDATION_ERROR: + raise forms.ValidationError(DOMAIN_API_MESSAGES[code], code=code) + case ValidationReturnType.JSON_RESPONSE: + return JsonResponse({"available": available, "code": code, "message": DOMAIN_API_MESSAGES[code]}) + case _: + raise ValueError("Invalid return type specified") + @classmethod def sld(cls, domain: str): """ diff --git a/src/registrar/templates/application_about_your_organization.html b/src/registrar/templates/application_about_your_organization.html index 0d384b4f5..02e2e2c4f 100644 --- a/src/registrar/templates/application_about_your_organization.html +++ b/src/registrar/templates/application_about_your_organization.html @@ -2,14 +2,16 @@ {% load field_helpers %} {% block form_instructions %} -
We’d like to know more about your organization. Include the following in your response:
+To help us determine your eligibility for a .gov domain, we need to know more about your organization. For example:
Is there anything else you'd like us to know about your domain request? This question is optional.
+This question is optional.
{% endblock %} {% block form_required_fields_help_text %} diff --git a/src/registrar/templates/application_authorizing_official.html b/src/registrar/templates/application_authorizing_official.html index 3e33ab34e..068457373 100644 --- a/src/registrar/templates/application_authorizing_official.html +++ b/src/registrar/templates/application_authorizing_official.html @@ -14,7 +14,7 @@ {% include "includes/ao_example.html" %} -We typically don’t reach out to the authorizing official, but if contact is necessary, our practice is to coordinate first with you, the requestor. Read more about who can serve as an authorizing official.
+We typically don’t reach out to the authorizing official, but if contact is necessary, our practice is to coordinate with you, the requestor, first.
{% endblock %} diff --git a/src/registrar/templates/application_current_sites.html b/src/registrar/templates/application_current_sites.html index 67343aee9..debadcfe2 100644 --- a/src/registrar/templates/application_current_sites.html +++ b/src/registrar/templates/application_current_sites.html @@ -2,9 +2,9 @@ {% load static field_helpers %} {% block form_instructions %} -Enter your organization’s current public website, if you have one. For example, - www.city.com. We can better evaluate your domain request if we know about domains -you’re already using. If you already have any .gov domains please include them. This question is optional.
+We can better evaluate your request if we know about domains you’re already using.
+Enter your organization’s current public websites. If you already have a .gov domain, include that in your list. This question is optional.
{% endblock %} {% block form_required_fields_help_text %} diff --git a/src/registrar/templates/application_dotgov_domain.html b/src/registrar/templates/application_dotgov_domain.html index bd3c4a473..1838f33f4 100644 --- a/src/registrar/templates/application_dotgov_domain.html +++ b/src/registrar/templates/application_dotgov_domain.html @@ -2,24 +2,22 @@ {% load static field_helpers url_helpers %} {% block form_instructions %} -Before requesting a .gov domain, please make sure it - meets our naming requirements. Your domain name must: +
Before requesting a .gov domain, please make sure it meets our naming requirements. Your domain name must:
Names that uniquely apply to your organization are likely to be approved over names that could also apply to other organizations. In most instances, this requires including your state’s two-letter abbreviation.
+ +Requests for your organization’s initials or an abbreviated name might not be approved, but we encourage you to request the name you want.
+Note that only federal agencies can request generic terms like vote.gov.
-We’ll try to give you the domain you want. We first need to make sure your request - meets our requirements. We’ll work with you to find the best domain for your - organization.
-After you enter your domain, we’ll make sure it’s - available and that it meets some of our naming requirements. If your domain passes - these initial checks, we’ll verify that it meets all of our requirements once you - complete and submit the rest of this form.
+After you enter your domain, we’ll make sure it’s available and that it meets some of our naming requirements. If your domain passes these initial checks, we’ll verify that it meets all our requirements after you complete the rest of this form.
{% with attr_aria_describedby="domain_instructions domain_instructions2" %} {# attr_validate / validate="domain" invokes code in get-gov.js #} @@ -53,6 +48,7 @@ {% endwith %} {% endwith %}