Merge branch 'main' into za/2927-blocked-from-starting-requests

This commit is contained in:
zandercymatics 2024-11-01 13:53:15 -06:00
commit d21695316c
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
20 changed files with 1117 additions and 118 deletions

View file

@ -20,6 +20,7 @@
"http://localhost:8080/request/anything_else/", "http://localhost:8080/request/anything_else/",
"http://localhost:8080/request/requirements/", "http://localhost:8080/request/requirements/",
"http://localhost:8080/request/finished/", "http://localhost:8080/request/finished/",
"http://localhost:8080/request/requesting_entity/",
"http://localhost:8080/user-profile/", "http://localhost:8080/user-profile/",
"http://localhost:8080/members/", "http://localhost:8080/members/",
"http://localhost:8080/members/new-member" "http://localhost:8080/members/new-member"

View file

@ -5,6 +5,7 @@ from django import forms
from django.db.models import Value, CharField, Q from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency
from registrar.utility.admin_helpers import ( from registrar.utility.admin_helpers import (
get_action_needed_reason_default_email, get_action_needed_reason_default_email,
get_rejection_reason_default_email, get_rejection_reason_default_email,
@ -28,6 +29,7 @@ from waffle.models import Sample, Switch
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.utility.waffle import flag_is_active_for_user
from registrar.views.utility.mixins import OrderableFieldsMixin from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR from django.contrib.admin.views.main import ORDER_VAR
from registrar.widgets import NoAutocompleteFilteredSelectMultiple from registrar.widgets import NoAutocompleteFilteredSelectMultiple
@ -1478,7 +1480,18 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain." search_help_text = "Search by domain."
fieldsets = [ fieldsets = [
(None, {"fields": ["portfolio", "sub_organization", "creator", "domain_request", "notes"]}), (
None,
{
"fields": [
"portfolio",
"sub_organization",
"creator",
"domain_request",
"notes",
]
},
),
(".gov domain", {"fields": ["domain"]}), (".gov domain", {"fields": ["domain"]}),
("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}), ("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}),
("Background info", {"fields": ["anything_else"]}), ("Background info", {"fields": ["anything_else"]}),
@ -1748,6 +1761,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"fields": [ "fields": [
"portfolio", "portfolio",
"sub_organization", "sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
"status_history", "status_history",
"status", "status",
"rejection_reason", "rejection_reason",
@ -1849,6 +1865,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"cisa_representative_first_name", "cisa_representative_first_name",
"cisa_representative_last_name", "cisa_representative_last_name",
"cisa_representative_email", "cisa_representative_email",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
] ]
autocomplete_fields = [ autocomplete_fields = [
"approved_domain", "approved_domain",
@ -1868,6 +1887,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_form_template = "django/admin/domain_request_change_form.html" change_form_template = "django/admin/domain_request_change_form.html"
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
# Hide certain suborg fields behind the organization feature flag
# if it is not enabled
if not flag_is_active_for_user(request.user, "organization_feature"):
excluded_fields = [
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
]
modified_fieldsets = []
for name, data in fieldsets:
fields = data.get("fields", [])
fields = tuple(field for field in fields if field not in excluded_fields)
modified_fieldsets.append((name, {**data, "fields": fields}))
return modified_fieldsets
return fieldsets
# Trigger action when a fieldset is changed # Trigger action when a fieldset is changed
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
"""Custom save_model definition that handles edge cases""" """Custom save_model definition that handles edge cases"""
@ -3206,6 +3244,14 @@ class PortfolioAdmin(ListHeaderAdmin):
# straightforward and the readonly_fields list can control their behavior # straightforward and the readonly_fields list can control their behavior
readonly_fields.extend([field.name for field in self.model._meta.fields]) readonly_fields.extend([field.name for field in self.model._meta.fields])
# Make senior_official readonly for federal organizations
if obj and obj.organization_type == obj.OrganizationChoices.FEDERAL:
if "senior_official" not in readonly_fields:
readonly_fields.append("senior_official")
elif "senior_official" in readonly_fields:
# Remove senior_official from readonly_fields if org is non-federal
readonly_fields.remove("senior_official")
if request.user.has_perm("registrar.full_access_permission"): if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields return readonly_fields
@ -3228,12 +3274,11 @@ class PortfolioAdmin(ListHeaderAdmin):
extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"]) extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"])
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change): def save_model(self, request, obj: Portfolio, form, change):
if hasattr(obj, "creator") is False: if hasattr(obj, "creator") is False:
# ---- update creator ---- # ---- update creator ----
# Set the creator field to the current admin user # Set the creator field to the current admin user
obj.creator = request.user if request.user.is_authenticated else None obj.creator = request.user if request.user.is_authenticated else None # type: ignore
# ---- update organization name ---- # ---- update organization name ----
# org name will be the same as federal agency, if it is federal, # org name will be the same as federal agency, if it is federal,
# otherwise it will be the actual org name. If nothing is entered for # otherwise it will be the actual org name. If nothing is entered for
@ -3243,12 +3288,19 @@ class PortfolioAdmin(ListHeaderAdmin):
if is_federal and obj.organization_name is None: if is_federal and obj.organization_name is None:
obj.organization_name = obj.federal_agency.agency obj.organization_name = obj.federal_agency.agency
# Remove this line when senior_official is no longer readonly in /admin. # Set the senior official field to the senior official on the federal agency
if obj.federal_agency: # when federal - otherwise, clear the field.
if obj.federal_agency.so_federal_agency.exists(): if obj.organization_type == obj.OrganizationChoices.FEDERAL:
obj.senior_official = obj.federal_agency.so_federal_agency.first() if obj.federal_agency:
else: if obj.federal_agency.so_federal_agency.exists():
obj.senior_official = None obj.senior_official = obj.federal_agency.so_federal_agency.first()
else:
obj.senior_official = None
else:
if obj.federal_agency and obj.federal_agency.agency != "Non-Federal Agency":
if obj.federal_agency.so_federal_agency.first() == obj.senior_official:
obj.senior_official = None
obj.federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first() # type: ignore
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)

View file

@ -47,10 +47,49 @@ function addOrRemoveSessionBoolean(name, add){
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Event handlers. // Event handlers.
/** Helper function that handles business logic for the suborganization field.
* Can be used anywhere the suborganization dropdown exists
*/
function handleSuborganizationFields(
portfolioDropdownSelector="#id_portfolio",
suborgDropdownSelector="#id_sub_organization",
requestedSuborgFieldSelector=".field-requested_suborganization",
suborgCitySelector=".field-suborganization_city",
suborgStateTerritorySelector=".field-suborganization_state_territory"
) {
// These dropdown are select2 fields so they must be interacted with via jquery
const portfolioDropdown = django.jQuery(portfolioDropdownSelector)
const suborganizationDropdown = django.jQuery(suborgDropdownSelector)
const requestedSuborgField = document.querySelector(requestedSuborgFieldSelector);
const suborgCity = document.querySelector(suborgCitySelector);
const suborgStateTerritory = document.querySelector(suborgStateTerritorySelector);
if (!suborganizationDropdown || !requestedSuborgField || !suborgCity || !suborgStateTerritory) {
console.error("Requested suborg fields not found.");
return;
}
function toggleSuborganizationFields() {
if (portfolioDropdown.val() && !suborganizationDropdown.val()) {
showElement(requestedSuborgField);
showElement(suborgCity);
showElement(suborgStateTerritory);
}else {
hideElement(requestedSuborgField);
hideElement(suborgCity);
hideElement(suborgStateTerritory);
}
}
// Run the function once on page startup, then attach an event listener
toggleSuborganizationFields();
suborganizationDropdown.on("change", toggleSuborganizationFields);
portfolioDropdown.on("change", toggleSuborganizationFields);
}
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Initialization code. // Initialization code.
/** An IIFE for pages in DjangoAdmin that use modals. /** An IIFE for pages in DjangoAdmin that use modals.
* Dja strips out form elements, and modals generate their content outside * Dja strips out form elements, and modals generate their content outside
* of the current form scope, so we need to "inject" these inputs. * of the current form scope, so we need to "inject" these inputs.
@ -927,6 +966,7 @@ document.addEventListener('DOMContentLoaded', function() {
// This is the additional information that exists beneath the SO element. // This is the additional information that exists beneath the SO element.
var contactList = document.querySelector(".field-senior_official .dja-address-contact-list"); var contactList = document.querySelector(".field-senior_official .dja-address-contact-list");
const federalAgencyContainer = document.querySelector(".field-federal_agency");
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
let isPortfolioPage = document.getElementById("portfolio_form"); let isPortfolioPage = document.getElementById("portfolio_form");
@ -975,11 +1015,13 @@ document.addEventListener('DOMContentLoaded', function() {
let selectedValue = organizationType.value; let selectedValue = organizationType.value;
if (selectedValue === "federal") { if (selectedValue === "federal") {
hideElement(organizationNameContainer); hideElement(organizationNameContainer);
showElement(federalAgencyContainer);
if (federalType) { if (federalType) {
showElement(federalType); showElement(federalType);
} }
} else { } else {
showElement(organizationNameContainer); showElement(organizationNameContainer);
hideElement(federalAgencyContainer);
if (federalType) { if (federalType) {
hideElement(federalType); hideElement(federalType);
} }
@ -1170,3 +1212,28 @@ document.addEventListener('DOMContentLoaded', function() {
}; };
} }
})(); })();
/** An IIFE for dynamic DomainRequest fields
*/
(function dynamicDomainRequestFields(){
const domainRequestPage = document.getElementById("domainrequest_form");
if (domainRequestPage) {
handleSuborganizationFields();
}
})();
/** An IIFE for dynamic DomainInformation fields
*/
(function dynamicDomainInformationFields(){
const domainInformationPage = document.getElementById("domaininformation_form");
// DomainInformation is embedded inside domain so this should fire there too
const domainPage = document.getElementById("domain_form");
if (domainInformationPage) {
handleSuborganizationFields();
}
if (domainPage) {
handleSuborganizationFields(portfolioDropdownSelector="#id_domain_info-0-portfolio", suborgDropdownSelector="#id_domain_info-0-sub_organization");
}
})();

View file

@ -26,8 +26,8 @@ const hideElement = (element) => {
}; };
/** /**
* Show element * Show element
* *
*/ */
const showElement = (element) => { const showElement = (element) => {
element.classList.remove('display-none'); element.classList.remove('display-none');
@ -2775,3 +2775,48 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
})(); })();
/** An IIFE that intializes the requesting entity page.
* This page has a radio button that dynamically toggles some fields
* Within that, the dropdown also toggles some additional form elements.
*/
(function handleRequestingEntityFieldset() {
// Sadly, these ugly ids are the auto generated with this prefix
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 suborgContainer = document.getElementById("suborganization-container");
const suborgDetailsContainer = document.getElementById("suborganization-container__details");
if (!radios || !select || !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.
var requestingSuborganization = Array.from(radios).find(radio => radio.checked)?.value === "True";
var requestingNewSuborganization = document.getElementById(`id_${formPrefix}-is_requesting_new_suborganization`);
function toggleSuborganization(radio=null) {
if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True";
requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer);
requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False";
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("Other (enter your organization manually)", "other"));
}
if (requestingNewSuborganization.value === "True") {
select.value = "other";
}
// Add event listener to is_suborganization radio buttons, and run for initial display
toggleSuborganization();
radios.forEach(radio => {
radio.addEventListener("click", () => toggleSuborganization(radio));
});
// Add event listener to the suborg dropdown to show/hide the suborg details section
select.addEventListener("change", () => toggleSuborganization());
})();

View file

@ -13,7 +13,7 @@ from registrar.forms.utility.wizard_form_helper import (
BaseYesNoForm, BaseYesNoForm,
BaseDeletableRegistrarForm, BaseDeletableRegistrarForm,
) )
from registrar.models import Contact, DomainRequest, DraftDomain, Domain, FederalAgency from registrar.models import Contact, DomainRequest, DraftDomain, Domain, FederalAgency, Suborganization
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
@ -22,10 +22,146 @@ logger = logging.getLogger(__name__)
class RequestingEntityForm(RegistrarForm): class RequestingEntityForm(RegistrarForm):
organization_name = forms.CharField( """The requesting entity form contains a dropdown for suborganizations,
label="Organization name", and some (hidden by default) input fields that allow the user to request for a suborganization.
error_messages={"required": "Enter the name of your organization."}, All of these fields are not required by default, but as we use javascript to conditionally show
and hide some of these, they then become required in certain circumstances."""
# IMPORTANT: This is tied to DomainRequest.is_requesting_new_suborganization().
# This is due to the from_database method on DomainRequestWizard.
# Add a hidden field to store if the user is requesting a new suborganization.
# This hidden boolean is used for our javascript to communicate to us and to it.
# If true, the suborganization form will auto select a js value "Other".
# If this selection is made on the form (tracked by js), then it will toggle the form value of this.
# In other words, this essentially tracks if the suborganization field == "Other".
# "Other" is just an imaginary value that is otherwise invalid.
# Note the logic in `def clean` and `handleRequestingEntityFieldset` in get-gov.js
is_requesting_new_suborganization = forms.BooleanField(required=False, widget=forms.HiddenInput())
sub_organization = forms.ModelChoiceField(
label="Suborganization name",
required=False,
queryset=Suborganization.objects.none(),
empty_label="--Select--",
) )
requested_suborganization = forms.CharField(
label="Requested suborganization",
required=False,
)
suborganization_city = forms.CharField(
label="City",
required=False,
)
suborganization_state_territory = forms.ChoiceField(
label="State, territory, or military post",
required=False,
choices=[("", "--Select--")] + DomainRequest.StateTerritoryChoices.choices,
)
def __init__(self, *args, **kwargs):
"""Override of init to add the suborganization queryset"""
super().__init__(*args, **kwargs)
if self.domain_request.portfolio:
self.fields["sub_organization"].queryset = Suborganization.objects.filter(
portfolio=self.domain_request.portfolio
)
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
# Otherwise just return the suborg as normal
return self.cleaned_data.get("sub_organization")
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."""
# Remove the custom other field before cleaning
data = self.data.copy() if self.data else None
# 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"] = ""
# Set the modified data back to the form
self.data = data
# Call the parent's full_clean method
super().full_clean()
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."""
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.
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"):
self.add_error("requested_suborganization", "Requested suborganization is required.")
if not cleaned_data.get("suborganization_city"):
self.add_error("suborganization_city", "City is required.")
if not cleaned_data.get("suborganization_state_territory"):
self.add_error("suborganization_state_territory", "State, territory, or military post is required.")
elif not suborganization:
self.add_error("sub_organization", "Suborganization is required.")
return cleaned_data
class RequestingEntityYesNoForm(BaseYesNoForm):
"""The yes/no field for the RequestingEntity form."""
# This first option will change dynamically
form_choices = ((False, "Current Organization"), (True, "A suborganization. (choose from list)"))
# IMPORTANT: This is tied to DomainRequest.is_requesting_new_suborganization().
# This is due to the from_database method on DomainRequestWizard.
field_name = "requesting_entity_is_suborganization"
required_error_message = "Requesting entity is required."
def __init__(self, *args, **kwargs):
"""Extend the initialization of the form from RegistrarForm __init__"""
super().__init__(*args, **kwargs)
if self.domain_request.portfolio:
self.form_choices = (
(False, self.domain_request.portfolio),
(True, "A suborganization (choose from list)"),
)
self.fields[self.field_name] = self.get_typed_choice_field()
@property
def form_is_checked(self):
"""
Determines the initial checked state of the form.
Returns True (checked) if the requesting entity is a suborganization,
and False if it is a portfolio. Returns None if neither condition is met.
"""
# True means that the requesting entity is a suborganization,
# whereas False means that the requesting entity is a portfolio.
if self.domain_request.requesting_entity_is_suborganization():
return True
elif self.domain_request.requesting_entity_is_portfolio():
return False
else:
return None
class OrganizationTypeForm(RegistrarForm): class OrganizationTypeForm(RegistrarForm):

View file

@ -0,0 +1,95 @@
# Generated by Django 4.2.10 on 2024-11-01 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0135_alter_federalagency_agency_and_more"),
]
operations = [
migrations.AddField(
model_name="domainrequest",
name="requested_suborganization",
field=models.CharField(blank=True, null=True),
),
migrations.AddField(
model_name="domainrequest",
name="suborganization_city",
field=models.CharField(blank=True, null=True),
),
migrations.AddField(
model_name="domainrequest",
name="suborganization_state_territory",
field=models.CharField(
blank=True,
choices=[
("AL", "Alabama (AL)"),
("AK", "Alaska (AK)"),
("AS", "American Samoa (AS)"),
("AZ", "Arizona (AZ)"),
("AR", "Arkansas (AR)"),
("CA", "California (CA)"),
("CO", "Colorado (CO)"),
("CT", "Connecticut (CT)"),
("DE", "Delaware (DE)"),
("DC", "District of Columbia (DC)"),
("FL", "Florida (FL)"),
("GA", "Georgia (GA)"),
("GU", "Guam (GU)"),
("HI", "Hawaii (HI)"),
("ID", "Idaho (ID)"),
("IL", "Illinois (IL)"),
("IN", "Indiana (IN)"),
("IA", "Iowa (IA)"),
("KS", "Kansas (KS)"),
("KY", "Kentucky (KY)"),
("LA", "Louisiana (LA)"),
("ME", "Maine (ME)"),
("MD", "Maryland (MD)"),
("MA", "Massachusetts (MA)"),
("MI", "Michigan (MI)"),
("MN", "Minnesota (MN)"),
("MS", "Mississippi (MS)"),
("MO", "Missouri (MO)"),
("MT", "Montana (MT)"),
("NE", "Nebraska (NE)"),
("NV", "Nevada (NV)"),
("NH", "New Hampshire (NH)"),
("NJ", "New Jersey (NJ)"),
("NM", "New Mexico (NM)"),
("NY", "New York (NY)"),
("NC", "North Carolina (NC)"),
("ND", "North Dakota (ND)"),
("MP", "Northern Mariana Islands (MP)"),
("OH", "Ohio (OH)"),
("OK", "Oklahoma (OK)"),
("OR", "Oregon (OR)"),
("PA", "Pennsylvania (PA)"),
("PR", "Puerto Rico (PR)"),
("RI", "Rhode Island (RI)"),
("SC", "South Carolina (SC)"),
("SD", "South Dakota (SD)"),
("TN", "Tennessee (TN)"),
("TX", "Texas (TX)"),
("UM", "United States Minor Outlying Islands (UM)"),
("UT", "Utah (UT)"),
("VT", "Vermont (VT)"),
("VI", "Virgin Islands (VI)"),
("VA", "Virginia (VA)"),
("WA", "Washington (WA)"),
("WV", "West Virginia (WV)"),
("WI", "Wisconsin (WI)"),
("WY", "Wyoming (WY)"),
("AA", "Armed Forces Americas (AA)"),
("AE", "Armed Forces Africa, Canada, Europe, Middle East (AE)"),
("AP", "Armed Forces Pacific (AP)"),
],
max_length=2,
null=True,
verbose_name="state, territory, or military post",
),
),
]

View file

@ -13,6 +13,8 @@ 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 registrar.utility.waffle import flag_is_active_for_user
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from itertools import chain from itertools import chain
@ -344,6 +346,24 @@ class DomainRequest(TimeStampedModel):
verbose_name="Suborganization", verbose_name="Suborganization",
) )
requested_suborganization = models.CharField(
null=True,
blank=True,
)
suborganization_city = models.CharField(
null=True,
blank=True,
)
suborganization_state_territory = models.CharField(
max_length=2,
choices=StateTerritoryChoices.choices,
null=True,
blank=True,
verbose_name="state, territory, or military post",
)
# This is the domain request user who created this domain request. # This is the domain request user who created this domain request.
creator = models.ForeignKey( creator = models.ForeignKey(
"registrar.User", "registrar.User",
@ -823,10 +843,13 @@ class DomainRequest(TimeStampedModel):
try: try:
if not context: if not context:
has_organization_feature_flag = flag_is_active_for_user(recipient, "organization_feature")
is_org_user = has_organization_feature_flag and recipient.has_base_portfolio_permission(self.portfolio)
context = { context = {
"domain_request": self, "domain_request": self,
# This is the user that we refer to in the email # This is the user that we refer to in the email
"recipient": recipient, "recipient": recipient,
"is_org_user": is_org_user,
} }
if custom_email_content: if custom_email_content:
@ -1102,7 +1125,61 @@ class DomainRequest(TimeStampedModel):
self.creator.restrict_user() self.creator.restrict_user()
# ## Form policies ### def requesting_entity_is_portfolio(self) -> bool:
"""Determines if this record is requesting that a portfolio be their organization.
Used for the RequestingEntity page.
Returns True if the portfolio exists and if organization_name matches portfolio.organization_name.
"""
if self.portfolio and self.organization_name == self.portfolio.organization_name:
return True
return False
def requesting_entity_is_suborganization(self) -> bool:
"""Determines if this record is also requesting that it be tied to a suborganization.
Used for the RequestingEntity page.
Returns True if portfolio exists and either sub_organization exists,
or if is_requesting_new_suborganization() is true.
Returns False otherwise.
"""
if self.portfolio and (self.sub_organization or self.is_requesting_new_suborganization()):
return True
return False
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.
Returns True if a sub_organization does not exist and if requested_suborganization,
suborganization_city, and suborganization_state_territory all exist.
Returns False otherwise.
"""
# If a suborganization already exists, it can't possibly be a new one.
# As well, we need all required fields to exist.
required_fields = [
self.requested_suborganization,
self.suborganization_city,
self.suborganization_state_territory,
]
if not self.sub_organization and all(required_fields):
return True
return False
# ## Form unlocking steps ## #
#
# These methods control the conditions in which we should unlock certain domain wizard steps.
def unlock_requesting_entity(self) -> bool:
"""Unlocks the requesting entity step. Used for the RequestingEntity page.
Returns true if requesting_entity_is_suborganization() and requesting_entity_is_portfolio().
Returns False otherwise.
"""
if self.requesting_entity_is_suborganization() or self.requesting_entity_is_portfolio():
return True
return False
# ## Form policies ## #
# #
# These methods control what questions need to be answered by applicants # These methods control what questions need to be answered by applicants
# during the domain request flow. They are policies about the domain request so # during the domain request flow. They are policies about the domain request so

View file

@ -2,6 +2,8 @@ from django.db import models
from registrar.models.domain_request import DomainRequest from registrar.models.domain_request import DomainRequest
from registrar.models.federal_agency import FederalAgency from registrar.models.federal_agency import FederalAgency
from registrar.models.user import User
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -131,6 +133,17 @@ class Portfolio(TimeStampedModel):
def get_federal_type(cls, federal_agency): def get_federal_type(cls, federal_agency):
return federal_agency.federal_type if federal_agency else None return federal_agency.federal_type if federal_agency else None
@property
def portfolio_admin_users(self):
"""Gets all users with the role organization_admin for this particular portfolio.
Returns a queryset of User."""
admin_ids = self.portfolio_users.filter(
roles__overlap=[
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
],
).values_list("user__id", flat=True)
return User.objects.filter(id__in=admin_ids)
# == Getters for domains == # # == Getters for domains == #
def get_domains(self, order_by=None): def get_domains(self, order_by=None):
"""Returns all DomainInformations associated with this portfolio""" """Returns all DomainInformations associated with this portfolio"""

View file

@ -2,15 +2,58 @@
{% load field_helpers url_helpers %} {% load field_helpers url_helpers %}
{% block form_instructions %} {% block form_instructions %}
<p>🛸🛸🛸🛸 Placeholder content 🛸🛸🛸🛸</p> <p>To help with our review, we need to understand whether the domain you're requesting will be used by the Department of Energy or by one of its suborganizations.</p>
<p>We define a suborganization as any entity (agency, bureau, office) that falls under the overarching organization.</p>
{% endblock %} {% endblock %}
{% block form_fields %} {% block form_fields %}
<fieldset class="usa-fieldset"> <fieldset class="usa-fieldset">
<legend> <legend>
<h2>What is the name of your space vessel?</h2> <h2>Who will use the domain youre requesting?</h2>
</legend> </legend>
{% input_with_errors forms.0.organization_name %} <p class="margin-bottom-0 margin-top-1">
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
</p>
{# forms.0 is a small yes/no form that toggles the visibility of "requesting entity" formset #}
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% with attr_required=True %}
{% input_with_errors forms.0.requesting_entity_is_suborganization %}
{% endwith %}
{% endwith %}
{% comment %} Add an invisible form element to track whether the custom value "other"
was selected or not. This allows for persistence across page reloads without using session variables.
{% endcomment %}
{% with add_group_class="display-none" %}
{% input_with_errors forms.1.is_requesting_new_suborganization %}
{% endwith %}
<div id="suborganization-container" class="margin-top-4">
<h2>Add suborganization information</h2>
<p>
This information will be published in <a class="usa-link usa-link--always-blue" href="{% public_site_url 'about/data' %}">.govs public data</a>. If you dont see your suborganization in the list,
select “other” and enter the name or your suborganization.
</p>
{% with attr_required=True %}
{% input_with_errors forms.1.sub_organization %}
{% endwith %}
{% comment %} This will be toggled if a special value, "other", is selected.
Otherwise this field is invisible.
{% endcomment %}
<div id="suborganization-container__details">
{% with attr_required=True %}
{% input_with_errors forms.1.requested_suborganization %}
{% endwith %}
{% with attr_required=True %}
{% input_with_errors forms.1.suborganization_city %}
{% endwith %}
{% with attr_required=True %}
{% input_with_errors forms.1.suborganization_state_territory %}
{% endwith %}
</div>
</div>
</fieldset> </fieldset>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,28 @@
{% load custom_filters %}SUMMARY OF YOUR DOMAIN REQUEST
Requesting entity: {# if blockmakes a newline #}
{{ domain_request|display_requesting_entity }}
{% if domain_request.current_websites.exists %}
Current websites: {% for site in domain_request.current_websites.all %}
{% spaceless %}{{ site.website }}{% endspaceless %}
{% endfor %}{% endif %}
.gov domain:
{{ domain_request.requested_domain.name }}
{% if domain_request.alternative_domains.all %}
Alternative domains:
{% for site in domain_request.alternative_domains.all %}{% spaceless %}{{ site.website }}{% endspaceless %}
{% endfor %}{% endif %}
Purpose of your domain:
{{ domain_request.purpose }}
{% if domain_request.anything_else %}
Additional details:
{{ domain_request.anything_else }}
{% endif %}
{% if recipient %}
Your contact information:
{% spaceless %}{% include "emails/includes/contact.txt" with contact=recipient %}{% endspaceless %}
{% endif %}
Administrators from your organization:{% for admin in domain_request.portfolio.portfolio_admin_users %}
{% spaceless %}{% if admin != recipient %}{% include "emails/includes/contact.txt" with contact=admin %}{% endif %}{% endspaceless %}
{% endfor %}

View file

@ -31,7 +31,7 @@ THANK YOU
---------------------------------------------------------------- ----------------------------------------------------------------
{% include 'emails/includes/domain_request_summary.txt' %} {% if is_org_user %}{% include 'emails/includes/portfolio_domain_request_summary.txt' %}{% else %}{% include 'emails/includes/domain_request_summary.txt' %}{% endif %}
---------------------------------------------------------------- ----------------------------------------------------------------
The .gov team The .gov team

View file

@ -93,7 +93,7 @@
</li> </li>
{% endif %} {% endif %}
{% if has_organization_members_flag %} {% if has_organization_members_flag and not hide_members %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
<a href="{% url 'members' %}" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}"> <a href="{% url 'members' %}" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
Members Members

View file

@ -8,15 +8,24 @@
{% endif %} {% endif %}
{% if step == Step.REQUESTING_ENTITY %} {% if step == Step.REQUESTING_ENTITY %}
{% if domain_request.organization_name %} {% with title=form_titles|get_item:step %}
{% with title=form_titles|get_item:step value=domain_request %} {% if domain_request.sub_organization %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url address='true' %} {% include "includes/summary_item.html" with value=domain_request.sub_organization edit_link=domain_request_url %}
{% endwith %} {% comment %} We don't have city or state_territory for suborganizations yet, so no data should display {% endcomment %}
{% elif domain_request.requesting_entity_is_suborganization %}
{% include "includes/summary_item.html" with value=domain_request.requested_suborganization edit_link=domain_request_url %}
<p class="margin-y-0">{{domain_request.suborganization_city}}, {{domain_request.suborganization_state_territory}}</p>
{% elif domain_request.requesting_entity_is_portfolio %}
{% include "includes/summary_item.html" with value=domain_request.portfolio.organization_name edit_link=domain_request_url %}
{% if domain_request.portfolio.city and domain_request.portfolio.state_territory %}
<p class="margin-y-0">{{domain_request.portfolio.city}}, {{domain_request.portfolio.state_territory}}</p>
{% endif %}
{% else %} {% else %}
{% with title=form_titles|get_item:step value="<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %} {% with value="<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %}
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %} {% include "includes/summary_item.html" with edit_link=domain_request_url %}
{% endwith %} {% endwith %}
{% endif%} {% endif %}
{% endwith %}
{% endif %} {% endif %}
{% if step == Step.CURRENT_SITES %} {% if step == Step.CURRENT_SITES %}

View file

@ -145,92 +145,96 @@
{% endblock request_summary_header%} {% endblock request_summary_header%}
{% block request_summary %} {% block request_summary %}
{% with heading_level='h3' %} {% if portfolio %}
{% with org_type=DomainRequest.get_generic_org_type_display %} {% include "includes/portfolio_request_review_steps.html" with is_editable=False domain_request=DomainRequest %}
{% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %}
{% endwith %}
{% if DomainRequest.tribe_name %}
{% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %}
{% if DomainRequest.federally_recognized_tribe %}
<p>Federally-recognized tribe</p>
{% endif %}
{% if DomainRequest.state_recognized_tribe %}
<p>State-recognized tribe</p>
{% endif %}
{% endif %}
{% if DomainRequest.get_federal_type_display %}
{% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %}
{% endif %}
{% if DomainRequest.is_election_board %}
{% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %}
{% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %}
{% endwith %}
{% endif %}
{% if DomainRequest.organization_name %}
{% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.about_your_organization %}
{% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %}
{% endif %}
{% if DomainRequest.senior_official %}
{% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.current_websites.all %}
{% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.requested_domain %}
{% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %}
{% endif %}
{% if DomainRequest.alternative_domains.all %}
{% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.purpose %}
{% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %}
{% endif %}
{% if DomainRequest.creator %}
{% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.other_contacts.all %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %}
{% else %} {% else %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %} {% with heading_level='h3' %}
{% endif %} {% with org_type=DomainRequest.get_generic_org_type_display %}
{% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %}
{% endwith %}
{# We always show this field even if None #} {% if DomainRequest.tribe_name %}
{% if DomainRequest %} {% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0"> {% if DomainRequest.federally_recognized_tribe %}
{% if DomainRequest.cisa_representative_first_name %} <p>Federally-recognized tribe</p>
{{ DomainRequest.get_formatted_cisa_rep_name }}
{% else %}
No
{% endif %} {% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3> {% if DomainRequest.state_recognized_tribe %}
<ul class="usa-list usa-list--unstyled margin-top-0"> <p>State-recognized tribe</p>
{% if DomainRequest.anything_else %}
{{DomainRequest.anything_else}}
{% else %}
No
{% endif %} {% endif %}
</ul>
{% endif %} {% endif %}
{% endwith %}
{% if DomainRequest.get_federal_type_display %}
{% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %}
{% endif %}
{% if DomainRequest.is_election_board %}
{% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %}
{% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %}
{% endwith %}
{% endif %}
{% if DomainRequest.organization_name %}
{% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.about_your_organization %}
{% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %}
{% endif %}
{% if DomainRequest.senior_official %}
{% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.current_websites.all %}
{% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.requested_domain %}
{% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %}
{% endif %}
{% if DomainRequest.alternative_domains.all %}
{% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.purpose %}
{% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %}
{% endif %}
{% if DomainRequest.creator %}
{% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.other_contacts.all %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %}
{% else %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %}
{% endif %}
{# We always show this field even if None #}
{% if DomainRequest %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.cisa_representative_first_name %}
{{ DomainRequest.get_formatted_cisa_rep_name }}
{% else %}
No
{% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.anything_else %}
{{DomainRequest.anything_else}}
{% else %}
No
{% endif %}
</ul>
{% endif %}
{% endwith %}
{% endif %}
{% endblock request_summary%} {% endblock request_summary%}
</div> </div>
</main> </main>

View file

@ -257,3 +257,28 @@ def portfolio_role_summary(user, portfolio):
return user.portfolio_role_summary(portfolio) return user.portfolio_role_summary(portfolio)
else: else:
return [] return []
@register.filter(name="display_requesting_entity")
def display_requesting_entity(domain_request):
"""Workaround for a newline issue in .txt files (our emails) as if statements
count as a newline to the file.
Will output something that looks like:
MyOrganizationName
Boise, ID
"""
display = ""
if domain_request.sub_organization:
display = domain_request.sub_organization
elif domain_request.requesting_entity_is_suborganization():
display = (
f"{domain_request.requested_suborganization}\n"
f"{domain_request.suborganization_city}, {domain_request.suborganization_state_territory}"
)
elif domain_request.requesting_entity_is_portfolio():
display = (
f"{domain_request.portfolio.organization_name}\n"
f"{domain_request.portfolio.city}, {domain_request.portfolio.state_territory}"
)
return display

View file

@ -2103,6 +2103,66 @@ class TestPortfolioAdmin(TestCase):
display_members = self.admin.display_members(self.portfolio) display_members = self.admin.display_members(self.portfolio)
self.assertIn(f'<a href="{url}">2 members</a>', display_members) self.assertIn(f'<a href="{url}">2 members</a>', display_members)
@less_console_noise_decorator
def test_senior_official_readonly_for_federal_org(self):
"""Test that senior_official field is readonly for federal organizations"""
request = self.factory.get("/")
request.user = self.superuser
# Create a federal portfolio
portfolio = Portfolio.objects.create(
organization_name="Test Federal Org",
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
creator=self.superuser,
)
readonly_fields = self.admin.get_readonly_fields(request, portfolio)
self.assertIn("senior_official", readonly_fields)
# Change to non-federal org
portfolio.organization_type = DomainRequest.OrganizationChoices.CITY
readonly_fields = self.admin.get_readonly_fields(request, portfolio)
self.assertNotIn("senior_official", readonly_fields)
@less_console_noise_decorator
def test_senior_official_auto_assignment(self):
"""Test automatic senior official assignment based on organization type and federal agency"""
request = self.factory.get("/")
request.user = self.superuser
# Create a federal agency with a senior official
federal_agency = FederalAgency.objects.create(agency="Test Agency")
senior_official = SeniorOfficial.objects.create(
first_name="Test",
last_name="Official",
title="Some guy",
email="test@example.gov",
federal_agency=federal_agency,
)
# Create a federal portfolio
portfolio = Portfolio.objects.create(
organization_name="Test Federal Org",
organization_type=DomainRequest.OrganizationChoices.FEDERAL,
creator=self.superuser,
)
# Test that the federal org gets senior official from agency when federal
portfolio.federal_agency = federal_agency
self.admin.save_model(request, portfolio, form=None, change=False)
self.assertEqual(portfolio.senior_official, senior_official)
# Test non-federal org clears senior official when not city
portfolio.organization_type = DomainRequest.OrganizationChoices.CITY
self.admin.save_model(request, portfolio, form=None, change=True)
self.assertIsNone(portfolio.senior_official)
self.assertEqual(portfolio.federal_agency.agency, "Non-Federal Agency")
# Cleanup
senior_official.delete()
federal_agency.delete()
portfolio.delete()
class TestTransferUser(WebTest): class TestTransferUser(WebTest):
"""User transfer custom admin page""" """User transfer custom admin page"""

View file

@ -1642,6 +1642,9 @@ class TestDomainRequestAdmin(MockEppLib):
"federal_agency", "federal_agency",
"portfolio", "portfolio",
"sub_organization", "sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
"creator", "creator",
"investigator", "investigator",
"generic_org_type", "generic_org_type",
@ -1686,7 +1689,7 @@ class TestDomainRequestAdmin(MockEppLib):
request.user = self.staffuser request.user = self.staffuser
readonly_fields = self.admin.get_readonly_fields(request) readonly_fields = self.admin.get_readonly_fields(request)
self.maxDiff = None
expected_fields = [ expected_fields = [
"other_contacts", "other_contacts",
"current_websites", "current_websites",
@ -1706,6 +1709,9 @@ class TestDomainRequestAdmin(MockEppLib):
"cisa_representative_first_name", "cisa_representative_first_name",
"cisa_representative_last_name", "cisa_representative_last_name",
"cisa_representative_email", "cisa_representative_email",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
] ]
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)

View file

@ -2,6 +2,7 @@ from django.urls import reverse
from api.tests.common import less_console_noise_decorator from api.tests.common import less_console_noise_decorator
from registrar.config import settings from registrar.config import settings
from registrar.models import Portfolio, SeniorOfficial from registrar.models import Portfolio, SeniorOfficial
from unittest.mock import MagicMock
from django_webtest import WebTest # type: ignore from django_webtest import WebTest # type: ignore
from registrar.models import ( from registrar.models import (
DomainRequest, DomainRequest,
@ -9,13 +10,15 @@ from registrar.models import (
DomainInformation, DomainInformation,
UserDomainRole, UserDomainRole,
User, User,
Suborganization,
AllowedEmail,
) )
from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_group import UserGroup from registrar.models.user_group import UserGroup
from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.tests.test_views import TestWithUser from registrar.tests.test_views import TestWithUser
from .common import MockSESClient, completed_domain_request, create_test_user from .common import MockSESClient, completed_domain_request, create_test_user, create_user
from waffle.testutils import override_flag from waffle.testutils import override_flag
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
import boto3_mocking # type: ignore import boto3_mocking # type: ignore
@ -1592,3 +1595,284 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
# Make sure the response is not found # Make sure the response is not found
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
class TestRequestingEntity(WebTest):
"""The requesting entity page is a domain request form that only exists
within the context of a portfolio."""
def setUp(self):
super().setUp()
self.client = Client()
self.user = create_user()
self.portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
self.portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel Alaska")
self.suborganization, _ = Suborganization.objects.get_or_create(
name="Rocky road",
portfolio=self.portfolio,
)
self.suborganization_2, _ = Suborganization.objects.get_or_create(
name="Vanilla",
portfolio=self.portfolio,
)
self.unrelated_suborganization, _ = Suborganization.objects.get_or_create(
name="Cold",
portfolio=self.portfolio_2,
)
self.portfolio_role = UserPortfolioPermission.objects.create(
portfolio=self.portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Login the current user
self.app.set_user(self.user.username)
self.mock_client_class = MagicMock()
self.mock_client = self.mock_client_class.return_value
def tearDown(self):
UserDomainRole.objects.all().delete()
DomainRequest.objects.all().delete()
DomainInformation.objects.all().delete()
Domain.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Suborganization.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
super().tearDown()
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_requesting_entity_page_new_request(self):
"""Tests that the requesting entity page loads correctly when a new request is started"""
response = self.app.get(reverse("domain-request:"))
# Navigate past the intro page
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
intro_form = response.forms[0]
response = intro_form.submit().follow()
# Test the requesting entiy page
self.assertContains(response, "Who will use the domain youre requesting?")
self.assertContains(response, "Add suborganization information")
# We expect to see the portfolio name in two places:
# the header, and as one of the radio button options.
self.assertContains(response, self.portfolio.organization_name, count=2)
# We expect the dropdown list to contain the suborganizations that currently exist on this portfolio
self.assertContains(response, self.suborganization.name, count=1)
self.assertContains(response, self.suborganization_2.name, count=1)
# However, we should only see suborgs that are on the actual portfolio
self.assertNotContains(response, self.unrelated_suborganization.name)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_requesting_entity_page_existing_suborg_submission(self):
"""Tests that you can submit a form on this page and set a suborg"""
response = self.app.get(reverse("domain-request:"))
# Navigate past the intro page
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
form = response.forms[0]
response = form.submit().follow()
# Check that we're on the right page
self.assertContains(response, "Who will use the domain youre requesting?")
form = response.forms[0]
# Test selecting an existing suborg
form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True
form["portfolio_requesting_entity-sub_organization"] = f"{self.suborganization.id}"
form["portfolio_requesting_entity-is_requesting_new_suborganization"] = False
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
response = form.submit().follow()
# Ensure that the post occurred successfully by checking that we're on the following page.
self.assertContains(response, "Current websites")
created_domain_request_exists = DomainRequest.objects.filter(
organization_name__isnull=True, sub_organization=self.suborganization
).exists()
self.assertTrue(created_domain_request_exists)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_requesting_entity_page_new_suborg_submission(self):
"""Tests that you can submit a form on this page and set a new suborg"""
response = self.app.get(reverse("domain-request:"))
# Navigate past the intro page
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
form = response.forms[0]
response = form.submit().follow()
# Check that we're on the right page
self.assertContains(response, "Who will use the domain youre requesting?")
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-sub_organization"] = ""
form["portfolio_requesting_entity-requested_suborganization"] = "moon"
form["portfolio_requesting_entity-suborganization_city"] = "kepler"
form["portfolio_requesting_entity-suborganization_state_territory"] = "AL"
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
response = form.submit().follow()
# Ensure that the post occurred successfully by checking that we're on the following page.
self.assertContains(response, "Current websites")
created_domain_request_exists = DomainRequest.objects.filter(
organization_name__isnull=True,
sub_organization__isnull=True,
requested_suborganization="moon",
suborganization_city="kepler",
suborganization_state_territory=DomainRequest.StateTerritoryChoices.ALABAMA,
).exists()
self.assertTrue(created_domain_request_exists)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_requesting_entity_page_organization_submission(self):
"""Tests submitting an organization on the requesting org form"""
response = self.app.get(reverse("domain-request:"))
# Navigate past the intro page
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
form = response.forms[0]
response = form.submit().follow()
# Check that we're on the right page
self.assertContains(response, "Who will use the domain youre requesting?")
form = response.forms[0]
# Test selecting an existing suborg
form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = False
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
response = form.submit().follow()
# Ensure that the post occurred successfully by checking that we're on the following page.
self.assertContains(response, "Current websites")
created_domain_request_exists = DomainRequest.objects.filter(
organization_name=self.portfolio.organization_name,
).exists()
self.assertTrue(created_domain_request_exists)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_requesting_entity_page_errors(self):
"""Tests that we get the expected form errors on requesting entity"""
domain_request = completed_domain_request(user=self.user, portfolio=self.portfolio)
response = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow()
form = response.forms[0]
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Test missing suborganization selection
form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = True
form["portfolio_requesting_entity-sub_organization"] = ""
response = form.submit()
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-is_requesting_new_suborganization"] = True
response = form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
self.assertContains(response, "Requested suborganization is required.", status_code=200)
self.assertContains(response, "City is required.", status_code=200)
self.assertContains(response, "State, territory, or military post is required.", status_code=200)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@boto3_mocking.patching
@less_console_noise_decorator
def test_requesting_entity_submission_email_sent(self):
"""Tests that an email is sent out on successful form submission"""
AllowedEmail.objects.create(email=self.user.email)
domain_request = completed_domain_request(
user=self.user,
# This is the additional details field
has_anything_else=True,
)
domain_request.portfolio = self.portfolio
domain_request.requested_suborganization = "moon"
domain_request.suborganization_city = "kepler"
domain_request.suborganization_state_territory = DomainRequest.StateTerritoryChoices.ALABAMA
domain_request.save()
domain_request.refresh_from_db()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Anything else", body)
self.assertIn("kepler, AL", body)
self.assertIn("Requesting entity:", body)
self.assertIn("Administrators from your organization:", body)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@boto3_mocking.patching
@less_console_noise_decorator
def test_requesting_entity_viewonly(self):
"""Tests the review steps page on under our viewonly context"""
domain_request = completed_domain_request(
user=create_test_user(),
# This is the additional details field
has_anything_else=True,
)
domain_request.portfolio = self.portfolio
domain_request.requested_suborganization = "moon"
domain_request.suborganization_city = "kepler"
domain_request.suborganization_state_territory = DomainRequest.StateTerritoryChoices.ALABAMA
domain_request.save()
domain_request.refresh_from_db()
domain_request.submit()
response = self.app.get(reverse("domain-request-status-viewonly", kwargs={"pk": domain_request.pk}))
self.assertContains(response, "Requesting entity")
self.assertContains(response, "moon")
self.assertContains(response, "kepler, AL")
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@boto3_mocking.patching
@less_console_noise_decorator
def test_requesting_entity_manage(self):
"""Tests the review steps page on under our manage context"""
domain_request = completed_domain_request(
user=self.user,
# This is the additional details field
has_anything_else=True,
)
domain_request.portfolio = self.portfolio
domain_request.requested_suborganization = "moon"
domain_request.suborganization_city = "kepler"
domain_request.suborganization_state_territory = DomainRequest.StateTerritoryChoices.ALABAMA
domain_request.save()
domain_request.refresh_from_db()
domain_request.submit()
response = self.app.get(reverse("domain-request-status", kwargs={"pk": domain_request.pk}))
self.assertContains(response, "Requesting entity")
self.assertContains(response, "moon")
self.assertContains(response, "kepler, AL")

View file

@ -2887,8 +2887,6 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
detail_page = self.app.get(f"/domain-request/{domain_request.id}") detail_page = self.app.get(f"/domain-request/{domain_request.id}")
self.assertContains(detail_page, "city.gov") self.assertContains(detail_page, "city.gov")
self.assertContains(detail_page, "city1.gov") self.assertContains(detail_page, "city1.gov")
self.assertContains(detail_page, "Chief Tester")
self.assertContains(detail_page, "testy@town.com")
self.assertContains(detail_page, "Status:") self.assertContains(detail_page, "Status:")
# click the "Withdraw request" button # click the "Withdraw request" button
mock_client = MockSESClient() mock_client = MockSESClient()

View file

@ -137,7 +137,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
} }
PORTFOLIO_UNLOCKING_STEPS = { PORTFOLIO_UNLOCKING_STEPS = {
PortfolioDomainRequestStep.REQUESTING_ENTITY: lambda self: self.domain_request.organization_name is not None, PortfolioDomainRequestStep.REQUESTING_ENTITY: lambda w: w.from_model("unlock_requesting_entity", False),
PortfolioDomainRequestStep.CURRENT_SITES: lambda self: ( PortfolioDomainRequestStep.CURRENT_SITES: lambda self: (
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
), ),
@ -320,7 +320,15 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
# Clear context so the prop getter won't create a request here. # Clear context so the prop getter won't create a request here.
# Creating a request will be handled in the post method for the # Creating a request will be handled in the post method for the
# intro page. # intro page.
return render(request, "domain_request_intro.html", {"hide_requests": True, "hide_domains": True}) return render(
request,
"domain_request_intro.html",
{
"hide_requests": True,
"hide_domains": True,
"hide_members": True,
},
)
else: else:
return self.goto(self.steps.first) return self.goto(self.steps.first)
@ -580,7 +588,43 @@ class PortfolioDomainRequestWizard(DomainRequestWizard):
# Portfolio pages # Portfolio pages
class RequestingEntity(DomainRequestWizard): class RequestingEntity(DomainRequestWizard):
template_name = "domain_request_requesting_entity.html" template_name = "domain_request_requesting_entity.html"
forms = [forms.RequestingEntityForm] forms = [forms.RequestingEntityYesNoForm, forms.RequestingEntityForm]
def save(self, forms: list):
"""Override of save to clear or associate certain suborganization data
depending on what the user wishes to do. For instance, we want to add a suborganization
if the user selects one."""
# Get the yes/no dropdown value
yesno_form = forms[0]
yesno_cleaned_data = yesno_form.cleaned_data
requesting_entity_is_suborganization = yesno_cleaned_data.get("requesting_entity_is_suborganization")
# Get the suborg value, and the requested suborg value
requesting_entity_form = forms[1]
cleaned_data = requesting_entity_form.cleaned_data
sub_organization = cleaned_data.get("sub_organization")
requested_suborganization = cleaned_data.get("requested_suborganization")
# Do some data cleanup, depending on what option was checked
if requesting_entity_is_suborganization and (sub_organization or requested_suborganization):
# Cleanup the organization name field, as this isn't for suborganizations.
requesting_entity_form.cleaned_data.update({"organization_name": None})
else:
# If the user doesn't intend to create a suborg, simply don't make one and do some data cleanup
requesting_entity_form.cleaned_data.update(
{
"organization_name": (
self.domain_request.portfolio.organization_name if self.domain_request.portfolio else None
),
"sub_organization": None,
"requested_suborganization": None,
"suborganization_city": None,
"suborganization_state_territory": None,
}
)
super().save(forms)
class PortfolioAdditionalDetails(DomainRequestWizard): class PortfolioAdditionalDetails(DomainRequestWizard):
@ -826,6 +870,18 @@ class DomainRequestStatus(DomainRequestPermissionView):
return True return True
def get_context_data(self, **kwargs):
"""Context override to add a step list to the context"""
context = super().get_context_data(**kwargs)
# Create a temp wizard object to grab the step list
if self.request.user.is_org_user(self.request):
wizard = PortfolioDomainRequestWizard()
wizard.request = self.request
context["Step"] = PortfolioDomainRequestStep.__members__
context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep)
context["form_titles"] = wizard.titles
return context
class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView): class DomainRequestWithdrawConfirmation(DomainRequestPermissionWithdrawView):
"""This page will ask user to confirm if they want to withdraw """This page will ask user to confirm if they want to withdraw