Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/3363-uat-1-bug-fixes

This commit is contained in:
Rebecca Hsieh 2025-01-22 15:50:02 -08:00
commit 94c99515f5
No known key found for this signature in database
17 changed files with 320 additions and 226 deletions

View file

@ -29,6 +29,7 @@
* - tooltip dynamic content updated to include nested element (for better sizing control)
* - modal exposed to window to be accessible in other js files
* - fixed bug in createHeaderButton which added newlines to header button tooltips
* - modified combobox to handle error class
*/
if ("document" in window.self) {
@ -1213,6 +1214,11 @@ const enhanceComboBox = _comboBoxEl => {
input.setAttribute("class", INPUT_CLASS);
input.setAttribute("type", "text");
input.setAttribute("role", "combobox");
// DOTGOV - handle error class for combobox
// Check if 'usa-input--error' exists in selectEl and add it to input if true
if (selectEl.classList.contains('usa-input--error')) {
input.classList.add('usa-input--error');
}
additionalAttributes.forEach(attr => Object.keys(attr).forEach(key => {
const value = Sanitizer.escapeHTML`${attr[key]}`;
input.setAttribute(key, value);

View file

@ -1,113 +0,0 @@
import { hideElement, showElement } from './helpers.js';
export function loadInitialValuesForComboBoxes() {
var overrideDefaultClearButton = true;
var isTyping = false;
document.addEventListener('DOMContentLoaded', (event) => {
handleAllComboBoxElements();
});
function handleAllComboBoxElements() {
const comboBoxElements = document.querySelectorAll(".usa-combo-box");
comboBoxElements.forEach(comboBox => {
const input = comboBox.querySelector("input");
const select = comboBox.querySelector("select");
if (!input || !select) {
console.warn("No combobox element found");
return;
}
// Set the initial value of the combobox
let initialValue = select.getAttribute("data-default-value");
let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input");
if (!clearInputButton) {
console.warn("No clear element found");
return;
}
// Override the default clear button behavior such that it no longer clears the input,
// it just resets to the data-initial-value.
// Due to the nature of how uswds works, this is slightly hacky.
// Use a MutationObserver to watch for changes in the dropdown list
const dropdownList = comboBox.querySelector(`#${input.id}--list`);
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === "childList") {
addBlankOption(clearInputButton, dropdownList, initialValue);
}
});
});
// Configure the observer to watch for changes in the dropdown list
const config = { childList: true, subtree: true };
observer.observe(dropdownList, config);
// Input event listener to detect typing
input.addEventListener("input", () => {
isTyping = true;
});
// Blur event listener to reset typing state
input.addEventListener("blur", () => {
isTyping = false;
});
// Hide the reset button when there is nothing to reset.
// Do this once on init, then everytime a change occurs.
updateClearButtonVisibility(select, initialValue, clearInputButton)
select.addEventListener("change", () => {
updateClearButtonVisibility(select, initialValue, clearInputButton)
});
// Change the default input behaviour - have it reset to the data default instead
clearInputButton.addEventListener("click", (e) => {
if (overrideDefaultClearButton && initialValue) {
e.preventDefault();
e.stopPropagation();
input.click();
// Find the dropdown option with the desired value
const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option");
if (dropdownOptions) {
dropdownOptions.forEach(option => {
if (option.getAttribute("data-value") === initialValue) {
// Simulate a click event on the dropdown option
option.click();
}
});
}
}
});
});
}
function updateClearButtonVisibility(select, initialValue, clearInputButton) {
if (select.value === initialValue) {
hideElement(clearInputButton);
}else {
showElement(clearInputButton)
}
}
function addBlankOption(clearInputButton, dropdownList, initialValue) {
if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) {
const blankOption = document.createElement("li");
blankOption.setAttribute("role", "option");
blankOption.setAttribute("data-value", "");
blankOption.classList.add("usa-combo-box__list-option");
if (!initialValue){
blankOption.classList.add("usa-combo-box__list-option--selected")
}
blankOption.textContent = "⎯";
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
blankOption.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
overrideDefaultClearButton = false;
// Trigger the default clear behavior
clearInputButton.click();
overrideDefaultClearButton = true;
});
}
}
}

View file

@ -3,7 +3,6 @@ import { initDomainValidators } from './domain-validators.js';
import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js';
import { initializeUrbanizationToggle } from './urbanization.js';
import { userProfileListener, finishUserSetupListener } from './user-profile.js';
import { loadInitialValuesForComboBoxes } from './combobox.js';
import { handleRequestingEntityFieldset } from './requesting-entity.js';
import { initDomainsTable } from './table-domains.js';
import { initDomainRequestsTable } from './table-domain-requests.js';
@ -31,8 +30,6 @@ initializeUrbanizationToggle();
userProfileListener();
finishUserSetupListener();
loadInitialValuesForComboBoxes();
handleRequestingEntityFieldset();
initDomainsTable();

View file

@ -9,15 +9,15 @@ export function handleRequestingEntityFieldset() {
const formPrefix = "portfolio_requesting_entity";
const radioFieldset = document.getElementById(`id_${formPrefix}-requesting_entity_is_suborganization__fieldset`);
const radios = radioFieldset?.querySelectorAll(`input[name="${formPrefix}-requesting_entity_is_suborganization"]`);
const select = document.getElementById(`id_${formPrefix}-sub_organization`);
const selectParent = select?.parentElement;
const input = document.getElementById(`id_${formPrefix}-sub_organization`);
const inputGrandParent = input?.parentElement?.parentElement;
const select = input?.previousElementSibling;
const suborgContainer = document.getElementById("suborganization-container");
const suborgDetailsContainer = document.getElementById("suborganization-container__details");
const suborgAddtlInstruction = document.getElementById("suborganization-addtl-instruction");
const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value;
// Make sure all crucial page elements exist before proceeding.
// This more or less ensures that we are on the Requesting Entity page, and not elsewhere.
if (!radios || !select || !selectParent || !suborgContainer || !suborgDetailsContainer) return;
if (!radios || !input || !select || !inputGrandParent || !suborgContainer || !suborgDetailsContainer) return;
// requestingSuborganization: This just broadly determines if they're requesting a suborg at all
// requestingNewSuborganization: This variable determines if the user is trying to *create* a new suborganization or not.
@ -27,8 +27,8 @@ export function handleRequestingEntityFieldset() {
function toggleSuborganization(radio=null) {
if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True";
requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer);
if (select.options.length == 2) { // --Select-- and other are the only options
hideElement(selectParent); // Hide the select drop down and indicate requesting new suborg
if (select.options.length == 1) { // other is the only option
hideElement(inputGrandParent); // Hide the combo box and indicate requesting new suborg
hideElement(suborgAddtlInstruction); // Hide additional instruction related to the list
requestingNewSuborganization.value = "True";
} else {
@ -37,11 +37,6 @@ export function handleRequestingEntityFieldset() {
requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer);
}
// Add fake "other" option to sub_organization select
if (select && !Array.from(select.options).some(option => option.value === "other")) {
select.add(new Option(subOrgCreateNewOption, "other"));
}
if (requestingNewSuborganization.value === "True") {
select.value = "other";
}

View file

@ -4,6 +4,7 @@ import logging
from django import forms
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
from django.forms import formset_factory
from registrar.forms.utility.combobox import ComboboxWidget
from registrar.models import DomainRequest, FederalAgency
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from registrar.models.suborganization import Suborganization
@ -161,9 +162,10 @@ class DomainSuborganizationForm(forms.ModelForm):
"""Form for updating the suborganization"""
sub_organization = forms.ModelChoiceField(
label="Suborganization name",
queryset=Suborganization.objects.none(),
required=False,
widget=forms.Select(),
widget=ComboboxWidget,
)
class Meta:
@ -178,20 +180,6 @@ class DomainSuborganizationForm(forms.ModelForm):
portfolio = self.instance.portfolio if self.instance else None
self.fields["sub_organization"].queryset = Suborganization.objects.filter(portfolio=portfolio)
# Set initial value
if self.instance and self.instance.sub_organization:
self.fields["sub_organization"].initial = self.instance.sub_organization
# Set custom form label
self.fields["sub_organization"].label = "Suborganization name"
# Use the combobox rather than the regular select widget
self.fields["sub_organization"].widget.template_name = "django/forms/widgets/combobox.html"
# Set data-default-value attribute
if self.instance and self.instance.sub_organization:
self.fields["sub_organization"].widget.attrs["data-default-value"] = self.instance.sub_organization.pk
class BaseNameserverFormset(forms.BaseFormSet):
def clean(self):
@ -456,6 +444,13 @@ class DomainSecurityEmailForm(forms.Form):
class DomainOrgNameAddressForm(forms.ModelForm):
"""Form for updating the organization name and mailing address."""
# for federal agencies we also want to know the top-level agency.
federal_agency = forms.ModelChoiceField(
label="Federal agency",
required=False,
queryset=FederalAgency.objects.all(),
widget=ComboboxWidget,
)
zipcode = forms.CharField(
label="Zip code",
validators=[
@ -469,6 +464,16 @@ class DomainOrgNameAddressForm(forms.ModelForm):
},
)
state_territory = forms.ChoiceField(
label="State, territory, or military post",
required=True,
choices=DomainInformation.StateTerritoryChoices.choices,
error_messages={
"required": ("Select the state, territory, or military post where your organization is located.")
},
widget=ComboboxWidget(attrs={"required": True}),
)
class Meta:
model = DomainInformation
fields = [
@ -486,25 +491,12 @@ class DomainOrgNameAddressForm(forms.ModelForm):
"organization_name": {"required": "Enter the name of your organization."},
"address_line1": {"required": "Enter the street address of your organization."},
"city": {"required": "Enter the city where your organization is located."},
"state_territory": {
"required": "Select the state, territory, or military post where your organization is located."
},
}
widgets = {
# We need to set the required attributed for State/territory
# because for this fields we are creating an individual
# instance of the Select. For the other fields we use the for loop to set
# the class's required attribute to true.
"organization_name": forms.TextInput,
"address_line1": forms.TextInput,
"address_line2": forms.TextInput,
"city": forms.TextInput,
"state_territory": forms.Select(
attrs={
"required": True,
},
choices=DomainInformation.StateTerritoryChoices.choices,
),
"urbanization": forms.TextInput,
}

View file

@ -7,6 +7,7 @@ from django import forms
from django.core.validators import RegexValidator, MaxLengthValidator
from django.utils.safestring import mark_safe
from registrar.forms.utility.combobox import ComboboxWidget
from registrar.forms.utility.wizard_form_helper import (
RegistrarForm,
RegistrarFormSet,
@ -43,7 +44,7 @@ class RequestingEntityForm(RegistrarForm):
label="Suborganization name",
required=False,
queryset=Suborganization.objects.none(),
empty_label="--Select--",
widget=ComboboxWidget,
)
requested_suborganization = forms.CharField(
label="Requested suborganization",
@ -56,22 +57,44 @@ class RequestingEntityForm(RegistrarForm):
suborganization_state_territory = forms.ChoiceField(
label="State, territory, or military post",
required=False,
choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices,
choices=DomainRequest.StateTerritoryChoices.choices,
widget=ComboboxWidget,
)
def __init__(self, *args, **kwargs):
"""Override of init to add the suborganization queryset"""
"""Override of init to add the suborganization queryset and 'other' option"""
super().__init__(*args, **kwargs)
if self.domain_request.portfolio:
self.fields["sub_organization"].queryset = Suborganization.objects.filter(
portfolio=self.domain_request.portfolio
)
# Fetch the queryset for the portfolio
queryset = Suborganization.objects.filter(portfolio=self.domain_request.portfolio)
# set the queryset appropriately so that post can validate against queryset
self.fields["sub_organization"].queryset = queryset
# Modify the choices to include "other" so that form can display options properly
self.fields["sub_organization"].choices = [(obj.id, str(obj)) for obj in queryset] + [
("other", "Other (enter your suborganization manually)")
]
@classmethod
def from_database(cls, obj: DomainRequest | Contact | None):
"""Returns a dict of form field values gotten from `obj`.
Overrides RegistrarForm method in order to set sub_organization to 'other'
on GETs of the RequestingEntityForm."""
if obj is None:
return {}
# get the domain request as a dict, per usual method
domain_request_dict = {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore
# set sub_organization to 'other' if is_requesting_new_suborganization is True
if isinstance(obj, DomainRequest) and obj.is_requesting_new_suborganization():
domain_request_dict["sub_organization"] = "other"
return domain_request_dict
def clean_sub_organization(self):
"""On suborganization clean, set the suborganization value to None if the user is requesting
a custom suborganization (as it doesn't exist yet)"""
# If it's a new suborganization, return None (equivalent to selecting nothing)
if self.cleaned_data.get("is_requesting_new_suborganization"):
return None
@ -94,41 +117,60 @@ class RequestingEntityForm(RegistrarForm):
return name
def full_clean(self):
"""Validation logic to remove the custom suborganization value before clean is triggered.
"""Validation logic to temporarily remove the custom suborganization value before clean is triggered.
Without this override, the form will throw an 'invalid option' error."""
# Remove the custom other field before cleaning
data = self.data.copy() if self.data else None
# Ensure self.data is not None before proceeding
if self.data:
# handle case where form has been submitted
# Create a copy of the data for manipulation
data = self.data.copy()
# Remove the 'other' value from suborganization if it exists.
# This is a special value that tracks if the user is requesting a new suborg.
suborganization = self.data.get("portfolio_requesting_entity-sub_organization")
if suborganization and "other" in suborganization:
data["portfolio_requesting_entity-sub_organization"] = ""
# Retrieve sub_organization and store in _original_suborganization
suborganization = data.get("portfolio_requesting_entity-sub_organization")
self._original_suborganization = suborganization
# If the original value was "other", clear it for validation
if self._original_suborganization == "other":
data["portfolio_requesting_entity-sub_organization"] = ""
# Set the modified data back to the form
self.data = data
# Set the modified data back to the form
self.data = data
else:
# handle case of a GET
suborganization = None
if self.initial and "sub_organization" in self.initial:
suborganization = self.initial["sub_organization"]
# Check if is_requesting_new_suborganization is True
is_requesting_new_suborganization = False
if self.initial and "is_requesting_new_suborganization" in self.initial:
# Call the method if it exists
is_requesting_new_suborganization = self.initial["is_requesting_new_suborganization"]()
# Determine if "other" should be set
if is_requesting_new_suborganization and suborganization is None:
self._original_suborganization = "other"
else:
self._original_suborganization = suborganization
# Call the parent's full_clean method
super().full_clean()
# Restore "other" if there are errors
if self.errors:
self.data["portfolio_requesting_entity-sub_organization"] = self._original_suborganization
def clean(self):
"""Custom clean implementation to handle our desired logic flow for suborganization.
Given that these fields often rely on eachother, we need to do this in the parent function."""
"""Custom clean implementation to handle our desired logic flow for suborganization."""
cleaned_data = super().clean()
# Do some custom error validation if the requesting entity is a suborg.
# Otherwise, just validate as normal.
suborganization = self.cleaned_data.get("sub_organization")
is_requesting_new_suborganization = self.cleaned_data.get("is_requesting_new_suborganization")
# Get the value of the yes/no checkbox from RequestingEntityYesNoForm.
# Since self.data stores this as a string, we need to convert "True" => True.
# Get the cleaned data
suborganization = cleaned_data.get("sub_organization")
is_requesting_new_suborganization = cleaned_data.get("is_requesting_new_suborganization")
requesting_entity_is_suborganization = self.data.get(
"portfolio_requesting_entity-requesting_entity_is_suborganization"
)
if requesting_entity_is_suborganization == "True":
if is_requesting_new_suborganization:
# Validate custom suborganization fields
if not cleaned_data.get("requested_suborganization") and "requested_suborganization" not in self.errors:
self.add_error("requested_suborganization", "Enter the name of your suborganization.")
if not cleaned_data.get("suborganization_city"):
@ -141,6 +183,12 @@ class RequestingEntityForm(RegistrarForm):
elif not suborganization:
self.add_error("sub_organization", "Suborganization is required.")
# If there are errors, restore the "other" value for rendering
if self.errors and getattr(self, "_original_suborganization", None) == "other":
self.cleaned_data["sub_organization"] = self._original_suborganization
elif not self.data and getattr(self, "_original_suborganization", None) == "other":
self.cleaned_data["sub_organization"] = self._original_suborganization
return cleaned_data
@ -274,7 +322,7 @@ class OrganizationContactForm(RegistrarForm):
# uncomment to see if modelChoiceField can be an arg later
required=False,
queryset=FederalAgency.objects.exclude(agency__in=excluded_agencies),
empty_label="--Select--",
widget=ComboboxWidget,
)
organization_name = forms.CharField(
label="Organization name",
@ -294,10 +342,11 @@ class OrganizationContactForm(RegistrarForm):
)
state_territory = forms.ChoiceField(
label="State, territory, or military post",
choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices,
choices=DomainRequest.StateTerritoryChoices.choices,
error_messages={
"required": ("Select the state, territory, or military post where your organization is located.")
},
widget=ComboboxWidget,
)
zipcode = forms.CharField(
label="Zip code",

View file

@ -6,6 +6,7 @@ from django.core.validators import RegexValidator
from django.core.validators import MaxLengthValidator
from django.utils.safestring import mark_safe
from registrar.forms.utility.combobox import ComboboxWidget
from registrar.models import (
PortfolioInvitation,
UserPortfolioPermission,
@ -33,6 +34,15 @@ class PortfolioOrgAddressForm(forms.ModelForm):
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
},
)
state_territory = forms.ChoiceField(
label="State, territory, or military post",
required=True,
choices=DomainInformation.StateTerritoryChoices.choices,
error_messages={
"required": ("Select the state, territory, or military post where your organization is located.")
},
widget=ComboboxWidget(attrs={"required": True}),
)
class Meta:
model = Portfolio
@ -47,25 +57,12 @@ class PortfolioOrgAddressForm(forms.ModelForm):
error_messages = {
"address_line1": {"required": "Enter the street address of your organization."},
"city": {"required": "Enter the city where your organization is located."},
"state_territory": {
"required": "Select the state, territory, or military post where your organization is located."
},
"zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."},
}
widgets = {
# We need to set the required attributed for State/territory
# because for this fields we are creating an individual
# instance of the Select. For the other fields we use the for loop to set
# the class's required attribute to true.
"address_line1": forms.TextInput,
"address_line2": forms.TextInput,
"city": forms.TextInput,
"state_territory": forms.Select(
attrs={
"required": True,
},
choices=DomainInformation.StateTerritoryChoices.choices,
),
# "urbanization": forms.TextInput,
}

View file

@ -0,0 +1,5 @@
from django.forms import Select
class ComboboxWidget(Select):
template_name = "django/forms/widgets/combobox.html"

View file

@ -4,9 +4,9 @@ import ipaddress
import re
from datetime import date, timedelta
from typing import Optional
from django.db import transaction
from django_fsm import FSMField, transition, TransitionNotAllowed # type: ignore
from django.db import models
from django.db import models, IntegrityError
from django.utils import timezone
from typing import Any
from registrar.models.host import Host
@ -1329,14 +1329,14 @@ class Domain(TimeStampedModel, DomainHelper):
def get_default_administrative_contact(self):
"""Gets the default administrative contact."""
logger.info("get_default_security_contact() -> Adding administrative security contact")
logger.info("get_default_administrative_contact() -> Adding default administrative contact")
contact = PublicContact.get_default_administrative()
contact.domain = self
return contact
def get_default_technical_contact(self):
"""Gets the default technical contact."""
logger.info("get_default_security_contact() -> Adding technical security contact")
logger.info("get_default_security_contact() -> Adding default technical contact")
contact = PublicContact.get_default_technical()
contact.domain = self
return contact
@ -1678,9 +1678,11 @@ class Domain(TimeStampedModel, DomainHelper):
for domainContact in contact_data:
req = commands.InfoContact(id=domainContact.contact)
data = registry.send(req, cleaned=True).res_data[0]
logger.info(f"_fetch_contacts => this is the data: {data}")
# Map the object we recieved from EPP to a PublicContact
mapped_object = self.map_epp_contact_to_public_contact(data, domainContact.contact, domainContact.type)
logger.info(f"_fetch_contacts => mapped_object: {mapped_object}")
# Find/create it in the DB
in_db = self._get_or_create_public_contact(mapped_object)
@ -1871,8 +1873,9 @@ class Domain(TimeStampedModel, DomainHelper):
missingSecurity = True
missingTech = True
if len(cleaned.get("_contacts")) < 3:
for contact in cleaned.get("_contacts"):
contacts = cleaned.get("_contacts", [])
if len(contacts) < 3:
for contact in contacts:
if contact.type == PublicContact.ContactTypeChoices.ADMINISTRATIVE:
missingAdmin = False
if contact.type == PublicContact.ContactTypeChoices.SECURITY:
@ -1891,6 +1894,11 @@ class Domain(TimeStampedModel, DomainHelper):
technical_contact = self.get_default_technical_contact()
technical_contact.save()
logger.info(
"_add_missing_contacts_if_unknown => Adding contacts. Values are "
f"missingAdmin: {missingAdmin}, missingSecurity: {missingSecurity}, missingTech: {missingTech}"
)
def _fetch_cache(self, fetch_hosts=False, fetch_contacts=False):
"""Contact registry for info about a domain."""
try:
@ -2104,8 +2112,21 @@ class Domain(TimeStampedModel, DomainHelper):
# Save to DB if it doesn't exist already.
if db_contact.count() == 0:
# Doesn't run custom save logic, just saves to DB
public_contact.save(skip_epp_save=True)
logger.info(f"Created a new PublicContact: {public_contact}")
try:
with transaction.atomic():
public_contact.save(skip_epp_save=True)
logger.info(f"Created a new PublicContact: {public_contact}")
except IntegrityError as err:
logger.error(
f"_get_or_create_public_contact() => tried to create a duplicate public contact: {err}",
exc_info=True,
)
return PublicContact.objects.get(
registry_id=public_contact.registry_id,
contact_type=public_contact.contact_type,
domain=self,
)
# Append the item we just created
return public_contact
@ -2115,7 +2136,7 @@ class Domain(TimeStampedModel, DomainHelper):
if existing_contact.email != public_contact.email or existing_contact.registry_id != public_contact.registry_id:
existing_contact.delete()
public_contact.save()
logger.warning("Requested PublicContact is out of sync " "with DB.")
logger.warning("Requested PublicContact is out of sync with DB.")
return public_contact
# If it already exists, we can assume that the DB instance was updated during set, so we should just use that.

View file

@ -11,6 +11,7 @@ for now we just carry the attribute to both the parent element and the select.
{{ name }}="{{ value }}"
{% endif %}
{% endfor %}
data-default-value="{% for group_name, group_choices, group_index in widget.optgroups %}{% for option in group_choices %}{% if option.selected %}{{ option.value }}{% endif %}{% endfor %}{% endfor %}"
>
{% include "django/forms/widgets/select.html" %}
{% include "django/forms/widgets/select.html" with is_combobox=True %}
</div>

View file

@ -3,6 +3,9 @@
{# hint: spacing in the class string matters #}
class="usa-select{% if classes %} {{ classes }}{% endif %}"
{% include "django/forms/widgets/attrs.html" %}
{% if is_combobox %}
data-default-value="{% for group_name, group_choices, group_index in widget.optgroups %}{% for option in group_choices %}{% if option.selected %}{{ option.value }}{% endif %}{% endfor %}{% endfor %}"
{% endif %}
>
{% for group, options, index in widget.optgroups %}
{% if group %}<optgroup label="{{ group }}">{% endif %}

View file

@ -578,6 +578,13 @@ class MockDb(TestCase):
creator=cls.custom_superuser, federal_agency=cls.federal_agency_3, organization_type="federal"
)
cls.suborganization_1, _ = Suborganization.objects.get_or_create(
name="SubOrg 1",
portfolio=cls.portfolio_1,
city="Nashville",
state_territory="TN",
)
current_date = get_time_aware_date(datetime(2024, 4, 2))
# Create start and end dates using timedelta
@ -848,6 +855,7 @@ class MockDb(TestCase):
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
name="city2.gov",
portfolio=cls.portfolio_1,
sub_organization=cls.suborganization_1,
)
cls.domain_request_3 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
@ -863,6 +871,9 @@ class MockDb(TestCase):
cls.domain_request_5 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.APPROVED,
name="city5.gov",
requested_suborganization="requested_suborg",
suborganization_city="SanFran",
suborganization_state_territory="CA",
)
cls.domain_request_6 = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,

View file

@ -2101,6 +2101,10 @@ class TestPatchSuborganizations(MockDbForIndividualTests):
1. Fewest spaces
2. Most leading capitals
"""
# Delete any other suborganizations defined in the initial test dataset
DomainRequest.objects.all().delete()
Suborganization.objects.all().delete()
Suborganization.objects.create(name="Test Organization ", portfolio=self.portfolio_1)
Suborganization.objects.create(name="test organization", portfolio=self.portfolio_1)
Suborganization.objects.create(name="Test Organization", portfolio=self.portfolio_1)
@ -2114,6 +2118,10 @@ class TestPatchSuborganizations(MockDbForIndividualTests):
@less_console_noise_decorator
def test_hardcoded_record(self):
"""Tests that our hardcoded records update as we expect them to"""
# Delete any other suborganizations defined in the initial test dataset
DomainRequest.objects.all().delete()
Suborganization.objects.all().delete()
# Create orgs with old and new name formats
old_name = "USDA/OC"
new_name = "USDA, Office of Communications"
@ -2123,7 +2131,7 @@ class TestPatchSuborganizations(MockDbForIndividualTests):
self.run_patch_suborganizations()
# Verify only the new one remains
# Verify only the new one of the two remains
self.assertEqual(Suborganization.objects.count(), 1)
remaining = Suborganization.objects.first()
self.assertEqual(remaining.name, new_name)

View file

@ -349,6 +349,70 @@ class TestDomainCache(MockEppLib):
class TestDomainCreation(MockEppLib):
"""Rule: An approved domain request must result in a domain"""
@less_console_noise_decorator
def test_get_or_create_public_contact_race_condition(self):
"""
Scenario: Two processes try to create the same security contact simultaneously
Given a domain in UNKNOWN state
When a race condition occurs during contact creation
Then no IntegrityError is raised
And only one security contact exists in database
And the correct public contact is returned
CONTEXT: We ran into an intermittent but somewhat rare issue where IntegrityError
was raised when creating PublicContact.
Per our logs, this seemed to appear during periods of high app activity.
"""
domain, _ = Domain.objects.get_or_create(name="defaultsecurity.gov")
self.first_call = True
def mock_filter(*args, **kwargs):
"""Simulates a race condition by creating a
duplicate contact between the first filter and save.
"""
# Return an empty queryset for the first call. Otherwise just proceed as normal.
if self.first_call:
self.first_call = False
duplicate = PublicContact(
domain=domain,
contact_type=PublicContact.ContactTypeChoices.SECURITY,
registry_id="defaultSec",
email="dotgov@cisa.dhs.gov",
name="Registry Customer Service",
)
duplicate.save(skip_epp_save=True)
return PublicContact.objects.none()
return PublicContact.objects.filter(*args, **kwargs)
with patch.object(PublicContact.objects, "filter", side_effect=mock_filter):
try:
public_contact = PublicContact(
domain=domain,
contact_type=PublicContact.ContactTypeChoices.SECURITY,
registry_id="defaultSec",
email="dotgov@cisa.dhs.gov",
name="Registry Customer Service",
)
returned_public_contact = domain._get_or_create_public_contact(public_contact)
except IntegrityError:
self.fail(
"IntegrityError was raised during contact creation due to a race condition. "
"This indicates that concurrent contact creation is not working in some cases. "
"The error occurs when two processes try to create the same contact simultaneously. "
"Expected behavior: gracefully handle duplicate creation and return existing contact."
)
# Verify that only one contact exists and its correctness
security_contacts = PublicContact.objects.filter(
domain=domain, contact_type=PublicContact.ContactTypeChoices.SECURITY
)
self.assertEqual(security_contacts.count(), 1)
self.assertEqual(returned_public_contact, security_contacts.get())
self.assertEqual(returned_public_contact.registry_id, "defaultSec")
self.assertEqual(returned_public_contact.email, "dotgov@cisa.dhs.gov")
@boto3_mocking.patching
def test_approved_domain_request_creates_domain_locally(self):
"""

View file

@ -729,6 +729,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# "Submitted at",
"Status",
"Domain type",
"Portfolio",
"Federal type",
"Federal agency",
"Organization name",
@ -736,6 +737,10 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
"City",
"State/territory",
"Region",
"Suborganization",
"Requested suborg",
"Suborg city",
"Suborg state/territory",
"Creator first name",
"Creator last name",
"Creator email",
@ -765,28 +770,30 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
expected_content = (
# Header
"Domain request,Status,Domain type,Federal type,Federal agency,Organization name,Election office,"
"City,State/territory,Region,Creator first name,Creator last name,Creator email,"
"Domain request,Status,Domain type,Portfolio,Federal type,Federal agency,Organization name,"
"Election office,City,State/territory,Region,Suborganization,Requested suborg,Suborg city,"
"Suborg state/territory,Creator first name,Creator last name,Creator email,"
"Creator approved domains count,Creator active requests count,Alternative domains,SO first name,"
"SO last name,SO email,SO title/role,Request purpose,Request additional details,Other contacts,"
"CISA regional representative,Current websites,Investigator\n"
# Content
"city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com,"
"Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,NY,2,,,,0,1,city1.gov,,,,,"
"Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,NY,2,,,,0,1,"
"city5.gov,Approved,Federal,No,Executive,,Testorg,N/A,,NY,2,requested_suborg,SanFran,CA,,,,,1,0,"
"city1.gov,Testy,Tester,testy@town.com,Chief Tester,Purpose of the site,There is more,"
"Testy Tester testy2@town.com,,city.com,\n"
"city2.gov,In review,Federal,Yes,Executive,Portfolio 1 Federal Agency,,N/A,,,2,SubOrg 1,,,,,,,0,"
"1,city1.gov,,,,,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"city3.gov,Submitted,Federal,Yes,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,,,,,0,1,"
'"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | '
'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, '
'Testy Tester testy2@town.com",'
'test@igorville.com,"city.com, https://www.example2.com, https://www.example.com",\n'
"city4.gov,Submitted,City,Executive,,Testorg,Yes,,NY,2,,,,0,1,city1.gov,Testy,"
"city4.gov,Submitted,City,No,Executive,,Testorg,Yes,,NY,2,,,,,,,,0,1,city1.gov,Testy,"
"Tester,testy@town.com,"
"Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
"Testy Tester testy2@town.com,"
"cisaRep@igorville.gov,city.com,\n"
"city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,NY,2,,,,0,1,city1.gov,,,,,"
"Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com,"
"city6.gov,Submitted,Federal,Yes,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,,,,,0,1,city1.gov,"
",,,,Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com,"
"cisaRep@igorville.gov,city.com,\n"
)

View file

@ -2879,7 +2879,7 @@ class TestRequestingEntity(WebTest):
form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True
form["portfolio_requesting_entity-is_requesting_new_suborganization"] = True
form["portfolio_requesting_entity-sub_organization"] = ""
form["portfolio_requesting_entity-sub_organization"] = "other"
form["portfolio_requesting_entity-requested_suborganization"] = "moon"
form["portfolio_requesting_entity-suborganization_city"] = "kepler"
@ -2942,18 +2942,34 @@ class TestRequestingEntity(WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# For 2 the tests below, it is required to submit a form without submitting a value
# for the select/combobox. WebTest will not do this; by default, WebTest will submit
# the first choice in a select. So, need to manipulate the form to remove the
# particular select/combobox that will not be submitted, and then post the form.
form_action = f"/request/{domain_request.pk}/portfolio_requesting_entity/"
# Test missing suborganization selection
form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True
form["portfolio_requesting_entity-sub_organization"] = ""
response = form.submit()
form["portfolio_requesting_entity-is_requesting_new_suborganization"] = False
# remove sub_organization from the form submission
form_data = form.submit_fields()
form_data = [(key, value) for key, value in form_data if key != "portfolio_requesting_entity-sub_organization"]
response = self.app.post(form_action, dict(form_data))
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
self.assertContains(response, "Suborganization is required.", status_code=200)
# Test missing custom suborganization details
form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True
form["portfolio_requesting_entity-is_requesting_new_suborganization"] = True
response = form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
form["portfolio_requesting_entity-sub_organization"] = "other"
# remove suborganization_state_territory from the form submission
form_data = form.submit_fields()
form_data = [
(key, value)
for key, value in form_data
if key != "portfolio_requesting_entity-suborganization_state_territory"
]
response = self.app.post(form_action, dict(form_data))
self.assertContains(response, "Enter the name of your suborganization.", status_code=200)
self.assertContains(response, "Enter the city where your suborganization is located.", status_code=200)
self.assertContains(

View file

@ -1660,6 +1660,27 @@ class DomainRequestExport(BaseExport):
default=F("organization_name"),
output_field=CharField(),
),
"converted_city": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__city")),
# Otherwise, return the natively assigned value
default=F("city"),
output_field=CharField(),
),
"converted_state_territory": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__state_territory")),
# Otherwise, return the natively assigned value
default=F("state_territory"),
output_field=CharField(),
),
"converted_suborganization_name": Case(
# When sub_organization is present, use its name
When(sub_organization__isnull=False, then=F("sub_organization__name")),
# Otherwise, return empty string
default=Value(""),
output_field=CharField(),
),
"converted_so_email": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__email")),
@ -1786,6 +1807,10 @@ class DomainRequestExport(BaseExport):
status = model.get("status")
status_display = DomainRequest.DomainRequestStatus.get_status_label(status) if status else None
# Handle the portfolio field. Display as a Yes/No
portfolio = model.get("portfolio")
portfolio_display = "Yes" if portfolio is not None else "No"
# Handle the region field.
state_territory = model.get("state_territory")
region = get_region(state_territory) if state_territory else None
@ -1819,6 +1844,7 @@ class DomainRequestExport(BaseExport):
"Election office": human_readable_election_board,
"Federal type": human_readable_federal_type,
"Domain type": human_readable_org_type,
"Portfolio": portfolio_display,
"Request additional details": additional_details,
# Annotated fields - passed into the request dict.
"Creator approved domains count": model.get("creator_approved_domains_count", 0),
@ -1827,6 +1853,10 @@ class DomainRequestExport(BaseExport):
"Other contacts": model.get("all_other_contacts"),
"Current websites": model.get("all_current_websites"),
# Untouched FK fields - passed into the request dict.
"Suborganization": model.get("converted_suborganization_name"),
"Requested suborg": model.get("requested_suborganization"),
"Suborg city": model.get("suborganization_city"),
"Suborg state/territory": model.get("suborganization_state_territory"),
"Federal agency": model.get("converted_federal_agency"),
"SO first name": model.get("converted_senior_official_first_name"),
"SO last name": model.get("converted_senior_official_last_name"),
@ -1838,8 +1868,8 @@ class DomainRequestExport(BaseExport):
"Investigator": model.get("investigator__email"),
# Untouched fields
"Organization name": model.get("converted_organization_name"),
"City": model.get("city"),
"State/territory": model.get("state_territory"),
"City": model.get("converted_city"),
"State/territory": model.get("converted_state_territory"),
"Request purpose": model.get("purpose"),
"CISA regional representative": model.get("cisa_representative_email"),
"Last submitted date": model.get("last_submitted_date"),
@ -2006,6 +2036,7 @@ class DomainRequestDataFull(DomainRequestExport):
"Last status update",
"Status",
"Domain type",
"Portfolio",
"Federal type",
"Federal agency",
"Organization name",
@ -2013,6 +2044,10 @@ class DomainRequestDataFull(DomainRequestExport):
"City",
"State/territory",
"Region",
"Suborganization",
"Requested suborg",
"Suborg city",
"Suborg state/territory",
"Creator first name",
"Creator last name",
"Creator email",