mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-25 03:58:39 +02:00
Merge pull request #3250 from cisagov/za/3025-add-new-suborgs
Ticket #3025: Add new suborganizations on domain request approval
This commit is contained in:
commit
e7e3c72e62
10 changed files with 494 additions and 4 deletions
|
@ -629,6 +629,51 @@ export function initRejectedEmail() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that handles the suborganzation and requested suborganization fields and buttons.
|
||||||
|
* - Fieldwise: Hooks to the sub_organization, suborganization_city, and suborganization_state_territory fields.
|
||||||
|
* On change, this function checks if any of these fields are not empty:
|
||||||
|
* sub_organization, suborganization_city, and suborganization_state_territory.
|
||||||
|
* If they aren't, then we show the "clear" button. If they are, then we hide it because we don't need it.
|
||||||
|
*
|
||||||
|
* - Buttonwise: Hooks to the #clear-requested-suborganization button.
|
||||||
|
* On click, this will clear the input value of sub_organization, suborganization_city, and suborganization_state_territory.
|
||||||
|
*/
|
||||||
|
function handleSuborgFieldsAndButtons() {
|
||||||
|
const requestedSuborganizationField = document.getElementById("id_requested_suborganization");
|
||||||
|
const suborganizationCity = document.getElementById("id_suborganization_city");
|
||||||
|
const suborganizationStateTerritory = document.getElementById("id_suborganization_state_territory");
|
||||||
|
const rejectButton = document.querySelector("#clear-requested-suborganization");
|
||||||
|
|
||||||
|
// Ensure that every variable is present before proceeding
|
||||||
|
if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) {
|
||||||
|
console.warn("handleSuborganizationSelection() => Could not find required fields.")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRejectButtonVisibility() {
|
||||||
|
if (requestedSuborganizationField.value || suborganizationCity.value || suborganizationStateTerritory.value) {
|
||||||
|
showElement(rejectButton);
|
||||||
|
}else {
|
||||||
|
hideElement(rejectButton)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRejectButton() {
|
||||||
|
// Clear the text fields
|
||||||
|
requestedSuborganizationField.value = "";
|
||||||
|
suborganizationCity.value = "";
|
||||||
|
suborganizationStateTerritory.value = "";
|
||||||
|
// Update button visibility after clearing
|
||||||
|
handleRejectButtonVisibility();
|
||||||
|
}
|
||||||
|
rejectButton.addEventListener("click", handleRejectButton)
|
||||||
|
requestedSuborganizationField.addEventListener("blur", handleRejectButtonVisibility);
|
||||||
|
suborganizationCity.addEventListener("blur", handleRejectButtonVisibility);
|
||||||
|
suborganizationStateTerritory.addEventListener("change", handleRejectButtonVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A function for dynamic DomainRequest fields
|
* A function for dynamic DomainRequest fields
|
||||||
*/
|
*/
|
||||||
|
@ -636,5 +681,6 @@ export function initDynamicDomainRequestFields(){
|
||||||
const domainRequestPage = document.getElementById("domainrequest_form");
|
const domainRequestPage = document.getElementById("domainrequest_form");
|
||||||
if (domainRequestPage) {
|
if (domainRequestPage) {
|
||||||
handlePortfolioSelection();
|
handlePortfolioSelection();
|
||||||
|
handleSuborgFieldsAndButtons();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,6 +49,13 @@ export function handlePortfolioSelection(
|
||||||
const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization");
|
const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization");
|
||||||
const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly");
|
const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly");
|
||||||
const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null;
|
const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null;
|
||||||
|
// These requested suborganization fields only exist on the domain request page
|
||||||
|
const rejectSuborganizationButton = document.querySelector("#clear-requested-suborganization");
|
||||||
|
const requestedSuborganizationFieldInput = document.getElementById("id_requested_suborganization");
|
||||||
|
const suborganizationCityInput = document.getElementById("id_suborganization_city");
|
||||||
|
const suborganizationStateTerritoryInput = document.getElementById("id_suborganization_state_territory");
|
||||||
|
|
||||||
|
// Global var to track page load
|
||||||
let isPageLoading = true;
|
let isPageLoading = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -469,11 +476,28 @@ export function handlePortfolioSelection(
|
||||||
if (requestedSuborganizationField) showElement(requestedSuborganizationField);
|
if (requestedSuborganizationField) showElement(requestedSuborganizationField);
|
||||||
if (suborganizationCity) showElement(suborganizationCity);
|
if (suborganizationCity) showElement(suborganizationCity);
|
||||||
if (suborganizationStateTerritory) showElement(suborganizationStateTerritory);
|
if (suborganizationStateTerritory) showElement(suborganizationStateTerritory);
|
||||||
|
|
||||||
|
// == LOGIC FOR THE DOMAIN REQUEST PAGE == //
|
||||||
|
// Handle rejectSuborganizationButton (display of the clear requested suborg button).
|
||||||
|
// Basically, this button should only be visible when we have data for suborg, city, and state_territory.
|
||||||
|
// The function handleSuborgFieldsAndButtons() in domain-request-form.js handles doing this same logic
|
||||||
|
// but on field input for city, state_territory, and the suborg field.
|
||||||
|
// If it doesn't exist, don't do anything.
|
||||||
|
if (rejectSuborganizationButton){
|
||||||
|
if (requestedSuborganizationFieldInput?.value || suborganizationCityInput?.value || suborganizationStateTerritoryInput?.value) {
|
||||||
|
showElement(rejectSuborganizationButton);
|
||||||
|
}else {
|
||||||
|
hideElement(rejectSuborganizationButton);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Hide suborganization request fields if suborganization is selected
|
// Hide suborganization request fields if suborganization is selected
|
||||||
if (requestedSuborganizationField) hideElement(requestedSuborganizationField);
|
if (requestedSuborganizationField) hideElement(requestedSuborganizationField);
|
||||||
if (suborganizationCity) hideElement(suborganizationCity);
|
if (suborganizationCity) hideElement(suborganizationCity);
|
||||||
if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory);
|
if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory);
|
||||||
|
|
||||||
|
// == LOGIC FOR THE DOMAIN REQUEST PAGE == //
|
||||||
|
if (rejectSuborganizationButton) hideElement(rejectSuborganizationButton);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ from registrar.models import Contact, DomainRequest, DraftDomain, Domain, Federa
|
||||||
from registrar.templatetags.url_helpers import public_site_url
|
from registrar.templatetags.url_helpers import public_site_url
|
||||||
from registrar.utility.enums import ValidationReturnType
|
from registrar.utility.enums import ValidationReturnType
|
||||||
from registrar.utility.constants import BranchChoices
|
from registrar.utility.constants import BranchChoices
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -78,6 +79,20 @@ class RequestingEntityForm(RegistrarForm):
|
||||||
# Otherwise just return the suborg as normal
|
# Otherwise just return the suborg as normal
|
||||||
return self.cleaned_data.get("sub_organization")
|
return self.cleaned_data.get("sub_organization")
|
||||||
|
|
||||||
|
def clean_requested_suborganization(self):
|
||||||
|
name = self.cleaned_data.get("requested_suborganization")
|
||||||
|
if (
|
||||||
|
name
|
||||||
|
and Suborganization.objects.filter(
|
||||||
|
name__iexact=name, portfolio=self.domain_request.portfolio, name__isnull=False, portfolio__isnull=False
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
raise ValidationError(
|
||||||
|
"This suborganization already exists. "
|
||||||
|
"Choose a new name, or select it directly if you would like to use it."
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
def full_clean(self):
|
def full_clean(self):
|
||||||
"""Validation logic to remove the custom suborganization value before clean is triggered.
|
"""Validation logic to remove the custom suborganization value before clean is triggered.
|
||||||
Without this override, the form will throw an 'invalid option' error."""
|
Without this override, the form will throw an 'invalid option' error."""
|
||||||
|
@ -114,7 +129,7 @@ class RequestingEntityForm(RegistrarForm):
|
||||||
if requesting_entity_is_suborganization == "True":
|
if requesting_entity_is_suborganization == "True":
|
||||||
if is_requesting_new_suborganization:
|
if is_requesting_new_suborganization:
|
||||||
# Validate custom suborganization fields
|
# Validate custom suborganization fields
|
||||||
if not cleaned_data.get("requested_suborganization"):
|
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.")
|
self.add_error("requested_suborganization", "Enter the name of your suborganization.")
|
||||||
if not cleaned_data.get("suborganization_city"):
|
if not cleaned_data.get("suborganization_city"):
|
||||||
self.add_error("suborganization_city", "Enter the city where your suborganization is located.")
|
self.add_error("suborganization_city", "Enter the city where your suborganization is located.")
|
||||||
|
|
|
@ -12,6 +12,7 @@ from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTy
|
||||||
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
||||||
from registrar.utility.constants import BranchChoices
|
from registrar.utility.constants import BranchChoices
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from registrar.utility.waffle import flag_is_active_for_user
|
from registrar.utility.waffle import flag_is_active_for_user
|
||||||
|
|
||||||
|
@ -671,6 +672,59 @@ class DomainRequest(TimeStampedModel):
|
||||||
# Store original values for caching purposes. Used to compare them on save.
|
# Store original values for caching purposes. Used to compare them on save.
|
||||||
self._cache_status_and_status_reasons()
|
self._cache_status_and_status_reasons()
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""
|
||||||
|
Validates suborganization-related fields in two scenarios:
|
||||||
|
1. New suborganization request: Prevents duplicate names within same portfolio
|
||||||
|
2. Partial suborganization data: Enforces a all-or-nothing rule for city/state/name fields
|
||||||
|
when portfolio exists without selected suborganization
|
||||||
|
|
||||||
|
Add new domain request validation rules here to ensure they're
|
||||||
|
enforced during both model save and form submission.
|
||||||
|
Not presently used on the domain request wizard, though.
|
||||||
|
"""
|
||||||
|
super().clean()
|
||||||
|
# Validation logic for a suborganization request
|
||||||
|
if self.is_requesting_new_suborganization():
|
||||||
|
# Raise an error if this suborganization already exists
|
||||||
|
Suborganization = apps.get_model("registrar.Suborganization")
|
||||||
|
if (
|
||||||
|
self.requested_suborganization
|
||||||
|
and Suborganization.objects.filter(
|
||||||
|
name__iexact=self.requested_suborganization,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
name__isnull=False,
|
||||||
|
portfolio__isnull=False,
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
# Add a field-level error to requested_suborganization.
|
||||||
|
# To pass in field-specific errors, we need to embed a dict of
|
||||||
|
# field: validationerror then pass that into a validation error itself.
|
||||||
|
# This is slightly confusing, but it just adds it at that level.
|
||||||
|
msg = (
|
||||||
|
"This suborganization already exists. "
|
||||||
|
"Choose a new name, or select it directly if you would like to use it."
|
||||||
|
)
|
||||||
|
errors = {"requested_suborganization": ValidationError(msg)}
|
||||||
|
raise ValidationError(errors)
|
||||||
|
elif self.portfolio and not self.sub_organization:
|
||||||
|
# You cannot create a new suborganization without these fields
|
||||||
|
required_suborg_fields = {
|
||||||
|
"requested_suborganization": self.requested_suborganization,
|
||||||
|
"suborganization_city": self.suborganization_city,
|
||||||
|
"suborganization_state_territory": self.suborganization_state_territory,
|
||||||
|
}
|
||||||
|
# If at least one value is populated, enforce a all-or-nothing rule
|
||||||
|
if any(bool(value) for value in required_suborg_fields.values()):
|
||||||
|
# Find which fields are empty and throw an error on the field
|
||||||
|
errors = {}
|
||||||
|
for field_name, value in required_suborg_fields.items():
|
||||||
|
if not value:
|
||||||
|
errors[field_name] = ValidationError(
|
||||||
|
"This field is required when creating a new suborganization.",
|
||||||
|
)
|
||||||
|
raise ValidationError(errors)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Save override for custom properties"""
|
"""Save override for custom properties"""
|
||||||
self.sync_organization_type()
|
self.sync_organization_type()
|
||||||
|
@ -690,6 +744,18 @@ class DomainRequest(TimeStampedModel):
|
||||||
# Update the cached values after saving
|
# Update the cached values after saving
|
||||||
self._cache_status_and_status_reasons()
|
self._cache_status_and_status_reasons()
|
||||||
|
|
||||||
|
def create_requested_suborganization(self):
|
||||||
|
"""Creates the requested suborganization.
|
||||||
|
Adds the name, portfolio, city, and state_territory fields.
|
||||||
|
Returns the created suborganization."""
|
||||||
|
Suborganization = apps.get_model("registrar.Suborganization")
|
||||||
|
return Suborganization.objects.create(
|
||||||
|
name=self.requested_suborganization,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
city=self.suborganization_city,
|
||||||
|
state_territory=self.suborganization_state_territory,
|
||||||
|
)
|
||||||
|
|
||||||
def send_custom_status_update_email(self, status):
|
def send_custom_status_update_email(self, status):
|
||||||
"""Helper function to send out a second status email when the status remains the same,
|
"""Helper function to send out a second status email when the status remains the same,
|
||||||
but the reason has changed."""
|
but the reason has changed."""
|
||||||
|
@ -784,7 +850,9 @@ class DomainRequest(TimeStampedModel):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def delete_and_clean_up_domain(self, called_from):
|
def delete_and_clean_up_domain(self, called_from):
|
||||||
|
# Delete the approved domain
|
||||||
try:
|
try:
|
||||||
|
# Clean up the approved domain
|
||||||
domain_state = self.approved_domain.state
|
domain_state = self.approved_domain.state
|
||||||
# Only reject if it exists on EPP
|
# Only reject if it exists on EPP
|
||||||
if domain_state != Domain.State.UNKNOWN:
|
if domain_state != Domain.State.UNKNOWN:
|
||||||
|
@ -796,6 +864,39 @@ class DomainRequest(TimeStampedModel):
|
||||||
logger.error(err)
|
logger.error(err)
|
||||||
logger.error(f"Can't query an approved domain while attempting {called_from}")
|
logger.error(f"Can't query an approved domain while attempting {called_from}")
|
||||||
|
|
||||||
|
# Delete the suborg as long as this is the only place it is used
|
||||||
|
self._cleanup_dangling_suborg()
|
||||||
|
|
||||||
|
def _cleanup_dangling_suborg(self):
|
||||||
|
"""Deletes the existing suborg if its only being used by the deleted record"""
|
||||||
|
# Nothing to delete, so we just smile and walk away
|
||||||
|
if self.sub_organization is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
Suborganization = apps.get_model("registrar.Suborganization")
|
||||||
|
|
||||||
|
# Stored as so because we need to set the reference to none first,
|
||||||
|
# so we can't just use the self.sub_organization property
|
||||||
|
suborg = Suborganization.objects.get(id=self.sub_organization.id)
|
||||||
|
requests = suborg.request_sub_organization
|
||||||
|
domain_infos = suborg.information_sub_organization
|
||||||
|
|
||||||
|
# Check if this is the only reference to the suborganization
|
||||||
|
if requests.count() != 1 or domain_infos.count() > 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove the suborganization reference from request.
|
||||||
|
self.sub_organization = None
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# Remove the suborganization reference from domain if it exists.
|
||||||
|
if domain_infos.count() == 1:
|
||||||
|
domain_infos.update(sub_organization=None)
|
||||||
|
|
||||||
|
# Delete the now-orphaned suborganization
|
||||||
|
logger.info(f"_cleanup_dangling_suborg() -> Deleting orphan suborganization: {suborg}")
|
||||||
|
suborg.delete()
|
||||||
|
|
||||||
def _send_status_update_email(
|
def _send_status_update_email(
|
||||||
self,
|
self,
|
||||||
new_status,
|
new_status,
|
||||||
|
@ -984,6 +1085,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
|
|
||||||
if self.status == self.DomainRequestStatus.APPROVED:
|
if self.status == self.DomainRequestStatus.APPROVED:
|
||||||
self.delete_and_clean_up_domain("action_needed")
|
self.delete_and_clean_up_domain("action_needed")
|
||||||
|
|
||||||
elif self.status == self.DomainRequestStatus.REJECTED:
|
elif self.status == self.DomainRequestStatus.REJECTED:
|
||||||
self.rejection_reason = None
|
self.rejection_reason = None
|
||||||
|
|
||||||
|
@ -1014,8 +1116,16 @@ class DomainRequest(TimeStampedModel):
|
||||||
domain request into an admin on that domain. It also triggers an email
|
domain request into an admin on that domain. It also triggers an email
|
||||||
notification."""
|
notification."""
|
||||||
|
|
||||||
|
should_save = False
|
||||||
if self.federal_agency is None:
|
if self.federal_agency is None:
|
||||||
self.federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first()
|
self.federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first()
|
||||||
|
should_save = True
|
||||||
|
|
||||||
|
if self.is_requesting_new_suborganization():
|
||||||
|
self.sub_organization = self.create_requested_suborganization()
|
||||||
|
should_save = True
|
||||||
|
|
||||||
|
if should_save:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
# create the domain
|
# create the domain
|
||||||
|
@ -1148,7 +1258,7 @@ class DomainRequest(TimeStampedModel):
|
||||||
def is_requesting_new_suborganization(self) -> bool:
|
def is_requesting_new_suborganization(self) -> bool:
|
||||||
"""Determines if a user is trying to request
|
"""Determines if a user is trying to request
|
||||||
a new suborganization using the domain request form, rather than one that already exists.
|
a new suborganization using the domain request form, rather than one that already exists.
|
||||||
Used for the RequestingEntity page.
|
Used for the RequestingEntity page and on DomainInformation.create_from_da().
|
||||||
|
|
||||||
Returns True if a sub_organization does not exist and if requested_suborganization,
|
Returns True if a sub_organization does not exist and if requested_suborganization,
|
||||||
suborganization_city, and suborganization_state_territory all exist.
|
suborganization_city, and suborganization_state_territory all exist.
|
||||||
|
|
|
@ -321,6 +321,22 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
{% else %}
|
{% else %}
|
||||||
<input id="last-sent-rejection-email-content" class="display-none" value="None">
|
<input id="last-sent-rejection-email-content" class="display-none" value="None">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% elif field.field.name == "requested_suborganization" %}
|
||||||
|
{{ field.field }}
|
||||||
|
<div class="requested-suborganization--clear-button">
|
||||||
|
<button
|
||||||
|
id="clear-requested-suborganization"
|
||||||
|
class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="usa-icon"
|
||||||
|
>
|
||||||
|
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||||
|
</svg>
|
||||||
|
Clear requested suborganization
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ field.field }}
|
{{ field.field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -1034,6 +1034,10 @@ def completed_domain_request( # noqa
|
||||||
action_needed_reason=None,
|
action_needed_reason=None,
|
||||||
portfolio=None,
|
portfolio=None,
|
||||||
organization_name=None,
|
organization_name=None,
|
||||||
|
sub_organization=None,
|
||||||
|
requested_suborganization=None,
|
||||||
|
suborganization_city=None,
|
||||||
|
suborganization_state_territory=None,
|
||||||
):
|
):
|
||||||
"""A completed domain request."""
|
"""A completed domain request."""
|
||||||
if not user:
|
if not user:
|
||||||
|
@ -1098,6 +1102,18 @@ def completed_domain_request( # noqa
|
||||||
if portfolio:
|
if portfolio:
|
||||||
domain_request_kwargs["portfolio"] = portfolio
|
domain_request_kwargs["portfolio"] = portfolio
|
||||||
|
|
||||||
|
if sub_organization:
|
||||||
|
domain_request_kwargs["sub_organization"] = sub_organization
|
||||||
|
|
||||||
|
if requested_suborganization:
|
||||||
|
domain_request_kwargs["requested_suborganization"] = requested_suborganization
|
||||||
|
|
||||||
|
if suborganization_city:
|
||||||
|
domain_request_kwargs["suborganization_city"] = suborganization_city
|
||||||
|
|
||||||
|
if suborganization_state_territory:
|
||||||
|
domain_request_kwargs["suborganization_state_territory"] = suborganization_state_territory
|
||||||
|
|
||||||
domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
|
domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
|
||||||
|
|
||||||
if has_other_contacts:
|
if has_other_contacts:
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from django.forms import ValidationError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from waffle.testutils import override_flag
|
||||||
import re
|
import re
|
||||||
from django.test import RequestFactory, Client, TestCase, override_settings
|
from django.test import RequestFactory, Client, TestCase, override_settings
|
||||||
from django.contrib.admin.sites import AdminSite
|
from django.contrib.admin.sites import AdminSite
|
||||||
|
@ -24,6 +26,7 @@ from registrar.models import (
|
||||||
SeniorOfficial,
|
SeniorOfficial,
|
||||||
Portfolio,
|
Portfolio,
|
||||||
AllowedEmail,
|
AllowedEmail,
|
||||||
|
Suborganization,
|
||||||
)
|
)
|
||||||
from .common import (
|
from .common import (
|
||||||
MockSESClient,
|
MockSESClient,
|
||||||
|
@ -82,6 +85,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
Contact.objects.all().delete()
|
Contact.objects.all().delete()
|
||||||
Website.objects.all().delete()
|
Website.objects.all().delete()
|
||||||
SeniorOfficial.objects.all().delete()
|
SeniorOfficial.objects.all().delete()
|
||||||
|
Suborganization.objects.all().delete()
|
||||||
Portfolio.objects.all().delete()
|
Portfolio.objects.all().delete()
|
||||||
self.mock_client.EMAILS_SENT.clear()
|
self.mock_client.EMAILS_SENT.clear()
|
||||||
|
|
||||||
|
@ -91,6 +95,83 @@ class TestDomainRequestAdmin(MockEppLib):
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
AllowedEmail.objects.all().delete()
|
AllowedEmail.objects.all().delete()
|
||||||
|
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_clean_validates_duplicate_suborganization(self):
|
||||||
|
"""Tests that clean() prevents duplicate suborganization names within the same portfolio"""
|
||||||
|
# Create a portfolio and existing suborganization
|
||||||
|
portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
|
||||||
|
|
||||||
|
# Create an existing suborganization
|
||||||
|
Suborganization.objects.create(name="Existing Suborg", portfolio=portfolio)
|
||||||
|
|
||||||
|
# Create a domain request trying to use the same suborganization name
|
||||||
|
# (intentionally lowercase)
|
||||||
|
domain_request = completed_domain_request(
|
||||||
|
name="test1234.gov",
|
||||||
|
portfolio=portfolio,
|
||||||
|
requested_suborganization="existing suborg",
|
||||||
|
suborganization_city="Rome",
|
||||||
|
suborganization_state_territory=DomainRequest.StateTerritoryChoices.OHIO,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert that the validation error is raised
|
||||||
|
with self.assertRaises(ValidationError) as err:
|
||||||
|
domain_request.clean()
|
||||||
|
|
||||||
|
self.assertIn("This suborganization already exists", str(err.exception))
|
||||||
|
|
||||||
|
# Test that a different name is allowed. Should not raise a error.
|
||||||
|
domain_request.requested_suborganization = "New Suborg"
|
||||||
|
domain_request.clean()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
def test_clean_validates_partial_suborganization_fields(self):
|
||||||
|
"""Tests that clean() enforces all-or-nothing rule for suborganization fields"""
|
||||||
|
portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
|
||||||
|
|
||||||
|
# Create domain request with only city filled out
|
||||||
|
domain_request = completed_domain_request(
|
||||||
|
name="test1234.gov",
|
||||||
|
portfolio=portfolio,
|
||||||
|
suborganization_city="Test City",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert validation error is raised with correct missing fields
|
||||||
|
with self.assertRaises(ValidationError) as err:
|
||||||
|
domain_request.clean()
|
||||||
|
|
||||||
|
error_dict = err.exception.error_dict
|
||||||
|
expected_missing = ["requested_suborganization", "suborganization_state_territory"]
|
||||||
|
|
||||||
|
# Verify correct fields are flagged as required
|
||||||
|
self.assertEqual(sorted(error_dict.keys()), sorted(expected_missing))
|
||||||
|
|
||||||
|
# Verify error message
|
||||||
|
for field in expected_missing:
|
||||||
|
self.assertEqual(
|
||||||
|
str(error_dict[field][0].message), "This field is required when creating a new suborganization."
|
||||||
|
)
|
||||||
|
|
||||||
|
# When all data is passed in, this should validate correctly
|
||||||
|
domain_request.requested_suborganization = "Complete Suborg"
|
||||||
|
domain_request.suborganization_state_territory = DomainRequest.StateTerritoryChoices.OHIO
|
||||||
|
# Assert that no ValidationError is raised
|
||||||
|
try:
|
||||||
|
domain_request.clean()
|
||||||
|
except ValidationError as e:
|
||||||
|
self.fail(f"ValidationError was raised unexpectedly: {e}")
|
||||||
|
|
||||||
|
# Also ensure that no validation error is raised if nothing is passed in at all
|
||||||
|
domain_request.suborganization_city = None
|
||||||
|
domain_request.requested_suborganization = None
|
||||||
|
domain_request.suborganization_state_territory = None
|
||||||
|
try:
|
||||||
|
domain_request.clean()
|
||||||
|
except ValidationError as e:
|
||||||
|
self.fail(f"ValidationError was raised unexpectedly: {e}")
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_domain_request_senior_official_is_alphabetically_sorted(self):
|
def test_domain_request_senior_official_is_alphabetically_sorted(self):
|
||||||
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
|
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
|
||||||
|
|
|
@ -15,6 +15,7 @@ from registrar.models import (
|
||||||
FederalAgency,
|
FederalAgency,
|
||||||
AllowedEmail,
|
AllowedEmail,
|
||||||
Portfolio,
|
Portfolio,
|
||||||
|
Suborganization,
|
||||||
)
|
)
|
||||||
|
|
||||||
import boto3_mocking
|
import boto3_mocking
|
||||||
|
@ -23,6 +24,8 @@ from registrar.utility.errors import FSMDomainRequestError
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
MockSESClient,
|
MockSESClient,
|
||||||
|
create_user,
|
||||||
|
create_superuser,
|
||||||
less_console_noise,
|
less_console_noise,
|
||||||
completed_domain_request,
|
completed_domain_request,
|
||||||
set_domain_request_investigators,
|
set_domain_request_investigators,
|
||||||
|
@ -1070,3 +1073,142 @@ class TestDomainRequest(TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(domain_request2.generic_org_type, domain_request2.converted_generic_org_type)
|
self.assertEqual(domain_request2.generic_org_type, domain_request2.converted_generic_org_type)
|
||||||
self.assertEqual(domain_request2.federal_agency, domain_request2.converted_federal_agency)
|
self.assertEqual(domain_request2.federal_agency, domain_request2.converted_federal_agency)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainRequestSuborganization(TestCase):
|
||||||
|
"""Tests for the suborganization fields on domain requests"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.user = create_user()
|
||||||
|
self.superuser = create_superuser()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super().tearDown()
|
||||||
|
DomainInformation.objects.all().delete()
|
||||||
|
DomainRequest.objects.all().delete()
|
||||||
|
Domain.objects.all().delete()
|
||||||
|
Suborganization.objects.all().delete()
|
||||||
|
Portfolio.objects.all().delete()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_approve_creates_requested_suborganization(self):
|
||||||
|
"""Test that approving a domain request with a requested suborganization creates it"""
|
||||||
|
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
|
||||||
|
|
||||||
|
domain_request = completed_domain_request(
|
||||||
|
name="test.gov",
|
||||||
|
portfolio=portfolio,
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
requested_suborganization="Boom",
|
||||||
|
suborganization_city="Explody town",
|
||||||
|
suborganization_state_territory=DomainRequest.StateTerritoryChoices.OHIO,
|
||||||
|
)
|
||||||
|
domain_request.investigator = self.superuser
|
||||||
|
domain_request.save()
|
||||||
|
|
||||||
|
domain_request.approve()
|
||||||
|
|
||||||
|
created_suborg = Suborganization.objects.filter(
|
||||||
|
name="Boom",
|
||||||
|
city="Explody town",
|
||||||
|
state_territory=DomainRequest.StateTerritoryChoices.OHIO,
|
||||||
|
portfolio=portfolio,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
self.assertIsNotNone(created_suborg)
|
||||||
|
self.assertEqual(domain_request.sub_organization, created_suborg)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_approve_without_requested_suborganization_makes_no_changes(self):
|
||||||
|
"""Test that approving without a requested suborganization doesn't create one"""
|
||||||
|
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
|
||||||
|
|
||||||
|
domain_request = completed_domain_request(
|
||||||
|
name="test.gov",
|
||||||
|
portfolio=portfolio,
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
)
|
||||||
|
domain_request.investigator = self.superuser
|
||||||
|
domain_request.save()
|
||||||
|
|
||||||
|
initial_suborg_count = Suborganization.objects.count()
|
||||||
|
domain_request.approve()
|
||||||
|
|
||||||
|
self.assertEqual(Suborganization.objects.count(), initial_suborg_count)
|
||||||
|
self.assertIsNone(domain_request.sub_organization)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_approve_with_existing_suborganization_makes_no_changes(self):
|
||||||
|
"""Test that approving with an existing suborganization doesn't create a new one"""
|
||||||
|
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
|
||||||
|
existing_suborg = Suborganization.objects.create(name="Existing Division", portfolio=portfolio)
|
||||||
|
|
||||||
|
domain_request = completed_domain_request(
|
||||||
|
name="test.gov",
|
||||||
|
portfolio=portfolio,
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
sub_organization=existing_suborg,
|
||||||
|
)
|
||||||
|
domain_request.investigator = self.superuser
|
||||||
|
domain_request.save()
|
||||||
|
|
||||||
|
initial_suborg_count = Suborganization.objects.count()
|
||||||
|
domain_request.approve()
|
||||||
|
|
||||||
|
self.assertEqual(Suborganization.objects.count(), initial_suborg_count)
|
||||||
|
self.assertEqual(domain_request.sub_organization, existing_suborg)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_cleanup_dangling_suborg_with_single_reference(self):
|
||||||
|
"""Test that a suborganization is deleted when it's only referenced once"""
|
||||||
|
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
|
||||||
|
suborg = Suborganization.objects.create(name="Test Division", portfolio=portfolio)
|
||||||
|
|
||||||
|
domain_request = completed_domain_request(
|
||||||
|
name="test.gov",
|
||||||
|
portfolio=portfolio,
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
sub_organization=suborg,
|
||||||
|
)
|
||||||
|
domain_request.approve()
|
||||||
|
|
||||||
|
# set it back to in review
|
||||||
|
domain_request.in_review()
|
||||||
|
domain_request.refresh_from_db()
|
||||||
|
|
||||||
|
# Verify the suborganization was deleted
|
||||||
|
self.assertFalse(Suborganization.objects.filter(id=suborg.id).exists())
|
||||||
|
self.assertIsNone(domain_request.sub_organization)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_cleanup_dangling_suborg_with_multiple_references(self):
|
||||||
|
"""Test that a suborganization is preserved when it has multiple references"""
|
||||||
|
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
|
||||||
|
suborg = Suborganization.objects.create(name="Test Division", portfolio=portfolio)
|
||||||
|
|
||||||
|
# Create two domain requests using the same suborganization
|
||||||
|
domain_request1 = completed_domain_request(
|
||||||
|
name="test1.gov",
|
||||||
|
portfolio=portfolio,
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
sub_organization=suborg,
|
||||||
|
)
|
||||||
|
domain_request2 = completed_domain_request(
|
||||||
|
name="test2.gov",
|
||||||
|
portfolio=portfolio,
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
sub_organization=suborg,
|
||||||
|
)
|
||||||
|
|
||||||
|
domain_request1.approve()
|
||||||
|
domain_request2.approve()
|
||||||
|
|
||||||
|
# set one back to in review
|
||||||
|
domain_request1.in_review()
|
||||||
|
domain_request1.refresh_from_db()
|
||||||
|
|
||||||
|
# Verify the suborganization still exists
|
||||||
|
self.assertTrue(Suborganization.objects.filter(id=suborg.id).exists())
|
||||||
|
self.assertEqual(domain_request1.sub_organization, suborg)
|
||||||
|
self.assertEqual(domain_request2.sub_organization, suborg)
|
||||||
|
|
|
@ -2576,6 +2576,46 @@ class TestRequestingEntity(WebTest):
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_requests", active=True)
|
||||||
|
def test_form_validates_duplicate_suborganization(self):
|
||||||
|
"""Tests that form validation prevents duplicate suborganization names within the same portfolio"""
|
||||||
|
# Create an existing suborganization
|
||||||
|
suborganization = Suborganization.objects.create(name="Existing Suborg", portfolio=self.portfolio)
|
||||||
|
|
||||||
|
# Start the domain request process
|
||||||
|
response = self.app.get(reverse("domain-request:start"))
|
||||||
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
|
|
||||||
|
# Navigate past the intro page
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
form = response.forms[0]
|
||||||
|
response = form.submit().follow()
|
||||||
|
|
||||||
|
# Fill out the requesting entity form
|
||||||
|
form = response.forms[0]
|
||||||
|
form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = "True"
|
||||||
|
form["portfolio_requesting_entity-is_requesting_new_suborganization"] = "True"
|
||||||
|
form["portfolio_requesting_entity-requested_suborganization"] = suborganization.name.lower()
|
||||||
|
form["portfolio_requesting_entity-suborganization_city"] = "Eggnog"
|
||||||
|
form["portfolio_requesting_entity-suborganization_state_territory"] = DomainRequest.StateTerritoryChoices.OHIO
|
||||||
|
|
||||||
|
# Submit form and verify error
|
||||||
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
response = form.submit()
|
||||||
|
self.assertContains(response, "This suborganization already exists")
|
||||||
|
|
||||||
|
# Test that a different name is allowed
|
||||||
|
form["portfolio_requesting_entity-requested_suborganization"] = "New Suborg"
|
||||||
|
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||||
|
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||||
|
response = form.submit().follow()
|
||||||
|
|
||||||
|
# Verify successful submission by checking we're on the next page
|
||||||
|
self.assertContains(response, "Current websites")
|
||||||
|
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_requests", active=True)
|
@override_flag("organization_requests", active=True)
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
|
|
@ -417,7 +417,7 @@ class MemberExport(BaseExport):
|
||||||
# Adding a order_by increases output predictability.
|
# Adding a order_by increases output predictability.
|
||||||
# Doesn't matter as much for normal use, but makes tests easier.
|
# Doesn't matter as much for normal use, but makes tests easier.
|
||||||
# We should also just be ordering by default anyway.
|
# We should also just be ordering by default anyway.
|
||||||
members = permissions.union(invitations).order_by("email_display")
|
members = permissions.union(invitations).order_by("email_display", "member_display", "first_name", "last_name")
|
||||||
return convert_queryset_to_dict(members, is_model=False)
|
return convert_queryset_to_dict(members, is_model=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue