diff --git a/.gitignore b/.gitignore index f2d82f599c..3092682c27 100644 --- a/.gitignore +++ b/.gitignore @@ -171,6 +171,9 @@ node_modules # Vim *.swp +# VS Code +.vscode + # Compliance/trestle related docs/compliance/.trestle/cache diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 0cab01d319..042666619b 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1,12 +1,14 @@ from datetime import date import logging import copy +from typing import Optional from django import forms 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 ( + AutocompleteSelectWithPlaceholder, get_action_needed_reason_default_email, get_rejection_reason_default_email, get_field_links_as_list, @@ -236,6 +238,14 @@ class Meta: "current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False), "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), + "portfolio": AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"} + ), + "sub_organization": AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field("sub_organization"), + admin.site, + attrs={"data-placeholder": "---------", "ajax-url": "get-suborganization-list-json"}, + ), } labels = { "action_needed_reason_email": "Email", @@ -1816,6 +1826,70 @@ def custom_election_board(self, obj): custom_election_board.admin_order_field = "is_election_board" # type: ignore custom_election_board.short_description = "Election office" # type: ignore + # Define methods to display fields from the related portfolio + def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: + return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None + + portfolio_senior_official.short_description = "Senior official" # type: ignore + + def portfolio_organization_type(self, obj): + return ( + DomainRequest.OrganizationChoices.get_org_label(obj.portfolio.organization_type) + if obj.portfolio and obj.portfolio.organization_type + else "-" + ) + + portfolio_organization_type.short_description = "Organization type" # type: ignore + + def portfolio_federal_type(self, obj): + return ( + BranchChoices.get_branch_label(obj.portfolio.federal_type) + if obj.portfolio and obj.portfolio.federal_type + else "-" + ) + + portfolio_federal_type.short_description = "Federal type" # type: ignore + + def portfolio_organization_name(self, obj): + return obj.portfolio.organization_name if obj.portfolio else "" + + portfolio_organization_name.short_description = "Organization name" # type: ignore + + def portfolio_federal_agency(self, obj): + return obj.portfolio.federal_agency if obj.portfolio else "" + + portfolio_federal_agency.short_description = "Federal agency" # type: ignore + + def portfolio_state_territory(self, obj): + return obj.portfolio.state_territory if obj.portfolio else "" + + portfolio_state_territory.short_description = "State, territory, or military post" # type: ignore + + def portfolio_address_line1(self, obj): + return obj.portfolio.address_line1 if obj.portfolio else "" + + portfolio_address_line1.short_description = "Address line 1" # type: ignore + + def portfolio_address_line2(self, obj): + return obj.portfolio.address_line2 if obj.portfolio else "" + + portfolio_address_line2.short_description = "Address line 2" # type: ignore + + def portfolio_city(self, obj): + return obj.portfolio.city if obj.portfolio else "" + + portfolio_city.short_description = "City" # type: ignore + + def portfolio_zipcode(self, obj): + return obj.portfolio.zipcode if obj.portfolio else "" + + portfolio_zipcode.short_description = "Zip code" # type: ignore + + def portfolio_urbanization(self, obj): + return obj.portfolio.urbanization if obj.portfolio else "" + + portfolio_urbanization.short_description = "Urbanization" # type: ignore + # This is just a placeholder. This field will be populated in the detail_table_fieldset view. # This is not a field that exists on the model. def status_history(self, obj): @@ -1847,30 +1921,38 @@ def status_history(self, obj): None, { "fields": [ - "portfolio", - "sub_organization", - "requested_suborganization", - "suborganization_city", - "suborganization_state_territory", "status_history", "status", "rejection_reason", "rejection_reason_email", "action_needed_reason", "action_needed_reason_email", - "investigator", - "creator", "approved_domain", + "investigator", "notes", ] }, ), + ( + "Requested by", + { + "fields": [ + "portfolio", + "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", + "creator", + ] + }, + ), (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), ( "Contacts", { "fields": [ "senior_official", + "portfolio_senior_official", "other_contacts", "no_other_contacts_rationale", "cisa_representative_first_name", @@ -1927,10 +2009,55 @@ def status_history(self, obj): ], }, ), + # the below three sections are for portfolio fields + ( + "Type of organization", + { + "fields": [ + "portfolio_organization_type", + "portfolio_federal_type", + ] + }, + ), + ( + "Organization name and mailing address", + { + "fields": [ + "portfolio_organization_name", + "portfolio_federal_agency", + ] + }, + ), + ( + "Show details", + { + "classes": ["collapse--dgfieldset"], + "description": "Extends organization name and mailing address", + "fields": [ + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + ], + }, + ), ] # Readonly fields for analysts and superusers readonly_fields = ( + "portfolio_senior_official", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", "other_contacts", "current_websites", "alternative_domains", @@ -1979,10 +2106,12 @@ def status_history(self, obj): def get_fieldsets(self, request, obj=None): fieldsets = super().get_fieldsets(request, obj) - # Hide certain suborg fields behind the organization feature flag + # Hide certain portfolio and suborg fields behind the organization requests flag # if it is not enabled - if not flag_is_active_for_user(request.user, "organization_feature"): + if not flag_is_active_for_user(request.user, "organization_requests"): excluded_fields = [ + "portfolio", + "sub_organization", "requested_suborganization", "suborganization_city", "suborganization_state_territory", diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index a5c55acb17..9fd15b9f94 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -86,6 +86,506 @@ function handleSuborganizationFields( portfolioDropdown.on("change", toggleSuborganizationFields); } + +/** + * This function handles the portfolio selection as well as display of + * portfolio-related fields in the DomainRequest Form. + * + * IMPORTANT NOTE: The logic in this method is paired dynamicPortfolioFields +*/ +function handlePortfolioSelection() { + // These dropdown are select2 fields so they must be interacted with via jquery + const portfolioDropdown = django.jQuery("#id_portfolio"); + const suborganizationDropdown = django.jQuery("#id_sub_organization"); + const suborganizationField = document.querySelector(".field-sub_organization"); + const requestedSuborganizationField = document.querySelector(".field-requested_suborganization"); + const suborganizationCity = document.querySelector(".field-suborganization_city"); + const suborganizationStateTerritory = document.querySelector(".field-suborganization_state_territory"); + const seniorOfficialField = document.querySelector(".field-senior_official"); + const otherEmployeesField = document.querySelector(".field-other_contacts"); + const noOtherContactsRationaleField = document.querySelector(".field-no_other_contacts_rationale"); + const cisaRepresentativeFirstNameField = document.querySelector(".field-cisa_representative_first_name"); + const cisaRepresentativeLastNameField = document.querySelector(".field-cisa_representative_last_name"); + const cisaRepresentativeEmailField = document.querySelector(".field-cisa_representative_email"); + const orgTypeFieldSet = document.querySelector(".field-is_election_board").parentElement; + const orgTypeFieldSetDetails = orgTypeFieldSet.nextElementSibling; + const orgNameFieldSet = document.querySelector(".field-organization_name").parentElement; + const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling; + const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official"); + const portfolioSeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly"); + const portfolioSeniorOfficialAddress = portfolioSeniorOfficialField.querySelector(".dja-address-contact-list"); + const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement; + const portfolioOrgType = document.querySelector(".field-portfolio_organization_type .readonly"); + const portfolioFederalTypeField = document.querySelector(".field-portfolio_federal_type"); + const portfolioFederalType = portfolioFederalTypeField.querySelector(".readonly"); + const portfolioOrgNameField = document.querySelector(".field-portfolio_organization_name") + const portfolioOrgName = portfolioOrgNameField.querySelector(".readonly"); + const portfolioOrgNameFieldSet = portfolioOrgNameField.parentElement; + const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling; + const portfolioFederalAgencyField = document.querySelector(".field-portfolio_federal_agency"); + const portfolioFederalAgency = portfolioFederalAgencyField.querySelector(".readonly"); + const portfolioStateTerritory = document.querySelector(".field-portfolio_state_territory .readonly"); + const portfolioAddressLine1 = document.querySelector(".field-portfolio_address_line1 .readonly"); + const portfolioAddressLine2 = document.querySelector(".field-portfolio_address_line2 .readonly"); + const portfolioCity = document.querySelector(".field-portfolio_city .readonly"); + const portfolioZipcode = document.querySelector(".field-portfolio_zipcode .readonly"); + const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization"); + const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly"); + const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; + let isPageLoading = true; + + /** + * Fetches portfolio data by ID using an AJAX call. + * + * @param {number|string} portfolio_id - The ID of the portfolio to retrieve. + * @returns {Promise} - A promise that resolves to the portfolio data object if successful, + * or null if there was an error. + * + * This function performs an asynchronous fetch request to retrieve portfolio data. + * If the request is successful, it returns the portfolio data as an object. + * If an error occurs during the request or the data contains an error, it logs the error + * to the console and returns null. + */ + function getPortfolio(portfolio_id) { + return fetch(`${portfolioJsonUrl}?id=${portfolio_id}`) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + return null; + } else { + return data; + } + }) + .catch(error => { + console.error("Error retrieving portfolio", error); + return null; + }); + } + + /** + * Updates various UI elements with the data from a given portfolio object. + * + * @param {Object} portfolio - The portfolio data object containing values to populate in the UI. + * + * This function updates multiple fields in the UI to reflect data in the `portfolio` object: + * - Clears and replaces selections in the `suborganizationDropdown` with values from `portfolio.suborganizations`. + * - Calls `updatePortfolioSeniorOfficial` to set the senior official information. + * - Sets the portfolio organization type, federal type, name, federal agency, and other address-related fields. + * + * The function expects that elements like `portfolioOrgType`, `portfolioFederalAgency`, etc., + * are already defined and accessible in the global scope. + */ + function updatePortfolioFieldsData(portfolio) { + // replace selections in suborganizationDropdown with + // values in portfolio.suborganizations + suborganizationDropdown.empty(); + // update portfolio senior official + updatePortfolioSeniorOfficial(portfolio.senior_official); + // update portfolio organization type + portfolioOrgType.innerText = portfolio.organization_type; + // update portfolio federal type + portfolioFederalType.innerText = portfolio.federal_type + // update portfolio organization name + portfolioOrgName.innerText = portfolio.organization_name; + // update portfolio federal agency + portfolioFederalAgency.innerText = portfolio.federal_agency ? portfolio.federal_agency.agency : ''; + // update portfolio state + portfolioStateTerritory.innerText = portfolio.state_territory; + // update portfolio address line 1 + portfolioAddressLine1.innerText = portfolio.address_line1; + // update portfolio address line 2 + portfolioAddressLine2.innerText = portfolio.address_line2; + // update portfolio city + portfolioCity.innerText = portfolio.city; + // update portfolio zip code + portfolioZipcode.innerText = portfolio.zipcode + // update portfolio urbanization + portfolioUrbanization.innerText = portfolio.urbanization; + } + + /** + * Updates the UI to display the senior official information from a given object. + * + * @param {Object} senior_official - The senior official's data object, containing details like + * first name, last name, and ID. If `senior_official` is null, displays a default message. + * + * This function: + * - Displays the senior official's name as a link (if available) in the `portfolioSeniorOfficial` element. + * - If a senior official exists, it sets `portfolioSeniorOfficialAddress` to show the official's contact info + * and displays it by calling `updateSeniorOfficialContactInfo`. + * - If no senior official is provided, it hides `portfolioSeniorOfficialAddress` and shows a "No senior official found." message. + * + * Dependencies: + * - Expects the `portfolioSeniorOfficial` and `portfolioSeniorOfficialAddress` elements to be available globally. + * - Uses `showElement` and `hideElement` for visibility control. + */ + function updatePortfolioSeniorOfficial(senior_official) { + if (senior_official) { + let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(' '); + let seniorOfficialLink = `${seniorOfficialName}` + portfolioSeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + updateSeniorOfficialContactInfo(portfolioSeniorOfficialAddress, senior_official); + showElement(portfolioSeniorOfficialAddress); + } else { + portfolioSeniorOfficial.innerText = "No senior official found."; + hideElement(portfolioSeniorOfficialAddress); + } + } + + /** + * Populates and displays contact information for a senior official within a specified address field element. + * + * @param {HTMLElement} addressField - The DOM element containing contact info fields for the senior official. + * @param {Object} senior_official - The senior official's data object, containing properties like title, email, and phone. + * + * This function: + * - Sets the `title`, `email`, and `phone` fields in `addressField` to display the senior official's data. + * - Updates the `titleSpan` with the official's title, or "None" if unavailable. + * - Updates the `emailSpan` with the official's email, or "None" if unavailable. + * - If an email is provided, populates `hiddenInput` with the email for copying and shows the `copyButton`. + * - If no email is provided, hides the `copyButton`. + * - Updates the `phoneSpan` with the official's phone number, or "None" if unavailable. + * + * Dependencies: + * - Uses `showElement` and `hideElement` to control visibility of the `copyButton`. + * - Expects `addressField` to have specific classes (.contact_info_title, .contact_info_email, etc.) for query selectors to work. + */ + function updateSeniorOfficialContactInfo(addressField, senior_official) { + const titleSpan = addressField.querySelector(".contact_info_title"); + const emailSpan = addressField.querySelector(".contact_info_email"); + const phoneSpan = addressField.querySelector(".contact_info_phone"); + const hiddenInput = addressField.querySelector("input"); + const copyButton = addressField.querySelector(".admin-icon-group"); + if (titleSpan) { + titleSpan.textContent = senior_official.title || "None"; + }; + if (emailSpan) { + emailSpan.textContent = senior_official.email || "None"; + if (senior_official.email) { + hiddenInput.value = senior_official.email; + showElement(copyButton); + }else { + hideElement(copyButton); + } + } + if (phoneSpan) { + phoneSpan.textContent = senior_official.phone || "None"; + }; + } + + /** + * Dynamically updates the visibility of certain portfolio fields based on specific conditions. + * + * This function adjusts the display of fields within the portfolio UI based on: + * - The presence of a senior official's contact information. + * - The selected state or territory, affecting the visibility of the urbanization field. + * - The organization type (Federal vs. non-Federal), toggling the visibility of related fields. + * + * Functionality: + * 1. **Senior Official Contact Info Display**: + * - If `portfolioSeniorOfficial` contains "No additional contact information found", + * hides `portfolioSeniorOfficialAddress`; otherwise, shows it. + * + * 2. **Urbanization Field Display**: + * - Displays `portfolioUrbanizationField` only when the `portfolioStateTerritory` value is "PR" (Puerto Rico). + * + * 3. **Federal Organization Type Display**: + * - If `portfolioOrgType` is "Federal", hides `portfolioOrgNameField` and shows both `portfolioFederalAgencyField` + * and `portfolioFederalTypeField`. + * - If not Federal, shows `portfolioOrgNameField` and hides `portfolioFederalAgencyField` and `portfolioFederalTypeField`. + * - Certain text fields (Organization Type, Organization Name, Federal Type, Federal Agency) updated to links + * to edit the portfolio + * + * Dependencies: + * - Expects specific elements to be defined globally (`portfolioSeniorOfficial`, `portfolioUrbanizationField`, etc.). + * - Uses `showElement` and `hideElement` functions to control element visibility. + */ + function updatePortfolioFieldsDataDynamicDisplay() { + + // Handle visibility of senior official's contact information + if (portfolioSeniorOfficial.innerText.includes("No senior official found.")) { + hideElement(portfolioSeniorOfficialAddress); + } else { + showElement(portfolioSeniorOfficialAddress); + } + + // Handle visibility of urbanization field based on state/territory value + let portfolioStateTerritoryValue = portfolioStateTerritory.innerText; + if (portfolioStateTerritoryValue === "PR") { + showElement(portfolioUrbanizationField); + } else { + hideElement(portfolioUrbanizationField); + } + + // Handle visibility of fields based on organization type (Federal vs. others) + if (portfolioOrgType.innerText === "Federal") { + hideElement(portfolioOrgNameField); + showElement(portfolioFederalAgencyField); + showElement(portfolioFederalTypeField); + } else { + showElement(portfolioOrgNameField); + hideElement(portfolioFederalAgencyField); + hideElement(portfolioFederalTypeField); + } + + // Modify the display of certain fields to convert them from text to links + // to edit the portfolio + let portfolio_id = portfolioDropdown.val(); + let portfolioEditUrl = `/admin/registrar/portfolio/${portfolio_id}/change/`; + let portfolioOrgTypeValue = portfolioOrgType.innerText; + portfolioOrgType.innerHTML = `${portfolioOrgTypeValue}`; + let portfolioOrgNameValue = portfolioOrgName.innerText; + portfolioOrgName.innerHTML = `${portfolioOrgNameValue}`; + let portfolioFederalAgencyValue = portfolioFederalAgency.innerText; + portfolioFederalAgency.innerHTML = `${portfolioFederalAgencyValue}`; + let portfolioFederalTypeValue = portfolioFederalType.innerText; + if (portfolioFederalTypeValue !== '-') + portfolioFederalType.innerHTML = `${portfolioFederalTypeValue}`; + + } + + /** + * Asynchronously updates portfolio fields in the UI based on the selected portfolio. + * + * This function first checks if the page is loading or if a portfolio selection is available + * in the `portfolioDropdown`. If a portfolio is selected, it retrieves the portfolio data, + * then updates the UI fields to display relevant data. If no portfolio is selected, it simply + * refreshes the UI field display without new data. The `isPageLoading` flag prevents + * updates during page load. + * + * Workflow: + * 1. **Check Page Loading**: + * - If `isPageLoading` is `true`, set it to `false` and exit to prevent redundant updates. + * - If `isPageLoading` is `false`, proceed with portfolio field updates. + * + * 2. **Portfolio Selection**: + * - If a portfolio is selected (`portfolioDropdown.val()`), fetch the portfolio data. + * - Once data is fetched, run three update functions: + * - `updatePortfolioFieldsData`: Populates specific portfolio-related fields. + * - `updatePortfolioFieldsDisplay`: Handles the visibility of general portfolio fields. + * - `updatePortfolioFieldsDataDynamicDisplay`: Manages conditional display based on portfolio data. + * - If no portfolio is selected, only refreshes the field display using `updatePortfolioFieldsDisplay`. + * + * Dependencies: + * - Expects global elements (`portfolioDropdown`, etc.) and `isPageLoading` flag to be defined. + * - Assumes `getPortfolio`, `updatePortfolioFieldsData`, `updatePortfolioFieldsDisplay`, and `updatePortfolioFieldsDataDynamicDisplay` are available as functions. + */ + async function updatePortfolioFields() { + if (!isPageLoading) { + if (portfolioDropdown.val()) { + getPortfolio(portfolioDropdown.val()).then((portfolio) => { + updatePortfolioFieldsData(portfolio); + updatePortfolioFieldsDisplay(); + updatePortfolioFieldsDataDynamicDisplay(); + }); + } else { + updatePortfolioFieldsDisplay(); + } + } else { + isPageLoading = false; + } + } + + /** + * Updates the Suborganization Dropdown with new data based on the provided portfolio ID. + * + * This function uses the Select2 jQuery plugin to update the dropdown by fetching suborganization + * data relevant to the selected portfolio. Upon invocation, it checks if Select2 is already initialized + * on `suborganizationDropdown` and destroys the existing instance to avoid duplication. + * It then reinitializes Select2 with customized options for an AJAX request, allowing the user to search + * and select suborganizations dynamically, with results filtered based on `portfolio_id`. + * + * Key workflow: + * 1. **Document Ready**: Ensures that the function runs only once the DOM is fully loaded. + * 2. **Check and Reinitialize Select2**: + * - If Select2 is already initialized, it’s destroyed to refresh with new options. + * - Select2 is reinitialized with AJAX settings for dynamic data fetching. + * 3. **AJAX Options**: + * - **Data Function**: Prepares the query by capturing the user's search term (`params.term`) + * and the provided `portfolio_id` to filter relevant suborganizations. + * - **Data Type**: Ensures responses are returned as JSON. + * - **Delay**: Introduces a 250ms delay to prevent excessive requests on fast typing. + * - **Cache**: Enables caching to improve performance. + * 4. **Theme and Placeholder**: + * - Sets the dropdown theme to ‘admin-autocomplete’ for consistent styling. + * - Allows clearing of the dropdown and displays a placeholder as defined in the HTML. + * + * Dependencies: + * - Requires `suborganizationDropdown` element, the jQuery library, and the Select2 plugin. + * - `portfolio_id` is passed to filter results relevant to a specific portfolio. + */ + function updateSubOrganizationDropdown(portfolio_id) { + django.jQuery(document).ready(function() { + if (suborganizationDropdown.data('select2')) { + suborganizationDropdown.select2('destroy'); + } + // Reinitialize Select2 with the updated URL + suborganizationDropdown.select2({ + ajax: { + data: function (params) { + var query = { + search: params.term, + portfolio_id: portfolio_id + } + return query; + }, + dataType: 'json', + delay: 250, + cache: true + }, + theme: 'admin-autocomplete', + allowClear: true, + placeholder: suborganizationDropdown.attr('data-placeholder') + }); + }); + } + + /** + * Updates the display of portfolio-related fields based on whether a portfolio is selected. + * + * This function controls the visibility of specific fields by showing or hiding them + * depending on the presence of a selected portfolio ID in the dropdown. When a portfolio + * is selected, certain fields are shown (like suborganizations and portfolio-related fields), + * while others are hidden (like senior official and other employee-related fields). + * + * Workflow: + * 1. **Retrieve Portfolio ID**: + * - Fetches the selected value from `portfolioDropdown` to check if a portfolio is selected. + * + * 2. **Display Fields for Selected Portfolio**: + * - If a `portfolio_id` exists, it updates the `suborganizationDropdown` for the specific portfolio. + * - Shows or hides various fields to display only relevant portfolio information: + * - Shows `suborganizationField`, `portfolioSeniorOfficialField`, and fields related to the portfolio organization. + * - Hides fields that are not applicable when a portfolio is selected, such as `seniorOfficialField` and `otherEmployeesField`. + * + * 3. **Display Fields for No Portfolio Selected**: + * - If no portfolio is selected (i.e., `portfolio_id` is falsy), it reverses the visibility: + * - Hides `suborganizationField` and other portfolio-specific fields. + * - Shows fields that are applicable when no portfolio is selected, such as the `seniorOfficialField`. + * + * Dependencies: + * - `portfolioDropdown` is assumed to be a dropdown element containing portfolio IDs. + * - `showElement` and `hideElement` utility functions are used to control element visibility. + * - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used. + */ + function updatePortfolioFieldsDisplay() { + // Retrieve the selected portfolio ID + let portfolio_id = portfolioDropdown.val(); + + if (portfolio_id) { + // A portfolio is selected - update suborganization dropdown and show/hide relevant fields + + // Update suborganization dropdown for the selected portfolio + updateSubOrganizationDropdown(portfolio_id); + + // Show fields relevant to a selected portfolio + showElement(suborganizationField); + hideElement(seniorOfficialField); + showElement(portfolioSeniorOfficialField); + + // Hide fields not applicable when a portfolio is selected + hideElement(otherEmployeesField); + hideElement(noOtherContactsRationaleField); + hideElement(cisaRepresentativeFirstNameField); + hideElement(cisaRepresentativeLastNameField); + hideElement(cisaRepresentativeEmailField); + hideElement(orgTypeFieldSet); + hideElement(orgTypeFieldSetDetails); + hideElement(orgNameFieldSet); + hideElement(orgNameFieldSetDetails); + + // Show portfolio-specific fields + showElement(portfolioOrgTypeFieldSet); + showElement(portfolioOrgNameFieldSet); + showElement(portfolioOrgNameFieldSetDetails); + } else { + // No portfolio is selected - reverse visibility of fields + + // Hide suborganization field as no portfolio is selected + hideElement(suborganizationField); + + // Show fields that are relevant when no portfolio is selected + showElement(seniorOfficialField); + hideElement(portfolioSeniorOfficialField); + showElement(otherEmployeesField); + showElement(noOtherContactsRationaleField); + showElement(cisaRepresentativeFirstNameField); + showElement(cisaRepresentativeLastNameField); + showElement(cisaRepresentativeEmailField); + + // Show organization type and name fields + showElement(orgTypeFieldSet); + showElement(orgTypeFieldSetDetails); + showElement(orgNameFieldSet); + showElement(orgNameFieldSetDetails); + + // Hide portfolio-specific fields that aren’t applicable + hideElement(portfolioOrgTypeFieldSet); + hideElement(portfolioOrgNameFieldSet); + hideElement(portfolioOrgNameFieldSetDetails); + } + + updateSuborganizationFieldsDisplay(); + + } + + /** + * Updates the visibility of suborganization-related fields based on the selected value in the suborganization dropdown. + * + * If a suborganization is selected: + * - Hides the fields related to requesting a new suborganization (`requestedSuborganizationField`). + * - Hides the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields for the suborganization. + * + * If no suborganization is selected: + * - Shows the fields for requesting a new suborganization (`requestedSuborganizationField`). + * - Displays the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields. + * + * This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested. + */ + function updateSuborganizationFieldsDisplay() { + let portfolio_id = portfolioDropdown.val(); + let suborganization_id = suborganizationDropdown.val(); + + if (portfolio_id && !suborganization_id) { + // Show suborganization request fields + showElement(requestedSuborganizationField); + showElement(suborganizationCity); + showElement(suborganizationStateTerritory); + } else { + // Hide suborganization request fields if suborganization is selected + hideElement(requestedSuborganizationField); + hideElement(suborganizationCity); + hideElement(suborganizationStateTerritory); + } + } + + /** + * Initializes necessary data and display configurations for the portfolio fields. + */ + function initializePortfolioSettings() { + // Update the visibility of portfolio-related fields based on current dropdown selection. + updatePortfolioFieldsDisplay(); + + // Dynamically adjust the display of certain fields based on the selected portfolio's characteristics. + updatePortfolioFieldsDataDynamicDisplay(); + } + + /** + * Sets event listeners for key UI elements. + */ + function setEventListeners() { + // When the `portfolioDropdown` selection changes, refresh the displayed portfolio fields. + portfolioDropdown.on("change", updatePortfolioFields); + // When the 'suborganizationDropdown' selection changes + suborganizationDropdown.on("change", updateSuborganizationFieldsDisplay); + } + + // Run initial setup functions + initializePortfolioSettings(); + setEventListeners(); +} + // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Initialization code. @@ -797,6 +1297,63 @@ document.addEventListener('DOMContentLoaded', function() { customEmail.loadRejectedEmail() }); +/** An IIFE that hides and shows approved domain select2 row in domain request + * conditionally based on the Status field selection. If Approved, show. If not Approved, + * don't show. + */ +document.addEventListener('DOMContentLoaded', function() { + const domainRequestForm = document.getElementById("domainrequest_form"); + if (!domainRequestForm) { + return; + } + + const statusToCheck = "approved"; + const statusSelect = document.getElementById("id_status"); + const sessionVariableName = "showApprovedDomain"; + let approvedDomainFormGroup = document.querySelector(".field-approved_domain"); + + function updateFormGroupVisibility(showFormGroups) { + if (showFormGroups) { + showElement(approvedDomainFormGroup); + }else { + hideElement(approvedDomainFormGroup); + } + } + + // Handle showing/hiding the related fields on page load. + function initializeFormGroups() { + let isStatus = statusSelect.value == statusToCheck; + + // Initial handling of these groups. + updateFormGroupVisibility(isStatus); + + // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage + statusSelect.addEventListener('change', () => { + // Show the approved if the status is what we expect. + isStatus = statusSelect.value == statusToCheck; + updateFormGroupVisibility(isStatus); + addOrRemoveSessionBoolean(sessionVariableName, isStatus); + }); + + // Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage + // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the + // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide + // accurately for this edge case, we use cache and test for the back/forward navigation. + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.type === "back_forward") { + let showTextAreaFormGroup = sessionStorage.getItem(sessionVariableName) !== null; + updateFormGroupVisibility(showTextAreaFormGroup); + } + }); + }); + observer.observe({ type: "navigation" }); + } + + initializeFormGroups(); + +}); + /** An IIFE for copy summary button (appears in DomainRegistry models) */ @@ -844,10 +1401,10 @@ document.addEventListener('DOMContentLoaded', function() { if (contacts) { contacts.forEach(contact => { // Check if the
element is not empty - const name = contact.querySelector('a#contact_info_name')?.innerText; - const title = contact.querySelector('span#contact_info_title')?.innerText; - const email = contact.querySelector('span#contact_info_email')?.innerText; - const phone = contact.querySelector('span#contact_info_phone')?.innerText; + const name = contact.querySelector('a.contact_info_name')?.innerText; + const title = contact.querySelector('span.contact_info_title')?.innerText; + const email = contact.querySelector('span.contact_info_email')?.innerText; + const phone = contact.querySelector('span.contact_info_phone')?.innerText; const url = nameToUrlMap[name] || '#'; // Format the contact information const listItem = document.createElement('li'); @@ -898,9 +1455,9 @@ document.addEventListener('DOMContentLoaded', function() { const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); const seniorOfficialElement = document.getElementById('id_senior_official'); const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text; - const seniorOfficialTitle = extractTextById('contact_info_title', seniorOfficialDiv); - const seniorOfficialEmail = extractTextById('contact_info_email', seniorOfficialDiv); - const seniorOfficialPhone = extractTextById('contact_info_phone', seniorOfficialDiv); + const seniorOfficialTitle = seniorOfficialDiv.querySelector('.contact_info_title'); + const seniorOfficialEmail = seniorOfficialDiv.querySelector('.contact_info_email'); + const seniorOfficialPhone = seniorOfficialDiv.querySelector('.contact_info_phone'); let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`; const html_summary = `Recommendation:
` + @@ -958,6 +1515,7 @@ document.addEventListener('DOMContentLoaded', function() { /** An IIFE for dynamically changing some fields on the portfolio admin model + * IMPORTANT NOTE: The logic in this IIFE is paired handlePortfolioSelection */ (function dynamicPortfolioFields(){ @@ -1184,9 +1742,9 @@ document.addEventListener('DOMContentLoaded', function() { function updateContactInfo(data) { if (!contactList) return; - const titleSpan = contactList.querySelector("#contact_info_title"); - const emailSpan = contactList.querySelector("#contact_info_email"); - const phoneSpan = contactList.querySelector("#contact_info_phone"); + const titleSpan = contactList.querySelector(".contact_info_title"); + const emailSpan = contactList.querySelector(".contact_info_email"); + const phoneSpan = contactList.querySelector(".contact_info_phone"); if (titleSpan) { titleSpan.textContent = data.title || "None"; @@ -1218,7 +1776,7 @@ document.addEventListener('DOMContentLoaded', function() { (function dynamicDomainRequestFields(){ const domainRequestPage = document.getElementById("domainrequest_form"); if (domainRequestPage) { - handleSuborganizationFields(); + handlePortfolioSelection(); } })(); diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 18235d3990..3831ea6036 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -28,6 +28,8 @@ from registrar.views.domains_json import get_domains_json from registrar.views.utility.api_views import ( get_senior_official_from_federal_agency_json, + get_portfolio_json, + get_suborganization_list_json, get_federal_and_portfolio_types_from_federal_agency_json, get_action_needed_email_for_user_json, get_rejection_email_for_user_json, @@ -201,6 +203,16 @@ get_senior_official_from_federal_agency_json, name="get-senior-official-from-federal-agency-json", ), + path( + "admin/api/get-portfolio-json/", + get_portfolio_json, + name="get-portfolio-json", + ), + path( + "admin/api/get-suborganization-list-json/", + get_suborganization_list_json, + name="get-suborganization-list-json", + ), path( "admin/api/get-federal-and-portfolio-types-from-federal-agency-json/", get_federal_and_portfolio_types_from_federal_agency_json, diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index d6a016fd5a..4418909788 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -4,7 +4,25 @@ Template for an input field with a clipboard {% endcomment %} -{% if not invisible_input_field %} +{% if empty_field %} +
+ + +
+{% elif not invisible_input_field %}
{{ field }}
{% endif %} + {% elif field.field.name == "portfolio_senior_official" %} +
+ {% if original_object.portfolio.senior_official %} + {{ field.contents }} + {% else %} + No senior official found.
+ {% endif %} +
{% elif field.field.name == "other_contacts" %} {% if all_contacts.count > 2 %}
@@ -332,6 +340,16 @@

{% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly %}

+ {% elif field.field.name == "portfolio_senior_official" %} +
+ + {% comment %}fields_always_present=True will shortcut the contact_detail_list template when + 1. Senior official field should be hidden on domain request because no portfoloio is selected, which is desirable + 2. A portfolio is selected but there is no senior official on the portfolio, where the shortcut is not desirable + To solve 2, we use an else No additional contact information found on field.field.name == "portfolio_senior_official" + and we hide the placeholders from detail_table_fieldset in JS{% endcomment %} + {% include "django/admin/includes/contact_detail_list.html" with user=original_object.portfolio.senior_official no_title_top_padding=field.is_readonly fields_always_present=True %} +
{% elif field.field.name == "other_contacts" and original_object.other_contacts.all %} {% with all_contacts=original_object.other_contacts.all %} {% if all_contacts.count > 2 %} diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 27dfcc7d11..9244fffcdd 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1526,7 +1526,7 @@ def test_contact_fields_have_detail_table(self): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "copy-to-clipboard", count=4) + self.assertContains(response, "copy-to-clipboard", count=5) # Test that Creator counts display properly self.assertNotContains(response, "Approved domains") @@ -1626,6 +1626,17 @@ def test_readonly_when_restricted_creator(self): readonly_fields = self.admin.get_readonly_fields(request, domain_request) expected_fields = [ + "portfolio_senior_official", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", "other_contacts", "current_websites", "alternative_domains", @@ -1691,6 +1702,17 @@ def test_readonly_fields_for_analyst(self): readonly_fields = self.admin.get_readonly_fields(request) self.maxDiff = None expected_fields = [ + "portfolio_senior_official", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", "other_contacts", "current_websites", "alternative_domains", @@ -1723,6 +1745,17 @@ def test_readonly_fields_for_superuser(self): readonly_fields = self.admin.get_readonly_fields(request) expected_fields = [ + "portfolio_senior_official", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", "other_contacts", "current_websites", "alternative_domains", diff --git a/src/registrar/tests/test_api.py b/src/registrar/tests/test_api.py index aba2d52e8f..f0d5dbcecc 100644 --- a/src/registrar/tests/test_api.py +++ b/src/registrar/tests/test_api.py @@ -2,7 +2,8 @@ from django.test import TestCase, Client from registrar.models import FederalAgency, SeniorOfficial, User, DomainRequest from django.contrib.auth import get_user_model -from registrar.tests.common import create_superuser, create_user, completed_domain_request +from registrar.models.portfolio import Portfolio +from registrar.tests.common import create_superuser, create_test_user, create_user, completed_domain_request from api.tests.common import less_console_noise_decorator from registrar.utility.constants import BranchChoices @@ -74,6 +75,79 @@ def test_get_senior_official_json_not_found(self): self.assertEqual(data["error"], "Senior Official not found") +class GetPortfolioJsonTest(TestCase): + def setUp(self): + self.client = Client() + self.user = create_test_user() + self.superuser = create_superuser() + self.analyst_user = create_user() + + self.agency = FederalAgency.objects.create(agency="Test Agency") + self.senior_official = SeniorOfficial.objects.create( + first_name="John", last_name="Doe", title="Director", federal_agency=self.agency + ) + self.portfolio = Portfolio.objects.create( + creator=self.user, + federal_agency=self.agency, + senior_official=self.senior_official, + organization_name="Org name", + organization_type=Portfolio.OrganizationChoices.FEDERAL, + ) + + self.api_url = reverse("get-portfolio-json") + + def tearDown(self): + Portfolio.objects.all().delete() + User.objects.all().delete() + SeniorOfficial.objects.all().delete() + FederalAgency.objects.all().delete() + + @less_console_noise_decorator + def test_get_portfolio_authenticated_superuser(self): + """Test that a superuser can get the portfolio information.""" + self.client.force_login(self.superuser) + response = self.client.get(self.api_url, {"id": self.portfolio.id}) + self.assertEqual(response.status_code, 200) + portfolio = response.json() + self.assertEqual(portfolio["id"], self.portfolio.id) + self.assertEqual(portfolio["creator"], self.user.id) + self.assertEqual(portfolio["organization_name"], self.portfolio.organization_name) + self.assertEqual(portfolio["organization_type"], "Federal") + self.assertEqual(portfolio["notes"], None) + self.assertEqual(portfolio["federal_agency"]["id"], self.agency.id) + self.assertEqual(portfolio["federal_agency"]["agency"], self.agency.agency) + self.assertEqual(portfolio["senior_official"]["id"], self.senior_official.id) + self.assertEqual(portfolio["senior_official"]["first_name"], self.senior_official.first_name) + self.assertEqual(portfolio["senior_official"]["last_name"], self.senior_official.last_name) + self.assertEqual(portfolio["senior_official"]["title"], self.senior_official.title) + self.assertEqual(portfolio["senior_official"]["phone"], None) + self.assertEqual(portfolio["senior_official"]["email"], None) + self.assertEqual(portfolio["federal_type"], "-") + + @less_console_noise_decorator + def test_get_portfolio_json_authenticated_analyst(self): + """Test that an analyst user can fetch the portfolio's information.""" + self.client.force_login(self.analyst_user) + response = self.client.get(self.api_url, {"id": self.portfolio.id}) + self.assertEqual(response.status_code, 200) + portfolio = response.json() + self.assertEqual(portfolio["id"], self.portfolio.id) + + @less_console_noise_decorator + def test_get_portfolio_json_unauthenticated(self): + """Test that an unauthenticated user receives a 403 with an error message.""" + self.client.force_login(self.user) + response = self.client.get(self.api_url, {"id": self.portfolio.id}) + self.assertEqual(response.status_code, 302) + + @less_console_noise_decorator + def test_get_portfolio_json_not_found(self): + """Test that a request for a non-existent portfolio returns a 404 with an error message.""" + self.client.force_login(self.superuser) + response = self.client.get(self.api_url, {"id": -1}) + self.assertEqual(response.status_code, 404) + + class GetFederalPortfolioTypeJsonTest(TestCase): def setUp(self): self.client = Client() diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 37e0a0e008..93a0a16b50 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -4,6 +4,7 @@ from django.urls import reverse from django.utils.html import escape from registrar.models.utility.generic_helper import value_of_attribute +from django.contrib.admin.widgets import AutocompleteSelect def get_action_needed_reason_default_email(domain_request, action_needed_reason): @@ -94,3 +95,26 @@ def get_field_links_as_list( else: links = "".join(links) return format_html(f'') if links else msg_for_none + + +class AutocompleteSelectWithPlaceholder(AutocompleteSelect): + """Override of the default autoselect element. This is because by default, + the autocomplete element clears data-placeholder""" + + def build_attrs(self, base_attrs, extra_attrs=None): + attrs = super().build_attrs(base_attrs, extra_attrs=extra_attrs) + if "data-placeholder" in base_attrs: + attrs["data-placeholder"] = base_attrs["data-placeholder"] + return attrs + + def __init__(self, field, admin_site, attrs=None, choices=(), using=None): + """Set a custom ajax url for the select2 if passed through attrs""" + if attrs: + self.custom_ajax_url = attrs.pop("ajax-url", None) + super().__init__(field, admin_site, attrs, choices, using) + + def get_url(self): + """Override the get_url method to use the custom ajax url""" + if self.custom_ajax_url: + return reverse(self.custom_ajax_url) + return reverse(self.url_name % self.admin_site.name) diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index 9d73b51978..aedfa757ab 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -39,6 +39,86 @@ def get_senior_official_from_federal_agency_json(request): return JsonResponse({"error": "Senior Official not found"}, status=404) +@login_required +@staff_member_required +def get_portfolio_json(request): + """Returns portfolio information as a JSON""" + + # This API is only accessible to admins and analysts + superuser_perm = request.user.has_perm("registrar.full_access_permission") + analyst_perm = request.user.has_perm("registrar.analyst_access_permission") + if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): + return JsonResponse({"error": "You do not have access to this resource"}, status=403) + + portfolio_id = request.GET.get("id") + try: + portfolio = Portfolio.objects.get(id=portfolio_id) + except Portfolio.DoesNotExist: + return JsonResponse({"error": "Portfolio not found"}, status=404) + + # Convert the portfolio to a dictionary + portfolio_dict = model_to_dict(portfolio) + + portfolio_dict["id"] = portfolio.id + + # map portfolio federal type + portfolio_dict["federal_type"] = ( + BranchChoices.get_branch_label(portfolio.federal_type) if portfolio.federal_type else "-" + ) + + # map portfolio organization type + portfolio_dict["organization_type"] = ( + DomainRequest.OrganizationChoices.get_org_label(portfolio.organization_type) + if portfolio.organization_type + else "-" + ) + + # Add senior official information if it exists + if portfolio.senior_official: + senior_official = model_to_dict( + portfolio.senior_official, fields=["id", "first_name", "last_name", "title", "phone", "email"] + ) + # The phone number field isn't json serializable, so we + # convert this to a string first if it exists. + if "phone" in senior_official and senior_official.get("phone"): + senior_official["phone"] = str(senior_official["phone"]) + portfolio_dict["senior_official"] = senior_official + else: + portfolio_dict["senior_official"] = None + + # Add federal agency information if it exists + if portfolio.federal_agency: + federal_agency = model_to_dict(portfolio.federal_agency, fields=["agency", "id"]) + portfolio_dict["federal_agency"] = federal_agency + else: + portfolio_dict["federal_agency"] = "-" + + return JsonResponse(portfolio_dict) + + +@login_required +@staff_member_required +def get_suborganization_list_json(request): + """Returns suborganization list information for a portfolio as a JSON""" + + # This API is only accessible to admins and analysts + superuser_perm = request.user.has_perm("registrar.full_access_permission") + analyst_perm = request.user.has_perm("registrar.analyst_access_permission") + if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): + return JsonResponse({"error": "You do not have access to this resource"}, status=403) + + portfolio_id = request.GET.get("portfolio_id") + try: + portfolio = Portfolio.objects.get(id=portfolio_id) + except Portfolio.DoesNotExist: + return JsonResponse({"error": "Portfolio not found"}, status=404) + + # Add suborganizations related to this portfolio + suborganizations = portfolio.portfolio_suborganizations.all().values("id", "name") + results = [{"id": sub["id"], "text": sub["name"]} for sub in suborganizations] + return JsonResponse({"results": results, "pagination": {"more": False}}) + + @login_required @staff_member_required def get_federal_and_portfolio_types_from_federal_agency_json(request):