diff --git a/src/epplibwrapper/__init__.py b/src/epplibwrapper/__init__.py index d0138d73c..dd6664a3a 100644 --- a/src/epplibwrapper/__init__.py +++ b/src/epplibwrapper/__init__.py @@ -45,7 +45,7 @@ except NameError: # Attn: these imports should NOT be at the top of the file try: from .client import CLIENT, commands - from .errors import RegistryError, ErrorCode, CANNOT_CONTACT_REGISTRY, GENERIC_ERROR + from .errors import RegistryError, ErrorCode from epplib.models import common, info from epplib.responses import extensions from epplib import responses @@ -61,6 +61,4 @@ __all__ = [ "info", "ErrorCode", "RegistryError", - "CANNOT_CONTACT_REGISTRY", - "GENERIC_ERROR", ] diff --git a/src/epplibwrapper/errors.py b/src/epplibwrapper/errors.py index dba5f328c..d34ed5e91 100644 --- a/src/epplibwrapper/errors.py +++ b/src/epplibwrapper/errors.py @@ -1,8 +1,5 @@ from enum import IntEnum -CANNOT_CONTACT_REGISTRY = "Update failed. Cannot contact the registry." -GENERIC_ERROR = "Value entered was wrong." - class ErrorCode(IntEnum): """ diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 851de8fcf..1c678a4d6 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -231,41 +231,15 @@ function handleValidationClick(e) { /** - * An IIFE that attaches a click handler for our dynamic nameservers form - * - * Only does something on a single page, but it should be fast enough to run - * it everywhere. + * 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 + * */ -(function prepareNameserverForms() { - let serverForm = document.querySelectorAll(".server-form"); - let container = document.querySelector("#form-container"); - let addButton = document.querySelector("#add-nameserver-form"); - let totalForms = document.querySelector("#id_form-TOTAL_FORMS"); - - let formNum = serverForm.length-1; - if (addButton) - addButton.addEventListener('click', addForm); - - function addForm(e){ - let newForm = serverForm[2].cloneNode(true); - let formNumberRegex = RegExp(`form-(\\d){1}-`,'g'); - let formLabelRegex = RegExp(`Name server (\\d){1}`, 'g'); - let formExampleRegex = RegExp(`ns(\\d){1}`, 'g'); - - formNum++; - newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum}-`); - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `Name server ${formNum+1}`); - newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum+1}`); - container.insertBefore(newForm, addButton); - newForm.querySelector("input").value = ""; - - totalForms.setAttribute('value', `${formNum+1}`); - } -})(); - -function prepareDeleteButtons() { +function prepareDeleteButtons(formLabel) { let deleteButtons = document.querySelectorAll(".delete-record"); let totalForms = document.querySelector("#id_form-TOTAL_FORMS"); + let isNameserversForm = document.title.includes("DNS name servers |"); + let addButton = document.querySelector("#add-form"); // Loop through each delete button and attach the click event listener deleteButtons.forEach((deleteButton) => { @@ -273,13 +247,15 @@ function prepareDeleteButtons() { }); function removeForm(e){ - let formToRemove = e.target.closest(".ds-record"); + let formToRemove = e.target.closest(".repeatable-form"); formToRemove.remove(); - let forms = document.querySelectorAll(".ds-record"); + let forms = document.querySelectorAll(".repeatable-form"); totalForms.setAttribute('value', `${forms.length}`); let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g'); - let formLabelRegex = RegExp(`DS Data record (\\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 @@ -294,48 +270,88 @@ function prepareDeleteButtons() { }); }); - Array.from(form.querySelectorAll('h2, legend')).forEach((node) => { - node.textContent = node.textContent.replace(formLabelRegex, `DS Data record ${index + 1}`); + // h2 and legend for DS form, label for nameservers + Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => { + + // Ticket: 1192 + // if (isNameserversForm && index <= 1 && !node.innerHTML.includes('*')) { + // // Create a new element + // const newElement = document.createElement('abbr'); + // newElement.textContent = '*'; + // // TODO: finish building abbr + + // // Append the new element to the parent + // 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 + // console.log(nextInputElement); + // break; + // } + // nextInputElement = nextInputElement.nextElementSibling; + // } + // nextInputElement.required = true; + // } + + // Ticket: 1192 - remove if + if (!(isNameserversForm && index <= 1)) { + 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.classList.remove("display-none") + } }); } } /** - * An IIFE that attaches a click handler for our dynamic DNSSEC forms + * An IIFE that attaches a click handler for our dynamic formsets * + * Only does something on a few pages, but it should be fast enough to run + * it everywhere. */ -(function prepareDNSSECForms() { - let serverForm = document.querySelectorAll(".ds-record"); +(function prepareFormsetsForms() { + let repeatableForm = document.querySelectorAll(".repeatable-form"); let container = document.querySelector("#form-container"); - let addButton = document.querySelector("#add-ds-form"); + 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 |"); + if (isNameserversForm) { + cloneIndex = 2; + formLabel = "Name server"; + } else if ((document.title.includes("DS Data |")) || (document.title.includes("Key Data |"))) { + formLabel = "DS Data record"; + } // Attach click event listener on the delete buttons of the existing forms - prepareDeleteButtons(); + prepareDeleteButtons(formLabel); - // Attack click event listener on the add button if (addButton) addButton.addEventListener('click', addForm); - /* - * Add a formset to the end of the form. - * For each element in the added formset, name the elements with the prefix, - * form-{#}-{element_name} where # is the index of the formset and element_name - * is the element's name. - * Additionally, update the form element's metadata, including totalForms' value. - */ function addForm(e){ - let forms = document.querySelectorAll(".ds-record"); + let forms = document.querySelectorAll(".repeatable-form"); let formNum = forms.length; - let newForm = serverForm[0].cloneNode(true); + let newForm = repeatableForm[cloneIndex].cloneNode(true); let formNumberRegex = RegExp(`form-(\\d){1}-`,'g'); - let formLabelRegex = RegExp(`DS Data record (\\d){1}`, 'g'); + let formLabelRegex = RegExp(`${formLabel} (\\d){1}`, 'g'); + // For the eample on Nameservers + let formExampleRegex = RegExp(`ns(\\d){1}`, 'g'); formNum++; newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum-1}-`); - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `DS Data record ${formNum}`); + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); + newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`); container.insertBefore(newForm, addButton); let inputs = newForm.querySelectorAll("input"); @@ -379,9 +395,13 @@ function prepareDeleteButtons() { totalForms.setAttribute('value', `${formNum}`); // Attach click event listener on the delete buttons of the new form - prepareDeleteButtons(); - } + prepareDeleteButtons(formLabel); + // Hide the add more button if we have 13 forms + if (isNameserversForm && formNum == 13) { + addButton.classList.add("display-none") + } + } })(); /** diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index 38b42c3d0..d0bfbee67 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -4,6 +4,10 @@ margin-top: units(3); } +.usa-form .usa-button.margin-bottom-075 { + margin-bottom: units(1.5); +} + .usa-form .usa-button.margin-top-1 { margin-top: units(1); } diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 6bbade5ef..3aca7af6d 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -5,8 +5,12 @@ from django.core.validators import MinValueValidator, MaxValueValidator, RegexVa from django.forms import formset_factory from phonenumber_field.widgets import RegionalPhoneNumberWidget +from registrar.utility.errors import ( + NameserverError, + NameserverErrorCodes as nsErrorCodes, +) -from ..models import Contact, DomainInformation +from ..models import Contact, DomainInformation, Domain from .common import ( ALGORITHM_CHOICES, DIGEST_TYPE_CHOICES, @@ -19,16 +23,78 @@ class DomainAddUserForm(forms.Form): email = forms.EmailField(label="Email") +class IPAddressField(forms.CharField): + def validate(self, value): + super().validate(value) # Run the default CharField validation + + class DomainNameserverForm(forms.Form): """Form for changing nameservers.""" + domain = forms.CharField(widget=forms.HiddenInput, required=False) + server = forms.CharField(label="Name server", strip=True) - # when adding IPs to this form ensure they are stripped as well + + ip = forms.CharField(label="IP Address (IPv4 or IPv6)", strip=True, required=False) + + def clean(self): + # clean is called from clean_forms, which is called from is_valid + # after clean_fields. it is used to determine form level errors. + # is_valid is typically called from view during a post + cleaned_data = super().clean() + self.clean_empty_strings(cleaned_data) + server = cleaned_data.get("server", "") + ip = cleaned_data.get("ip", None) + # remove ANY spaces in the ip field + ip = ip.replace(" ", "") + domain = cleaned_data.get("domain", "") + + ip_list = self.extract_ip_list(ip) + + if ip and not server and ip_list: + self.add_error("server", NameserverError(code=nsErrorCodes.MISSING_HOST)) + elif server: + self.validate_nameserver_ip_combo(domain, server, ip_list) + + return cleaned_data + + def clean_empty_strings(self, cleaned_data): + ip = cleaned_data.get("ip", "") + if ip and len(ip.strip()) == 0: + cleaned_data["ip"] = None + + def extract_ip_list(self, ip): + return [ip.strip() for ip in ip.split(",")] if ip else [] + + def validate_nameserver_ip_combo(self, domain, server, ip_list): + try: + Domain.checkHostIPCombo(domain, server, ip_list) + except NameserverError as e: + if e.code == nsErrorCodes.GLUE_RECORD_NOT_ALLOWED: + self.add_error( + "server", + NameserverError( + code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED, + nameserver=domain, + ip=ip_list, + ), + ) + elif e.code == nsErrorCodes.MISSING_IP: + self.add_error( + "ip", + NameserverError( + code=nsErrorCodes.MISSING_IP, nameserver=domain, ip=ip_list + ), + ) + else: + self.add_error("ip", str(e)) NameserverFormset = formset_factory( DomainNameserverForm, extra=1, + max_num=13, + validate_max=True, ) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 12cb8b5db..07e49dfdd 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -262,8 +262,11 @@ class Domain(TimeStampedModel, DomainHelper): """Creates the host object in the registry doesn't add the created host to the domain returns ErrorCode (int)""" - if addrs is not None: - addresses = [epp.Ip(addr=addr) for addr in addrs] + if addrs is not None and addrs != []: + addresses = [ + epp.Ip(addr=addr, ip="v6" if self.is_ipv6(addr) else None) + for addr in addrs + ] request = commands.CreateHost(name=host, addrs=addresses) else: request = commands.CreateHost(name=host) @@ -274,7 +277,7 @@ class Domain(TimeStampedModel, DomainHelper): return response.code except RegistryError as e: logger.error("Error _create_host, code was %s error was %s" % (e.code, e)) - return e.code + raise e def _convert_list_to_dict(self, listToConvert: list[tuple[str, list]]): """converts a list of hosts into a dictionary @@ -293,14 +296,16 @@ class Domain(TimeStampedModel, DomainHelper): newDict[tup[0]] = tup[1] return newDict - def isSubdomain(self, nameserver: str): + @classmethod + def isSubdomain(cls, name: str, nameserver: str): """Returns boolean if the domain name is found in the argument passed""" subdomain_pattern = r"([\w-]+\.)*" - full_pattern = subdomain_pattern + self.name + full_pattern = subdomain_pattern + name regex = re.compile(full_pattern) return bool(regex.match(nameserver)) - def checkHostIPCombo(self, nameserver: str, ip: list[str]): + @classmethod + def checkHostIPCombo(cls, name: str, nameserver: str, ip: list[str]): """Checks the parameters past for a valid combination raises error if: - nameserver is a subdomain but is missing ip @@ -314,22 +319,23 @@ class Domain(TimeStampedModel, DomainHelper): NameserverError (if exception hit) Returns: None""" - if self.isSubdomain(nameserver) and (ip is None or ip == []): + if cls.isSubdomain(name, nameserver) and (ip is None or ip == []): raise NameserverError(code=nsErrorCodes.MISSING_IP, nameserver=nameserver) - elif not self.isSubdomain(nameserver) and (ip is not None and ip != []): + elif not cls.isSubdomain(name, nameserver) and (ip is not None and ip != []): raise NameserverError( code=nsErrorCodes.GLUE_RECORD_NOT_ALLOWED, nameserver=nameserver, ip=ip ) elif ip is not None and ip != []: for addr in ip: - if not self._valid_ip_addr(addr): + if not cls._valid_ip_addr(addr): raise NameserverError( code=nsErrorCodes.INVALID_IP, nameserver=nameserver, ip=ip ) return None - def _valid_ip_addr(self, ipToTest: str): + @classmethod + def _valid_ip_addr(cls, ipToTest: str): """returns boolean if valid ip address string We currently only accept v4 or v6 ips returns: @@ -382,7 +388,9 @@ class Domain(TimeStampedModel, DomainHelper): if newHostDict[prevHost] is not None and set( newHostDict[prevHost] ) != set(addrs): - self.checkHostIPCombo(nameserver=prevHost, ip=newHostDict[prevHost]) + self.__class__.checkHostIPCombo( + name=self.name, nameserver=prevHost, ip=newHostDict[prevHost] + ) updated_values.append((prevHost, newHostDict[prevHost])) new_values = { @@ -392,7 +400,9 @@ class Domain(TimeStampedModel, DomainHelper): } for nameserver, ip in new_values.items(): - self.checkHostIPCombo(nameserver=nameserver, ip=ip) + self.__class__.checkHostIPCombo( + name=self.name, nameserver=nameserver, ip=ip + ) return (deleted_values, updated_values, new_values, previousHostDict) @@ -567,7 +577,11 @@ class Domain(TimeStampedModel, DomainHelper): if len(hosts) > 13: raise NameserverError(code=nsErrorCodes.TOO_MANY_HOSTS) - if self.state not in [self.State.DNS_NEEDED, self.State.READY]: + if self.state not in [ + self.State.DNS_NEEDED, + self.State.READY, + self.State.UNKNOWN, + ]: raise ActionNotAllowed("Nameservers can not be " "set in the current state") logger.info("Setting nameservers") @@ -1351,7 +1365,7 @@ class Domain(TimeStampedModel, DomainHelper): @transition( field="state", - source=[State.DNS_NEEDED], + source=[State.DNS_NEEDED, State.READY], target=State.READY, # conditions=[dns_not_needed] ) @@ -1514,7 +1528,7 @@ class Domain(TimeStampedModel, DomainHelper): data = registry.send(req, cleaned=True).res_data[0] host = { "name": name, - "addrs": getattr(data, "addrs", ...), + "addrs": [item.addr for item in getattr(data, "addrs", [])], "cr_date": getattr(data, "cr_date", ...), "statuses": getattr(data, "statuses", ...), "tr_date": getattr(data, "tr_date", ...), @@ -1539,10 +1553,9 @@ class Domain(TimeStampedModel, DomainHelper): return [] for ip_addr in ip_list: - if self.is_ipv6(ip_addr): - edited_ip_list.append(epp.Ip(addr=ip_addr, ip="v6")) - else: # default ip addr is v4 - edited_ip_list.append(epp.Ip(addr=ip_addr)) + edited_ip_list.append( + epp.Ip(addr=ip_addr, ip="v6" if self.is_ipv6(ip_addr) else None) + ) return edited_ip_list @@ -1580,7 +1593,7 @@ class Domain(TimeStampedModel, DomainHelper): return response.code except RegistryError as e: logger.error("Error _update_host, code was %s error was %s" % (e.code, e)) - return e.code + raise e def addAndRemoveHostsFromDomain( self, hostsToAdd: list[str], hostsToDelete: list[str] diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index bdf4deb46..927628b11 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -29,7 +29,7 @@ {{ formset.management_form }} {% for form in formset %} -