diff --git a/.github/workflows/clone-staging.yaml b/.github/workflows/clone-staging.yaml
index e2aa4e1d3..3e0504700 100644
--- a/.github/workflows/clone-staging.yaml
+++ b/.github/workflows/clone-staging.yaml
@@ -16,7 +16,7 @@ env:
jobs:
clone-database:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
env:
CF_USERNAME: ${{ secrets.CF_MS_USERNAME }}
CF_PASSWORD: ${{ secrets.CF_MS_PASSWORD }}
@@ -26,9 +26,10 @@ jobs:
# install cf cli and other tools
wget -q -O - https://packages.cloudfoundry.org/debian/cli.cloudfoundry.org.key | sudo gpg --dearmor -o /usr/share/keyrings/cli.cloudfoundry.org.gpg
echo "deb [signed-by=/usr/share/keyrings/cli.cloudfoundry.org.gpg] https://packages.cloudfoundry.org/debian stable main" | sudo tee /etc/apt/sources.list.d/cloudfoundry-cli.list
-
+
sudo apt-get update
- sudo apt-get install cf8-cli postgresql-client
+ sudo apt-get install cf8-cli
+
# install cg-manage-rds tool
pip install git+https://github.com/cloud-gov/cg-manage-rds.git
diff --git a/src/.pa11yci b/src/.pa11yci
index c18704c07..6a5ce4f26 100644
--- a/src/.pa11yci
+++ b/src/.pa11yci
@@ -20,6 +20,9 @@
"http://localhost:8080/request/anything_else/",
"http://localhost:8080/request/requirements/",
"http://localhost:8080/request/finished/",
- "http://localhost:8080/user-profile/"
+ "http://localhost:8080/request/requesting_entity/",
+ "http://localhost:8080/user-profile/",
+ "http://localhost:8080/members/",
+ "http://localhost:8080/members/new-member"
]
}
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 0b96b4c48..8a0a458f8 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -5,6 +5,7 @@
from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
+from registrar.models.federal_agency import FederalAgency
from registrar.utility.admin_helpers import (
get_action_needed_reason_default_email,
get_rejection_reason_default_email,
@@ -28,6 +29,7 @@
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices
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 django.contrib.admin.views.main import ORDER_VAR
from registrar.widgets import NoAutocompleteFilteredSelectMultiple
@@ -1478,7 +1480,18 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain."
fieldsets = [
- (None, {"fields": ["portfolio", "sub_organization", "creator", "domain_request", "notes"]}),
+ (
+ None,
+ {
+ "fields": [
+ "portfolio",
+ "sub_organization",
+ "creator",
+ "domain_request",
+ "notes",
+ ]
+ },
+ ),
(".gov domain", {"fields": ["domain"]}),
("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}),
("Background info", {"fields": ["anything_else"]}),
@@ -1748,6 +1761,9 @@ def status_history(self, obj):
"fields": [
"portfolio",
"sub_organization",
+ "requested_suborganization",
+ "suborganization_city",
+ "suborganization_state_territory",
"status_history",
"status",
"rejection_reason",
@@ -1849,6 +1865,9 @@ def status_history(self, obj):
"cisa_representative_first_name",
"cisa_representative_last_name",
"cisa_representative_email",
+ "requested_suborganization",
+ "suborganization_city",
+ "suborganization_state_territory",
]
autocomplete_fields = [
"approved_domain",
@@ -1868,6 +1887,25 @@ def status_history(self, obj):
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
def save_model(self, request, obj, form, change):
"""Custom save_model definition that handles edge cases"""
@@ -3206,6 +3244,14 @@ def get_readonly_fields(self, request, obj=None):
# straightforward and the readonly_fields list can control their behavior
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"):
return readonly_fields
@@ -3228,12 +3274,11 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context["domain_requests"] = obj.get_domain_requests(order_by=["requested_domain__name"])
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:
# ---- update creator ----
# 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 ----
# 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
@@ -3243,12 +3288,19 @@ def save_model(self, request, obj, form, change):
if is_federal and obj.organization_name is None:
obj.organization_name = obj.federal_agency.agency
- # Remove this line when senior_official is no longer readonly in /admin.
- if obj.federal_agency:
- if obj.federal_agency.so_federal_agency.exists():
- obj.senior_official = obj.federal_agency.so_federal_agency.first()
- else:
- obj.senior_official = None
+ # Set the senior official field to the senior official on the federal agency
+ # when federal - otherwise, clear the field.
+ if obj.organization_type == obj.OrganizationChoices.FEDERAL:
+ if obj.federal_agency:
+ if obj.federal_agency.so_federal_agency.exists():
+ 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)
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index fd50fbb0c..a5c55acb1 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -47,10 +47,49 @@ function addOrRemoveSessionBoolean(name, add){
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// 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.
+
/** An IIFE for pages in DjangoAdmin that use modals.
* Dja strips out form elements, and modals generate their content outside
* 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.
var contactList = document.querySelector(".field-senior_official .dja-address-contact-list");
+ const federalAgencyContainer = document.querySelector(".field-federal_agency");
document.addEventListener('DOMContentLoaded', function() {
let isPortfolioPage = document.getElementById("portfolio_form");
@@ -975,11 +1015,13 @@ document.addEventListener('DOMContentLoaded', function() {
let selectedValue = organizationType.value;
if (selectedValue === "federal") {
hideElement(organizationNameContainer);
+ showElement(federalAgencyContainer);
if (federalType) {
showElement(federalType);
}
} else {
showElement(organizationNameContainer);
+ hideElement(federalAgencyContainer);
if (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");
+ }
+})();
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index fac25d2b0..adcc21d2a 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -26,8 +26,8 @@ const hideElement = (element) => {
};
/**
- * Show element
- *
+* Show element
+*
*/
const showElement = (element) => {
element.classList.remove('display-none');
@@ -297,28 +297,56 @@ function clearValidators(el) {
* radio button is false (hides this element if true)
* **/
function HookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToShowIfNo) {
+ HookupRadioTogglerListener(radioButtonName, {
+ 'True': elementIdToShowIfYes,
+ 'False': elementIdToShowIfNo
+ });
+}
+
+/**
+ * Hookup listeners for radio togglers in form fields.
+ *
+ * Parameters:
+ * - radioButtonName: The "name=" value for the radio buttons being used as togglers
+ * - valueToElementMap: An object where keys are the values of the radio buttons,
+ * and values are the corresponding DOM element IDs to show. All other elements will be hidden.
+ *
+ * Usage Example:
+ * Assuming you have radio buttons with values 'option1', 'option2', and 'option3',
+ * and corresponding DOM IDs 'section1', 'section2', 'section3'.
+ *
+ * HookupValueBasedListener('exampleRadioGroup', {
+ * 'option1': 'section1',
+ * 'option2': 'section2',
+ * 'option3': 'section3'
+ * });
+ **/
+function HookupRadioTogglerListener(radioButtonName, valueToElementMap) {
// Get the radio buttons
let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]');
+
+ // Extract the list of all element IDs from the valueToElementMap
+ let allElementIds = Object.values(valueToElementMap);
function handleRadioButtonChange() {
- // Check the value of the selected radio button
- // Attempt to find the radio button element that is checked
+ // Find the checked radio button
let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked');
-
- // Check if the element exists before accessing its value
let selectedValue = radioButtonChecked ? radioButtonChecked.value : null;
- switch (selectedValue) {
- case 'True':
- toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 1);
- break;
-
- case 'False':
- toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 2);
- break;
+ // Hide all elements by default
+ allElementIds.forEach(function (elementId) {
+ let element = document.getElementById(elementId);
+ if (element) {
+ hideElement(element);
+ }
+ });
- default:
- toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 0);
+ // Show the relevant element for the selected value
+ if (selectedValue && valueToElementMap[selectedValue]) {
+ let elementToShow = document.getElementById(valueToElementMap[selectedValue]);
+ if (elementToShow) {
+ showElement(elementToShow);
+ }
}
}
@@ -328,11 +356,12 @@ function HookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToS
radioButton.addEventListener('change', handleRadioButtonChange);
});
- // initialize
+ // Initialize by checking the current state
handleRadioButtonChange();
}
}
+
// A generic display none/block toggle function that takes an integer param to indicate how the elements toggle
function toggleTwoDomElements(ele1, ele2, index) {
let element1 = document.getElementById(ele1);
@@ -912,6 +941,18 @@ function setupUrbanizationToggle(stateTerritoryField) {
HookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null)
})();
+
+/**
+ * An IIFE that listens to the yes/no radio buttons on the anything else form and toggles form field visibility accordingly
+ *
+ */
+(function newMemberFormListener() {
+ HookupRadioTogglerListener('member_access_level', {
+ 'admin': 'new-member-admin-permissions',
+ 'basic': 'new-member-basic-permissions'
+ });
+})();
+
/**
* An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms
*
@@ -2734,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());
+})();
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index f61e31e54..d289eaf90 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -120,6 +120,11 @@
# views.PortfolioNoMembersView.as_view(),
# name="no-portfolio-members",
# ),
+ path(
+ "members/new-member/",
+ views.NewMemberView.as_view(),
+ name="new-member",
+ ),
path(
"requests/",
views.PortfolioDomainRequestsView.as_view(),
diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py
index 3e413e8d8..bfbc22124 100644
--- a/src/registrar/forms/domain_request_wizard.py
+++ b/src/registrar/forms/domain_request_wizard.py
@@ -13,7 +13,7 @@
BaseYesNoForm,
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.utility.enums import ValidationReturnType
from registrar.utility.constants import BranchChoices
@@ -22,11 +22,147 @@
class RequestingEntityForm(RegistrarForm):
- organization_name = forms.CharField(
- label="Organization name",
- error_messages={"required": "Enter the name of your organization."},
+ """The requesting entity form contains a dropdown for suborganizations,
+ and some (hidden by default) input fields that allow the user to request for a suborganization.
+ 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):
generic_org_type = forms.ChoiceField(
diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py
index 7c8d2f171..5309f7263 100644
--- a/src/registrar/forms/portfolio.py
+++ b/src/registrar/forms/portfolio.py
@@ -3,6 +3,7 @@
import logging
from django import forms
from django.core.validators import RegexValidator
+from django.core.validators import MaxLengthValidator
from registrar.models import (
PortfolioInvitation,
@@ -10,6 +11,7 @@
DomainInformation,
Portfolio,
SeniorOfficial,
+ User,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@@ -160,3 +162,112 @@ class Meta:
"roles",
"additional_permissions",
]
+
+
+class NewMemberForm(forms.ModelForm):
+ member_access_level = forms.ChoiceField(
+ label="Select permission",
+ choices=[("admin", "Admin Access"), ("basic", "Basic Access")],
+ widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}),
+ required=True,
+ error_messages={
+ "required": "Member access level is required",
+ },
+ )
+ admin_org_domain_request_permissions = forms.ChoiceField(
+ label="Select permission",
+ choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")],
+ widget=forms.RadioSelect,
+ required=True,
+ error_messages={
+ "required": "Admin domain request permission is required",
+ },
+ )
+ admin_org_members_permissions = forms.ChoiceField(
+ label="Select permission",
+ choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")],
+ widget=forms.RadioSelect,
+ required=True,
+ error_messages={
+ "required": "Admin member permission is required",
+ },
+ )
+ basic_org_domain_request_permissions = forms.ChoiceField(
+ label="Select permission",
+ choices=[
+ ("view_only", "View all requests"),
+ ("view_and_create", "View all requests plus create requests"),
+ ("no_access", "No access"),
+ ],
+ widget=forms.RadioSelect,
+ required=True,
+ error_messages={
+ "required": "Basic member permission is required",
+ },
+ )
+
+ email = forms.EmailField(
+ label="Enter the email of the member you'd like to invite",
+ max_length=None,
+ error_messages={
+ "invalid": ("Enter an email address in the required format, like name@example.com."),
+ "required": ("Enter an email address in the required format, like name@example.com."),
+ },
+ validators=[
+ MaxLengthValidator(
+ 320,
+ message="Response must be less than 320 characters.",
+ )
+ ],
+ required=True,
+ )
+
+ class Meta:
+ model = User
+ fields = ["email"]
+
+ def clean(self):
+ cleaned_data = super().clean()
+
+ # Lowercase the value of the 'email' field
+ email_value = cleaned_data.get("email")
+ if email_value:
+ cleaned_data["email"] = email_value.lower()
+
+ ##########################################
+ # TODO: future ticket
+ # (invite new member)
+ ##########################################
+ # Check for an existing user (if there isn't any, send an invite)
+ # if email_value:
+ # try:
+ # existingUser = User.objects.get(email=email_value)
+ # except User.DoesNotExist:
+ # raise forms.ValidationError("User with this email does not exist.")
+
+ member_access_level = cleaned_data.get("member_access_level")
+
+ # Intercept the error messages so that we don't validate hidden inputs
+ if not member_access_level:
+ # If no member access level has been selected, delete error messages
+ # for all hidden inputs (which is everything except the e-mail input
+ # and member access selection)
+ for field in self.fields:
+ if field in self.errors and field != "email" and field != "member_access_level":
+ del self.errors[field]
+ return cleaned_data
+
+ basic_dom_req_error = "basic_org_domain_request_permissions"
+ admin_dom_req_error = "admin_org_domain_request_permissions"
+ admin_member_error = "admin_org_members_permissions"
+
+ if member_access_level == "admin" and basic_dom_req_error in self.errors:
+ # remove the error messages pertaining to basic permission inputs
+ del self.errors[basic_dom_req_error]
+ elif member_access_level == "basic":
+ # remove the error messages pertaining to admin permission inputs
+ if admin_dom_req_error in self.errors:
+ del self.errors[admin_dom_req_error]
+ if admin_member_error in self.errors:
+ del self.errors[admin_member_error]
+ return cleaned_data
diff --git a/src/registrar/migrations/0136_domainrequest_requested_suborganization_and_more.py b/src/registrar/migrations/0136_domainrequest_requested_suborganization_and_more.py
new file mode 100644
index 000000000..e1b130b4f
--- /dev/null
+++ b/src/registrar/migrations/0136_domainrequest_requested_suborganization_and_more.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py
index 5f98197bd..7dadf26ac 100644
--- a/src/registrar/models/domain_information.py
+++ b/src/registrar/models/domain_information.py
@@ -425,3 +425,70 @@ def get_state_display_of_domain(self):
return self.domain.get_state_display()
else:
return None
+
+ @property
+ def converted_organization_name(self):
+ if self.portfolio:
+ return self.portfolio.organization_name
+ return self.organization_name
+
+ # ----- Portfolio Properties -----
+ @property
+ def converted_generic_org_type(self):
+ if self.portfolio:
+ return self.portfolio.organization_type
+ return self.generic_org_type
+
+ @property
+ def converted_federal_agency(self):
+ if self.portfolio:
+ return self.portfolio.federal_agency
+ return self.federal_agency
+
+ @property
+ def converted_federal_type(self):
+ if self.portfolio:
+ return self.portfolio.federal_type
+ return self.federal_type
+
+ @property
+ def converted_senior_official(self):
+ if self.portfolio:
+ return self.portfolio.senior_official
+ return self.senior_official
+
+ @property
+ def converted_address_line1(self):
+ if self.portfolio:
+ return self.portfolio.address_line1
+ return self.address_line1
+
+ @property
+ def converted_address_line2(self):
+ if self.portfolio:
+ return self.portfolio.address_line2
+ return self.address_line2
+
+ @property
+ def converted_city(self):
+ if self.portfolio:
+ return self.portfolio.city
+ return self.city
+
+ @property
+ def converted_state_territory(self):
+ if self.portfolio:
+ return self.portfolio.state_territory
+ return self.state_territory
+
+ @property
+ def converted_zipcode(self):
+ if self.portfolio:
+ return self.portfolio.zipcode
+ return self.zipcode
+
+ @property
+ def converted_urbanization(self):
+ if self.portfolio:
+ return self.portfolio.urbanization
+ return self.urbanization
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index 7f793f3e0..ca4b322be 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -13,6 +13,8 @@
from registrar.utility.constants import BranchChoices
from auditlog.models import LogEntry
+from registrar.utility.waffle import flag_is_active_for_user
+
from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError
from itertools import chain
@@ -344,6 +346,24 @@ def get_action_needed_reason_label(cls, action_needed_reason: str):
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.
creator = models.ForeignKey(
"registrar.User",
@@ -823,10 +843,13 @@ def _send_status_update_email(
try:
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 = {
"domain_request": self,
# This is the user that we refer to in the email
"recipient": recipient,
+ "is_org_user": is_org_user,
}
if custom_email_content:
@@ -1102,7 +1125,61 @@ def reject_with_prejudice(self):
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
# during the domain request flow. They are policies about the domain request so
diff --git a/src/registrar/models/portfolio.py b/src/registrar/models/portfolio.py
index 8d820e105..82afcd4d6 100644
--- a/src/registrar/models/portfolio.py
+++ b/src/registrar/models/portfolio.py
@@ -2,6 +2,8 @@
from registrar.models.domain_request import DomainRequest
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
@@ -131,6 +133,17 @@ def federal_type(self):
def get_federal_type(cls, federal_agency):
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 == #
def get_domains(self, order_by=None):
"""Returns all DomainInformations associated with this portfolio"""
diff --git a/src/registrar/templates/django/forms/widgets/input.html b/src/registrar/templates/django/forms/widgets/input.html
index f47fc6415..e7b43655d 100644
--- a/src/registrar/templates/django/forms/widgets/input.html
+++ b/src/registrar/templates/django/forms/widgets/input.html
@@ -4,6 +4,7 @@
{# hint: spacing in the class string matters #}
class="{{ uswds_input_class }}{% if classes %} {{ classes }}{% endif %}"
{% if widget.value != None %}value="{{ widget.value|stringformat:'s' }}"{% endif %}
+ {% if aria_label %}aria-label="{{ aria_label }} {{ label }}"{% endif %}
{% if sublabel_text %}aria-describedby="{{ widget.attrs.id }}__sublabel"{% endif %}
{% include "django/forms/widgets/attrs.html" %}
/>
diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html
index 6e18bce13..ba742ab09 100644
--- a/src/registrar/templates/domain_dsdata.html
+++ b/src/registrar/templates/domain_dsdata.html
@@ -63,11 +63,12 @@
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.
+
We define a suborganization as any entity (agency, bureau, office) that falls under the overarching organization.
{% endblock %}
{% block form_fields %}
{% endblock %}
diff --git a/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt b/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt
new file mode 100644
index 000000000..866fde50f
--- /dev/null
+++ b/src/registrar/templates/emails/includes/portfolio_domain_request_summary.txt
@@ -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 %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/submission_confirmation.txt b/src/registrar/templates/emails/submission_confirmation.txt
index c8ff4c7eb..ef9736a9d 100644
--- a/src/registrar/templates/emails/submission_confirmation.txt
+++ b/src/registrar/templates/emails/submission_confirmation.txt
@@ -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
diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html
index 5b7604222..5142131af 100644
--- a/src/registrar/templates/includes/domain_requests_table.html
+++ b/src/registrar/templates/includes/domain_requests_table.html
@@ -23,7 +23,7 @@