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 @@

DS data record {{forloop.counter}}

- +
diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html index fd6256f39..cc1fc0164 100644 --- a/src/registrar/templates/domain_nameservers.html +++ b/src/registrar/templates/domain_nameservers.html @@ -47,7 +47,7 @@

DNS name servers

{% endwith %}
- {% with sublabel_text="Example: 86.124.49.54 or 2001:db8::1234:5678" add_group_class="usa-form-group--unstyled-error" %} + {% with label_text=form.ip.label sublabel_text="Example: 86.124.49.54 or 2001:db8::1234:5678" add_group_class="usa-form-group--unstyled-error" add_aria_label="Name server "|concat:forloop.counter|concat:" "|concat:form.ip.label %} {% input_with_errors form.ip %} {% endwith %}
@@ -56,6 +56,7 @@

DNS name servers

Delete + Name server {{forloop.counter}} diff --git a/src/registrar/templates/domain_request_requesting_entity.html b/src/registrar/templates/domain_request_requesting_entity.html index ed8dd771c..d09e8ab89 100644 --- a/src/registrar/templates/domain_request_requesting_entity.html +++ b/src/registrar/templates/domain_request_requesting_entity.html @@ -2,15 +2,58 @@ {% load field_helpers url_helpers %} {% block form_instructions %} -

🛸🛸🛸🛸 Placeholder content 🛸🛸🛸🛸

+

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 %}
-

What is the name of your space vessel?

+

Who will use the domain you’re requesting?

- {% input_with_errors forms.0.organization_name %} +

+ Select one. * +

+ + {# 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 %} + +
+

Add suborganization information

+

+ This information will be published in .gov’s public data. If you don’t see your suborganization in the list, + select “other” and enter the name or your suborganization. +

+ {% 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 %} +
+ {% 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 %} +
+
{% 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 @@

Domain requests

Reset -