mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-23 19:20:47 +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
|
||||
*/
|
||||
|
@ -636,5 +681,6 @@ export function initDynamicDomainRequestFields(){
|
|||
const domainRequestPage = document.getElementById("domainrequest_form");
|
||||
if (domainRequestPage) {
|
||||
handlePortfolioSelection();
|
||||
handleSuborgFieldsAndButtons();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,6 +49,13 @@ export function handlePortfolioSelection(
|
|||
const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization");
|
||||
const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly");
|
||||
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;
|
||||
|
||||
/**
|
||||
|
@ -469,11 +476,28 @@ export function handlePortfolioSelection(
|
|||
if (requestedSuborganizationField) showElement(requestedSuborganizationField);
|
||||
if (suborganizationCity) showElement(suborganizationCity);
|
||||
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 {
|
||||
// Hide suborganization request fields if suborganization is selected
|
||||
if (requestedSuborganizationField) hideElement(requestedSuborganizationField);
|
||||
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.utility.enums import ValidationReturnType
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -78,6 +79,20 @@ class RequestingEntityForm(RegistrarForm):
|
|||
# Otherwise just return the suborg as normal
|
||||
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):
|
||||
"""Validation logic to remove the custom suborganization value before clean is triggered.
|
||||
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 is_requesting_new_suborganization:
|
||||
# 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.")
|
||||
if not cleaned_data.get("suborganization_city"):
|
||||
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.constants import BranchChoices
|
||||
from auditlog.models import LogEntry
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
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.
|
||||
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):
|
||||
"""Save override for custom properties"""
|
||||
self.sync_organization_type()
|
||||
|
@ -690,6 +744,18 @@ class DomainRequest(TimeStampedModel):
|
|||
# Update the cached values after saving
|
||||
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):
|
||||
"""Helper function to send out a second status email when the status remains the same,
|
||||
but the reason has changed."""
|
||||
|
@ -784,7 +850,9 @@ class DomainRequest(TimeStampedModel):
|
|||
return True
|
||||
|
||||
def delete_and_clean_up_domain(self, called_from):
|
||||
# Delete the approved domain
|
||||
try:
|
||||
# Clean up the approved domain
|
||||
domain_state = self.approved_domain.state
|
||||
# Only reject if it exists on EPP
|
||||
if domain_state != Domain.State.UNKNOWN:
|
||||
|
@ -796,6 +864,39 @@ class DomainRequest(TimeStampedModel):
|
|||
logger.error(err)
|
||||
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(
|
||||
self,
|
||||
new_status,
|
||||
|
@ -984,6 +1085,7 @@ class DomainRequest(TimeStampedModel):
|
|||
|
||||
if self.status == self.DomainRequestStatus.APPROVED:
|
||||
self.delete_and_clean_up_domain("action_needed")
|
||||
|
||||
elif self.status == self.DomainRequestStatus.REJECTED:
|
||||
self.rejection_reason = None
|
||||
|
||||
|
@ -1014,8 +1116,16 @@ class DomainRequest(TimeStampedModel):
|
|||
domain request into an admin on that domain. It also triggers an email
|
||||
notification."""
|
||||
|
||||
should_save = False
|
||||
if self.federal_agency is None:
|
||||
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()
|
||||
|
||||
# create the domain
|
||||
|
@ -1148,7 +1258,7 @@ class DomainRequest(TimeStampedModel):
|
|||
def is_requesting_new_suborganization(self) -> bool:
|
||||
"""Determines if a user is trying to request
|
||||
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,
|
||||
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 %}
|
||||
<input id="last-sent-rejection-email-content" class="display-none" value="None">
|
||||
{% 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 %}
|
||||
{{ field.field }}
|
||||
{% endif %}
|
||||
|
|
|
@ -1034,6 +1034,10 @@ def completed_domain_request( # noqa
|
|||
action_needed_reason=None,
|
||||
portfolio=None,
|
||||
organization_name=None,
|
||||
sub_organization=None,
|
||||
requested_suborganization=None,
|
||||
suborganization_city=None,
|
||||
suborganization_state_territory=None,
|
||||
):
|
||||
"""A completed domain request."""
|
||||
if not user:
|
||||
|
@ -1098,6 +1102,18 @@ def completed_domain_request( # noqa
|
|||
if 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)
|
||||
|
||||
if has_other_contacts:
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from datetime import datetime
|
||||
from django.forms import ValidationError
|
||||
from django.utils import timezone
|
||||
from waffle.testutils import override_flag
|
||||
import re
|
||||
from django.test import RequestFactory, Client, TestCase, override_settings
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
|
@ -24,6 +26,7 @@ from registrar.models import (
|
|||
SeniorOfficial,
|
||||
Portfolio,
|
||||
AllowedEmail,
|
||||
Suborganization,
|
||||
)
|
||||
from .common import (
|
||||
MockSESClient,
|
||||
|
@ -82,6 +85,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
Contact.objects.all().delete()
|
||||
Website.objects.all().delete()
|
||||
SeniorOfficial.objects.all().delete()
|
||||
Suborganization.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
self.mock_client.EMAILS_SENT.clear()
|
||||
|
||||
|
@ -91,6 +95,83 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
User.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
|
||||
def test_domain_request_senior_official_is_alphabetically_sorted(self):
|
||||
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
|
||||
|
|
|
@ -15,6 +15,7 @@ from registrar.models import (
|
|||
FederalAgency,
|
||||
AllowedEmail,
|
||||
Portfolio,
|
||||
Suborganization,
|
||||
)
|
||||
|
||||
import boto3_mocking
|
||||
|
@ -23,6 +24,8 @@ from registrar.utility.errors import FSMDomainRequestError
|
|||
|
||||
from .common import (
|
||||
MockSESClient,
|
||||
create_user,
|
||||
create_superuser,
|
||||
less_console_noise,
|
||||
completed_domain_request,
|
||||
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.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()
|
||||
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_requests", active=True)
|
||||
@less_console_noise_decorator
|
||||
|
|
|
@ -417,7 +417,7 @@ class MemberExport(BaseExport):
|
|||
# Adding a order_by increases output predictability.
|
||||
# Doesn't matter as much for normal use, but makes tests easier.
|
||||
# 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)
|
||||
|
||||
@classmethod
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue