mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-22 18:56:15 +02:00
Merge remote-tracking branch 'origin' into ms/2826-self-host-select2
This commit is contained in:
commit
1d08f52447
22 changed files with 1208 additions and 102 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -171,6 +171,9 @@ node_modules
|
|||
# Vim
|
||||
*.swp
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
|
||||
# Compliance/trestle related
|
||||
docs/compliance/.trestle/cache
|
||||
|
||||
|
|
|
@ -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 DomainRequestAdminForm(forms.ModelForm):
|
|||
"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 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
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 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
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 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
],
|
||||
},
|
||||
),
|
||||
# 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 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
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",
|
||||
|
|
|
@ -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<Object|null>} - 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 = `<a href=/admin/registrar/seniorofficial/${senior_official.id}/change/ class='test'>${seniorOfficialName}</a>`
|
||||
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 = `<a href=${portfolioEditUrl}>${portfolioOrgTypeValue}</a>`;
|
||||
let portfolioOrgNameValue = portfolioOrgName.innerText;
|
||||
portfolioOrgName.innerHTML = `<a href=${portfolioEditUrl}>${portfolioOrgNameValue}</a>`;
|
||||
let portfolioFederalAgencyValue = portfolioFederalAgency.innerText;
|
||||
portfolioFederalAgency.innerHTML = `<a href=${portfolioEditUrl}>${portfolioFederalAgencyValue}</a>`;
|
||||
let portfolioFederalTypeValue = portfolioFederalType.innerText;
|
||||
if (portfolioFederalTypeValue !== '-')
|
||||
portfolioFederalType.innerHTML = `<a href=${portfolioEditUrl}>${portfolioFederalTypeValue}</a>`;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <dl> 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 = `<strong>Recommendation:</strong></br>` +
|
||||
|
@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
|
|
|
@ -28,6 +28,8 @@ from registrar.views.domain_requests_json import get_domain_requests_json
|
|||
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 @@ urlpatterns = [
|
|||
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,
|
||||
|
@ -327,9 +339,9 @@ urlpatterns = [
|
|||
name="user-profile",
|
||||
),
|
||||
path(
|
||||
"invitation/<int:pk>/delete",
|
||||
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
|
||||
name="invitation-delete",
|
||||
"invitation/<int:pk>/cancel",
|
||||
views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
|
||||
name="invitation-cancel",
|
||||
),
|
||||
path(
|
||||
"domain-request/<int:pk>/delete",
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 4.2.10 on 2024-11-18 16:47
|
||||
|
||||
from django.db import migrations
|
||||
import django_fsm
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0137_suborganization_city_suborganization_state_territory"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="domaininvitation",
|
||||
name="status",
|
||||
field=django_fsm.FSMField(
|
||||
choices=[("invited", "Invited"), ("retrieved", "Retrieved"), ("canceled", "Canceled")],
|
||||
default="invited",
|
||||
max_length=50,
|
||||
protected=True,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -26,6 +26,7 @@ class DomainInvitation(TimeStampedModel):
|
|||
class DomainInvitationStatus(models.TextChoices):
|
||||
INVITED = "invited", "Invited"
|
||||
RETRIEVED = "retrieved", "Retrieved"
|
||||
CANCELED = "canceled", "Canceled"
|
||||
|
||||
email = models.EmailField(
|
||||
null=False,
|
||||
|
@ -73,3 +74,13 @@ class DomainInvitation(TimeStampedModel):
|
|||
# something strange happened and this role already existed when
|
||||
# the invitation was retrieved. Log that this occurred.
|
||||
logger.warn("Invitation %s was retrieved for a role that already exists.", self)
|
||||
|
||||
@transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED)
|
||||
def cancel_invitation(self):
|
||||
"""When an invitation is canceled, change the status to canceled"""
|
||||
pass
|
||||
|
||||
@transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED)
|
||||
def update_cancellation_status(self):
|
||||
"""When an invitation is canceled but reinvited, update the status to invited"""
|
||||
pass
|
||||
|
|
|
@ -4,7 +4,25 @@
|
|||
Template for an input field with a clipboard
|
||||
{% endcomment %}
|
||||
|
||||
{% if not invisible_input_field %}
|
||||
{% if empty_field %}
|
||||
<div class="admin-icon-group">
|
||||
<input aria-hidden="true" class="display-none" value="">
|
||||
<button
|
||||
class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled padding-left-1 usa-button--icon copy-to-clipboard"
|
||||
type="button"
|
||||
>
|
||||
<div class="no-outline-on-click">
|
||||
<svg
|
||||
class="usa-icon"
|
||||
>
|
||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
<!-- the span is targeted in JS, do not remove -->
|
||||
<span>Copy</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{% elif not invisible_input_field %}
|
||||
<div class="admin-icon-group">
|
||||
{{ field }}
|
||||
<button
|
||||
|
|
|
@ -2,6 +2,13 @@
|
|||
{% load custom_filters %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block content %}
|
||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get-portfolio-json' as url %}
|
||||
<input id="portfolio_json_url" class="display-none" value="{{url}}" />
|
||||
{{ block.super }}
|
||||
{% endblock content %}
|
||||
|
||||
{% block field_sets %}
|
||||
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #}
|
||||
<a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
{% if show_formatted_name %}
|
||||
{% if user.get_formatted_name %}
|
||||
<a id="contact_info_name" href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a>
|
||||
<a class="contact_info_name" href="{% url 'admin:registrar_contact_change' user.id %}">{{ user.get_formatted_name }}</a>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
|
@ -16,7 +16,7 @@
|
|||
{% if user|has_contact_info %}
|
||||
{# Title #}
|
||||
{% if user.title %}
|
||||
<span id="contact_info_title">{{ user.title }}</span>
|
||||
<span class="contact_info_title">{{ user.title }}</span>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
|
@ -24,7 +24,7 @@
|
|||
|
||||
{# Email #}
|
||||
{% if user.email %}
|
||||
<span id="contact_info_email">{{ user.email }}</span>
|
||||
<span class="contact_info_email">{{ user.email }}</span>
|
||||
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
|
||||
<br>
|
||||
{% else %}
|
||||
|
@ -33,17 +33,24 @@
|
|||
|
||||
{# Phone #}
|
||||
{% if user.phone %}
|
||||
<span id="contact_info_phone">{{ user.phone }}</span>
|
||||
<span class="contact_info_phone">{{ user.phone }}</span>
|
||||
<br>
|
||||
{% else %}
|
||||
None<br>
|
||||
{% endif %}
|
||||
|
||||
{% elif fields_always_present %}
|
||||
<span class="contact_info_title"></span>
|
||||
</br>
|
||||
<span class="contact_info_email"></span>
|
||||
{% include "admin/input_with_clipboard.html" with field=user empty_field=True %}
|
||||
<br>
|
||||
<span class="contact_info_phone"></span>
|
||||
<br>
|
||||
{% elif not hide_no_contact_info_message %}
|
||||
No additional contact information found.<br>
|
||||
{% endif %}
|
||||
|
||||
{% if user_verification_type and not skip_additional_contact_info %}
|
||||
<span id="contact_info_phone">{{ user_verification_type }}</span>
|
||||
<span class="contact_info_phone">{{ user_verification_type }}</span>
|
||||
{% endif %}
|
||||
</address>
|
||||
|
|
|
@ -66,6 +66,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
No changelog to display.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif field.field.name == "portfolio_senior_official" %}
|
||||
<div class="readonly">
|
||||
{% if original_object.portfolio.senior_official %}
|
||||
<a href="{% url 'admin:registrar_seniorofficial_change' original_object.portfolio.senior_official.id %}">{{ field.contents }}</a>
|
||||
{% else %}
|
||||
No senior official found.<br>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif field.field.name == "other_contacts" %}
|
||||
{% if all_contacts.count > 2 %}
|
||||
<div class="readonly">
|
||||
|
@ -332,6 +340,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
<label aria-label="Senior official contact details"></label>
|
||||
{% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly %}
|
||||
</div>
|
||||
{% elif field.field.name == "portfolio_senior_official" %}
|
||||
<div class="flex-container">
|
||||
<label aria-label="Senior official contact details"></label>
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
|
|
@ -6,21 +6,30 @@
|
|||
{% block domain_content %}
|
||||
<h1>Domain managers</h1>
|
||||
|
||||
{% comment %}Copy below differs depending on whether view is in portfolio mode.{% endcomment %}
|
||||
{% if not portfolio %}
|
||||
<p>
|
||||
Domain managers can update all information related to a domain within the
|
||||
.gov registrar, including security email and DNS name servers.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
Domain managers can update all information related to a domain within the
|
||||
.gov registrar, including security email and DNS name servers.
|
||||
.gov registrar, including contact details, senior official, security email, and DNS name servers.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<ul class="usa-list">
|
||||
<li>There is no limit to the number of domain managers you can add.</li>
|
||||
<li>After adding a domain manager, an email invitation will be sent to that user with
|
||||
instructions on how to set up an account.</li>
|
||||
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
|
||||
<li>All domain managers will be notified when updates are made to this domain.</li>
|
||||
<li>Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.</li>
|
||||
{% if not portfolio %}<li>All domain managers will be notified when updates are made to this domain.</li>{% endif %}
|
||||
<li>Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.
|
||||
{% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}</li>
|
||||
</ul>
|
||||
|
||||
{% if domain.permissions %}
|
||||
{% if domain_manager_roles %}
|
||||
<section class="section-outlined">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||
<h2 class> Domain managers </h2>
|
||||
|
@ -28,17 +37,18 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th data-sortable scope="col" role="columnheader">Email</th>
|
||||
<th class="grid-col-2" data-sortable scope="col" role="columnheader">Role</th>
|
||||
{% if not portfolio %}<th class="grid-col-2" data-sortable scope="col" role="columnheader">Role</th>{% endif %}
|
||||
<th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for permission in domain.permissions.all %}
|
||||
{% for item in domain_manager_roles %}
|
||||
<tr>
|
||||
<th scope="row" role="rowheader" data-sort-value="{{ permission.user.email }}" data-label="Email">
|
||||
{{ permission.user.email }}
|
||||
<th scope="row" role="rowheader" data-sort-value="{{ item.permission.user.email }}" data-label="Email">
|
||||
{{ item.permission.user.email }}
|
||||
{% if item.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
|
||||
</th>
|
||||
<td data-label="Role">{{ permission.role|title }}</td>
|
||||
{% if not portfolio %}<td data-label="Role">{{ item.permission.role|title }}</td>{% endif %}
|
||||
<td>
|
||||
{% if can_delete_users %}
|
||||
<a
|
||||
|
@ -52,7 +62,7 @@
|
|||
Remove
|
||||
</a>
|
||||
{# Display a custom message if the user is trying to delete themselves #}
|
||||
{% if permission.user.email == current_user_email %}
|
||||
{% if item.permission.user.email == current_user_email %}
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="toggle-user-alert-{{ forloop.counter }}"
|
||||
|
@ -60,7 +70,7 @@
|
|||
aria-describedby="You will be removed from this domain"
|
||||
data-force-action
|
||||
>
|
||||
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=permission.user.id %}">
|
||||
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}">
|
||||
{% with domain_name=domain.name|force_escape %}
|
||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button_self|safe %}
|
||||
{% endwith %}
|
||||
|
@ -71,11 +81,11 @@
|
|||
class="usa-modal"
|
||||
id="toggle-user-alert-{{ forloop.counter }}"
|
||||
aria-labelledby="Are you sure you want to continue?"
|
||||
aria-describedby="{{ permission.user.email }} will be removed"
|
||||
aria-describedby="{{ item.permission.user.email }} will be removed"
|
||||
data-force-action
|
||||
>
|
||||
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=permission.user.id %}">
|
||||
{% with email=permission.user.email|default:permission.user|force_escape domain_name=domain.name|force_escape %}
|
||||
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}">
|
||||
{% with email=item.permission.user.email|default:item.permission.user|force_escape domain_name=domain.name|force_escape %}
|
||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button|safe %}
|
||||
{% endwith %}
|
||||
</form>
|
||||
|
@ -111,7 +121,7 @@
|
|||
</a>
|
||||
</section>
|
||||
|
||||
{% if domain.invitations.exists %}
|
||||
{% if invitations %}
|
||||
<section class="section-outlined">
|
||||
<h2>Invitations</h2>
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||
|
@ -120,21 +130,22 @@
|
|||
<tr>
|
||||
<th data-sortable scope="col" role="columnheader">Email</th>
|
||||
<th data-sortable scope="col" role="columnheader">Date created</th>
|
||||
<th class="grid-col-2" data-sortable scope="col" role="columnheader">Status</th>
|
||||
{% if not portfolio %}<th class="grid-col-2" data-sortable scope="col" role="columnheader">Status</th>{% endif %}
|
||||
<th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invitation in domain.invitations.all %}
|
||||
{% for invitation in invitations %}
|
||||
<tr>
|
||||
<th scope="row" role="rowheader" data-sort-value="{{ invitation.user.email }}" data-label="Email">
|
||||
{{ invitation.email }}
|
||||
<th scope="row" role="rowheader" data-sort-value="{{ invitation.domain_invitation.user.email }}" data-label="Email">
|
||||
{{ invitation.domain_invitation.email }}
|
||||
{% if invitation.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
|
||||
</th>
|
||||
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
|
||||
<td data-label="Status">{{ invitation.status|title }}</td>
|
||||
<td data-sort-value="{{ invitation.domain_invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.domain_invitation.created_at|date }} </td>
|
||||
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
|
||||
<td>
|
||||
{% if invitation.status == invitation.DomainInvitationStatus.INVITED %}
|
||||
<form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
|
||||
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
|
||||
<form method="POST" action="{% url "invitation-cancel" pk=invitation.domain_invitation.id %}">
|
||||
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
|
||||
</form>
|
||||
{% endif %}
|
||||
|
|
|
@ -200,7 +200,7 @@ def is_domain_subpage(path):
|
|||
"domain-users-add",
|
||||
"domain-request-delete",
|
||||
"domain-user-delete",
|
||||
"invitation-delete",
|
||||
"invitation-cancel",
|
||||
]
|
||||
return get_url_name(path) in url_names
|
||||
|
||||
|
|
|
@ -1526,7 +1526,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
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 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
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 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
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 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
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",
|
||||
|
|
|
@ -2,7 +2,8 @@ from django.urls import reverse
|
|||
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 @@ class GetSeniorOfficialJsonTest(TestCase):
|
|||
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()
|
||||
|
|
|
@ -370,6 +370,17 @@ class TestDomainManagers(TestDomainOverview):
|
|||
]
|
||||
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Add portfolio in order to test portfolio view
|
||||
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream")
|
||||
# Add the portfolio to the domain_information object
|
||||
self.domain_information.portfolio = self.portfolio
|
||||
# Add portfolio perms to the user object
|
||||
self.portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
super().tearDownClass()
|
||||
|
@ -383,13 +394,22 @@ class TestDomainManagers(TestDomainOverview):
|
|||
def test_domain_managers(self):
|
||||
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
|
||||
self.assertContains(response, "Domain managers")
|
||||
self.assertContains(response, "Add a domain manager")
|
||||
# assert that the non-portfolio view contains Role column and doesn't contain Admin
|
||||
self.assertContains(response, "Role</th>")
|
||||
self.assertNotContains(response, "Admin")
|
||||
self.assertContains(response, "This domain has one manager. Adding more can prevent issues.")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_managers_add_link(self):
|
||||
"""Button to get to user add page works."""
|
||||
management_page = self.app.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
|
||||
add_page = management_page.click("Add a domain manager")
|
||||
self.assertContains(add_page, "Add a domain manager")
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_domain_managers_portfolio_view(self):
|
||||
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
|
||||
self.assertContains(response, "Domain managers")
|
||||
self.assertContains(response, "Add a domain manager")
|
||||
# assert that the portfolio view doesn't contain Role column and does contain Admin
|
||||
self.assertNotContains(response, "Role</th>")
|
||||
self.assertContains(response, "Admin")
|
||||
self.assertContains(response, "This domain has one manager. Adding more can prevent issues.")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_user_add(self):
|
||||
|
@ -706,21 +726,18 @@ class TestDomainManagers(TestDomainOverview):
|
|||
"""Posting to the delete view deletes an invitation."""
|
||||
email_address = "mayor@igorville.gov"
|
||||
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
||||
mock_client = MockSESClient()
|
||||
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
||||
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
|
||||
mock_client.EMAILS_SENT.clear()
|
||||
with self.assertRaises(DomainInvitation.DoesNotExist):
|
||||
DomainInvitation.objects.get(id=invitation.id)
|
||||
self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
|
||||
invitation = DomainInvitation.objects.get(id=invitation.id)
|
||||
self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_invitation_cancel_retrieved_invitation(self):
|
||||
"""Posting to the delete view when invitation retrieved returns an error message"""
|
||||
"""Posting to the cancel view when invitation retrieved returns an error message"""
|
||||
email_address = "mayor@igorville.gov"
|
||||
invitation, _ = DomainInvitation.objects.get_or_create(
|
||||
domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
|
||||
)
|
||||
response = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}), follow=True)
|
||||
response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True)
|
||||
# Assert that an error message is displayed to the user
|
||||
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
|
||||
# Assert that the Cancel link is not displayed
|
||||
|
@ -731,7 +748,7 @@ class TestDomainManagers(TestDomainOverview):
|
|||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_invitation_cancel_no_permissions(self):
|
||||
"""Posting to the delete view as a different user should fail."""
|
||||
"""Posting to the cancel view as a different user should fail."""
|
||||
email_address = "mayor@igorville.gov"
|
||||
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
|
||||
|
||||
|
@ -740,7 +757,7 @@ class TestDomainManagers(TestDomainOverview):
|
|||
self.client.force_login(other_user)
|
||||
mock_client = MagicMock()
|
||||
with boto3_mocking.clients.handler_for("sesv2", mock_client):
|
||||
result = self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id}))
|
||||
result = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
|
||||
|
||||
self.assertEqual(result.status_code, 403)
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ from django.utils.html import format_html
|
|||
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'<ul class="add-list-reset">{links}</ul>') 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)
|
||||
|
|
|
@ -11,7 +11,7 @@ from .domain import (
|
|||
DomainSecurityEmailView,
|
||||
DomainUsersView,
|
||||
DomainAddUserView,
|
||||
DomainInvitationDeleteView,
|
||||
DomainInvitationCancelView,
|
||||
DomainDeleteUserView,
|
||||
)
|
||||
from .user_profile import UserProfileView, FinishProfileSetupView
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Authorization is handled by the `DomainPermissionView`. To ensure that only
|
||||
authorized users can see information on a domain, every view here should
|
||||
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView).
|
||||
inherit from `DomainPermissionView` (or DomainInvitationPermissionCancelView).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
@ -28,6 +28,7 @@ from registrar.models import (
|
|||
UserPortfolioPermission,
|
||||
PublicContact,
|
||||
)
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.utility.enums import DefaultEmail
|
||||
from registrar.utility.errors import (
|
||||
GenericError,
|
||||
|
@ -62,7 +63,7 @@ from epplibwrapper import (
|
|||
)
|
||||
|
||||
from ..utility.email import send_templated_email, EmailSendingError
|
||||
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView
|
||||
from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -841,11 +842,88 @@ class DomainUsersView(DomainBaseView):
|
|||
# Add modal buttons to the context (such as for delete)
|
||||
context = self._add_modal_buttons_to_context(context)
|
||||
|
||||
# Get portfolio from session (if set)
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
|
||||
# Add domain manager roles separately in order to also pass admin status
|
||||
context = self._add_domain_manager_roles_to_context(context, portfolio)
|
||||
|
||||
# Add domain invitations separately in order to also pass admin status
|
||||
context = self._add_invitations_to_context(context, portfolio)
|
||||
|
||||
# Get the email of the current user
|
||||
context["current_user_email"] = self.request.user.email
|
||||
|
||||
return context
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Get method for DomainUsersView."""
|
||||
# Call the parent class's `get` method to get the response and context
|
||||
response = super().get(request, *args, **kwargs)
|
||||
|
||||
# Ensure context is available after the parent call
|
||||
context = response.context_data if hasattr(response, "context_data") else {}
|
||||
|
||||
# Check if context contains `domain_managers_roles` and its length is 1
|
||||
if context.get("domain_manager_roles") and len(context["domain_manager_roles"]) == 1:
|
||||
# Add an info message
|
||||
messages.info(request, "This domain has one manager. Adding more can prevent issues.")
|
||||
|
||||
return response
|
||||
|
||||
def _add_domain_manager_roles_to_context(self, context, portfolio):
|
||||
"""Add domain_manager_roles to context separately, as roles need admin indicator."""
|
||||
|
||||
# Prepare a list to store roles with an admin flag
|
||||
domain_manager_roles = []
|
||||
|
||||
for permission in self.object.permissions.all():
|
||||
# Determine if the user has the ORGANIZATION_ADMIN role
|
||||
has_admin_flag = any(
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
|
||||
and portfolio == portfolio_permission.portfolio
|
||||
for portfolio_permission in permission.user.portfolio_permissions.all()
|
||||
)
|
||||
|
||||
# Add the role along with the computed flag to the list
|
||||
domain_manager_roles.append({"permission": permission, "has_admin_flag": has_admin_flag})
|
||||
|
||||
# Pass roles_with_flags to the context
|
||||
context["domain_manager_roles"] = domain_manager_roles
|
||||
|
||||
return context
|
||||
|
||||
def _add_invitations_to_context(self, context, portfolio):
|
||||
"""Add invitations to context separately as invitations needs admin indicator."""
|
||||
|
||||
# Prepare a list to store invitations with an admin flag
|
||||
invitations = []
|
||||
|
||||
for domain_invitation in self.object.invitations.all():
|
||||
# Check if there are any PortfolioInvitations linked to the same portfolio with the ORGANIZATION_ADMIN role
|
||||
has_admin_flag = False
|
||||
|
||||
# Query PortfolioInvitations linked to the same portfolio and check roles
|
||||
portfolio_invitations = PortfolioInvitation.objects.filter(
|
||||
portfolio=portfolio, email=domain_invitation.email
|
||||
)
|
||||
|
||||
# If any of the PortfolioInvitations have the ORGANIZATION_ADMIN role, set the flag to True
|
||||
for portfolio_invitation in portfolio_invitations:
|
||||
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles:
|
||||
has_admin_flag = True
|
||||
break # Once we find one match, no need to check further
|
||||
|
||||
# Add the role along with the computed flag to the list if the domain invitation
|
||||
# if the status is not canceled
|
||||
if domain_invitation.status != "canceled":
|
||||
invitations.append({"domain_invitation": domain_invitation, "has_admin_flag": has_admin_flag})
|
||||
|
||||
# Pass roles_with_flags to the context
|
||||
context["invitations"] = invitations
|
||||
|
||||
return context
|
||||
|
||||
def _add_booleans_to_context(self, context):
|
||||
# Determine if the current user can delete managers
|
||||
domain_pk = None
|
||||
|
@ -909,6 +987,23 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
|
||||
)
|
||||
|
||||
def _check_invite_status(self, invite, email):
|
||||
"""Check if invitation status is canceled or retrieved, and gives the appropiate response"""
|
||||
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
||||
messages.warning(
|
||||
self.request,
|
||||
f"{email} is already a manager for this domain.",
|
||||
)
|
||||
return False
|
||||
elif invite.status == DomainInvitation.DomainInvitationStatus.CANCELED:
|
||||
invite.update_cancellation_status()
|
||||
invite.save()
|
||||
return True
|
||||
else:
|
||||
# else if it has been sent but not accepted
|
||||
messages.warning(self.request, f"{email} has already been invited to this domain")
|
||||
return False
|
||||
|
||||
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
|
||||
"""Performs the sending of the domain invitation email,
|
||||
does not make a domain information object
|
||||
|
@ -944,17 +1039,8 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
# Check to see if an invite has already been sent
|
||||
try:
|
||||
invite = DomainInvitation.objects.get(email=email, domain=self.object)
|
||||
# check if the invite has already been accepted
|
||||
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED:
|
||||
add_success = False
|
||||
messages.warning(
|
||||
self.request,
|
||||
f"{email} is already a manager for this domain.",
|
||||
)
|
||||
else:
|
||||
add_success = False
|
||||
# else if it has been sent but not accepted
|
||||
messages.warning(self.request, f"{email} has already been invited to this domain")
|
||||
# check if the invite has already been accepted or has a canceled invite
|
||||
add_success = self._check_invite_status(invite, email)
|
||||
except Exception:
|
||||
logger.error("An error occured")
|
||||
|
||||
|
@ -976,6 +1062,7 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info(exc)
|
||||
raise EmailSendingError("Could not send email invitation.") from exc
|
||||
else:
|
||||
if add_success:
|
||||
|
@ -1051,11 +1138,9 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
return redirect(self.get_success_url())
|
||||
|
||||
|
||||
# The order of the superclasses matters here. BaseDeleteView has a bug where the
|
||||
# "form_valid" function does not call super, so it cannot use SuccessMessageMixin.
|
||||
# The workaround is to use SuccessMessageMixin first.
|
||||
class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView):
|
||||
object: DomainInvitation # workaround for type mismatch in DeleteView
|
||||
class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView):
|
||||
object: DomainInvitation
|
||||
fields = []
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""Override post method in order to error in the case when the
|
||||
|
@ -1063,6 +1148,8 @@ class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermission
|
|||
self.object = self.get_object()
|
||||
form = self.get_form()
|
||||
if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED:
|
||||
self.object.cancel_invitation()
|
||||
self.object.save()
|
||||
return self.form_valid(form)
|
||||
else:
|
||||
# Produce an error message if the domain invatation status is RETRIEVED
|
||||
|
|
|
@ -5,9 +5,9 @@ from .permission_views import (
|
|||
DomainPermissionView,
|
||||
DomainRequestPermissionView,
|
||||
DomainRequestPermissionWithdrawView,
|
||||
DomainInvitationPermissionDeleteView,
|
||||
DomainRequestWizardPermissionView,
|
||||
PortfolioMembersPermission,
|
||||
DomainRequestPortfolioViewonlyView,
|
||||
DomainInvitationPermissionCancelView,
|
||||
)
|
||||
from .api_views import get_senior_official_from_federal_agency_json
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -430,7 +430,6 @@ class DomainInvitationPermission(PermissionsLoginMixin):
|
|||
id=self.kwargs["pk"], domain__permissions__user=self.request.user
|
||||
).exists():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import abc # abstract base class
|
||||
|
||||
from django.views.generic import DetailView, DeleteView, TemplateView
|
||||
from django.views.generic import DetailView, DeleteView, TemplateView, UpdateView
|
||||
from registrar.models import Domain, DomainRequest, DomainInvitation, Portfolio
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
|
@ -156,17 +156,11 @@ class DomainRequestWizardPermissionView(DomainRequestWizardPermission, TemplateV
|
|||
raise NotImplementedError
|
||||
|
||||
|
||||
class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteView, abc.ABC):
|
||||
"""Abstract view for deleting a domain invitation.
|
||||
|
||||
This one is fairly specialized, but this is the only thing that we do
|
||||
right now with domain invitations. We still have the full
|
||||
`DomainInvitationPermission` class, but here we just pair it with a
|
||||
DeleteView.
|
||||
"""
|
||||
class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC):
|
||||
"""Abstract view for cancelling a DomainInvitation."""
|
||||
|
||||
model = DomainInvitation
|
||||
object: DomainInvitation # workaround for type mismatch in DeleteView
|
||||
object: DomainInvitation
|
||||
|
||||
|
||||
class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue