Merge remote-tracking branch 'origin' into ms/2826-self-host-select2

This commit is contained in:
Matthew Spence 2024-11-19 11:38:47 -06:00
commit 1d08f52447
No known key found for this signature in database
22 changed files with 1208 additions and 102 deletions

3
.gitignore vendored
View file

@ -171,6 +171,9 @@ node_modules
# Vim # Vim
*.swp *.swp
# VS Code
.vscode
# Compliance/trestle related # Compliance/trestle related
docs/compliance/.trestle/cache docs/compliance/.trestle/cache

View file

@ -1,12 +1,14 @@
from datetime import date from datetime import date
import logging import logging
import copy import copy
from typing import Optional
from django import forms from django import forms
from django.db.models import Value, CharField, Q from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency from registrar.models.federal_agency import FederalAgency
from registrar.utility.admin_helpers import ( from registrar.utility.admin_helpers import (
AutocompleteSelectWithPlaceholder,
get_action_needed_reason_default_email, get_action_needed_reason_default_email,
get_rejection_reason_default_email, get_rejection_reason_default_email,
get_field_links_as_list, get_field_links_as_list,
@ -236,6 +238,14 @@ class DomainRequestAdminForm(forms.ModelForm):
"current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False), "current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False),
"alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False),
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", 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 = { labels = {
"action_needed_reason_email": "Email", "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.admin_order_field = "is_election_board" # type: ignore
custom_election_board.short_description = "Election office" # 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 just a placeholder. This field will be populated in the detail_table_fieldset view.
# This is not a field that exists on the model. # This is not a field that exists on the model.
def status_history(self, obj): def status_history(self, obj):
@ -1847,30 +1921,38 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
None, None,
{ {
"fields": [ "fields": [
"portfolio",
"sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
"status_history", "status_history",
"status", "status",
"rejection_reason", "rejection_reason",
"rejection_reason_email", "rejection_reason_email",
"action_needed_reason", "action_needed_reason",
"action_needed_reason_email", "action_needed_reason_email",
"investigator",
"creator",
"approved_domain", "approved_domain",
"investigator",
"notes", "notes",
] ]
}, },
), ),
(
"Requested by",
{
"fields": [
"portfolio",
"sub_organization",
"requested_suborganization",
"suborganization_city",
"suborganization_state_territory",
"creator",
]
},
),
(".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}),
( (
"Contacts", "Contacts",
{ {
"fields": [ "fields": [
"senior_official", "senior_official",
"portfolio_senior_official",
"other_contacts", "other_contacts",
"no_other_contacts_rationale", "no_other_contacts_rationale",
"cisa_representative_first_name", "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 for analysts and superusers
readonly_fields = ( 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", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
@ -1979,10 +2106,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
def get_fieldsets(self, request, obj=None): def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj) 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 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 = [ excluded_fields = [
"portfolio",
"sub_organization",
"requested_suborganization", "requested_suborganization",
"suborganization_city", "suborganization_city",
"suborganization_state_territory", "suborganization_state_territory",

View file

@ -86,6 +86,506 @@ function handleSuborganizationFields(
portfolioDropdown.on("change", toggleSuborganizationFields); 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, its 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 arent 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. // Initialization code.
@ -797,6 +1297,63 @@ document.addEventListener('DOMContentLoaded', function() {
customEmail.loadRejectedEmail() 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) /** An IIFE for copy summary button (appears in DomainRegistry models)
*/ */
@ -844,10 +1401,10 @@ document.addEventListener('DOMContentLoaded', function() {
if (contacts) { if (contacts) {
contacts.forEach(contact => { contacts.forEach(contact => {
// Check if the <dl> element is not empty // Check if the <dl> element is not empty
const name = contact.querySelector('a#contact_info_name')?.innerText; const name = contact.querySelector('a.contact_info_name')?.innerText;
const title = contact.querySelector('span#contact_info_title')?.innerText; const title = contact.querySelector('span.contact_info_title')?.innerText;
const email = contact.querySelector('span#contact_info_email')?.innerText; const email = contact.querySelector('span.contact_info_email')?.innerText;
const phone = contact.querySelector('span#contact_info_phone')?.innerText; const phone = contact.querySelector('span.contact_info_phone')?.innerText;
const url = nameToUrlMap[name] || '#'; const url = nameToUrlMap[name] || '#';
// Format the contact information // Format the contact information
const listItem = document.createElement('li'); const listItem = document.createElement('li');
@ -898,9 +1455,9 @@ document.addEventListener('DOMContentLoaded', function() {
const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official');
const seniorOfficialElement = document.getElementById('id_senior_official'); const seniorOfficialElement = document.getElementById('id_senior_official');
const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text; const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text;
const seniorOfficialTitle = extractTextById('contact_info_title', seniorOfficialDiv); const seniorOfficialTitle = seniorOfficialDiv.querySelector('.contact_info_title');
const seniorOfficialEmail = extractTextById('contact_info_email', seniorOfficialDiv); const seniorOfficialEmail = seniorOfficialDiv.querySelector('.contact_info_email');
const seniorOfficialPhone = extractTextById('contact_info_phone', seniorOfficialDiv); const seniorOfficialPhone = seniorOfficialDiv.querySelector('.contact_info_phone');
let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`; let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`;
const html_summary = `<strong>Recommendation:</strong></br>` + 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 /** An IIFE for dynamically changing some fields on the portfolio admin model
* IMPORTANT NOTE: The logic in this IIFE is paired handlePortfolioSelection
*/ */
(function dynamicPortfolioFields(){ (function dynamicPortfolioFields(){
@ -1184,9 +1742,9 @@ document.addEventListener('DOMContentLoaded', function() {
function updateContactInfo(data) { function updateContactInfo(data) {
if (!contactList) return; if (!contactList) return;
const titleSpan = contactList.querySelector("#contact_info_title"); const titleSpan = contactList.querySelector(".contact_info_title");
const emailSpan = contactList.querySelector("#contact_info_email"); const emailSpan = contactList.querySelector(".contact_info_email");
const phoneSpan = contactList.querySelector("#contact_info_phone"); const phoneSpan = contactList.querySelector(".contact_info_phone");
if (titleSpan) { if (titleSpan) {
titleSpan.textContent = data.title || "None"; titleSpan.textContent = data.title || "None";
@ -1218,7 +1776,7 @@ document.addEventListener('DOMContentLoaded', function() {
(function dynamicDomainRequestFields(){ (function dynamicDomainRequestFields(){
const domainRequestPage = document.getElementById("domainrequest_form"); const domainRequestPage = document.getElementById("domainrequest_form");
if (domainRequestPage) { if (domainRequestPage) {
handleSuborganizationFields(); handlePortfolioSelection();
} }
})(); })();

View file

@ -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.domains_json import get_domains_json
from registrar.views.utility.api_views import ( from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json, get_senior_official_from_federal_agency_json,
get_portfolio_json,
get_suborganization_list_json,
get_federal_and_portfolio_types_from_federal_agency_json, get_federal_and_portfolio_types_from_federal_agency_json,
get_action_needed_email_for_user_json, get_action_needed_email_for_user_json,
get_rejection_email_for_user_json, get_rejection_email_for_user_json,
@ -201,6 +203,16 @@ urlpatterns = [
get_senior_official_from_federal_agency_json, get_senior_official_from_federal_agency_json,
name="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( path(
"admin/api/get-federal-and-portfolio-types-from-federal-agency-json/", "admin/api/get-federal-and-portfolio-types-from-federal-agency-json/",
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", name="user-profile",
), ),
path( path(
"invitation/<int:pk>/delete", "invitation/<int:pk>/cancel",
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]), views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
name="invitation-delete", name="invitation-cancel",
), ),
path( path(
"domain-request/<int:pk>/delete", "domain-request/<int:pk>/delete",

View file

@ -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,
),
),
]

View file

@ -26,6 +26,7 @@ class DomainInvitation(TimeStampedModel):
class DomainInvitationStatus(models.TextChoices): class DomainInvitationStatus(models.TextChoices):
INVITED = "invited", "Invited" INVITED = "invited", "Invited"
RETRIEVED = "retrieved", "Retrieved" RETRIEVED = "retrieved", "Retrieved"
CANCELED = "canceled", "Canceled"
email = models.EmailField( email = models.EmailField(
null=False, null=False,
@ -73,3 +74,13 @@ class DomainInvitation(TimeStampedModel):
# something strange happened and this role already existed when # something strange happened and this role already existed when
# the invitation was retrieved. Log that this occurred. # the invitation was retrieved. Log that this occurred.
logger.warn("Invitation %s was retrieved for a role that already exists.", self) 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

View file

@ -4,7 +4,25 @@
Template for an input field with a clipboard Template for an input field with a clipboard
{% endcomment %} {% 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"> <div class="admin-icon-group">
{{ field }} {{ field }}
<button <button

View file

@ -2,6 +2,13 @@
{% load custom_filters %} {% load custom_filters %}
{% load i18n static %} {% 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 %} {% block field_sets %}
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #} {# 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> <a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>

View file

@ -6,7 +6,7 @@
{% if show_formatted_name %} {% if show_formatted_name %}
{% if user.get_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 %} {% else %}
None None
{% endif %} {% endif %}
@ -16,7 +16,7 @@
{% if user|has_contact_info %} {% if user|has_contact_info %}
{# Title #} {# Title #}
{% if user.title %} {% if user.title %}
<span id="contact_info_title">{{ user.title }}</span> <span class="contact_info_title">{{ user.title }}</span>
{% else %} {% else %}
None None
{% endif %} {% endif %}
@ -24,7 +24,7 @@
{# Email #} {# Email #}
{% if user.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 %} {% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
<br> <br>
{% else %} {% else %}
@ -33,17 +33,24 @@
{# Phone #} {# Phone #}
{% if user.phone %} {% if user.phone %}
<span id="contact_info_phone">{{ user.phone }}</span> <span class="contact_info_phone">{{ user.phone }}</span>
<br> <br>
{% else %} {% else %}
None<br> None<br>
{% endif %} {% 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 %} {% elif not hide_no_contact_info_message %}
No additional contact information found.<br> No additional contact information found.<br>
{% endif %} {% endif %}
{% if user_verification_type and not skip_additional_contact_info %} {% 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 %} {% endif %}
</address> </address>

View file

@ -66,6 +66,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
No changelog to display. No changelog to display.
</div> </div>
{% endif %} {% 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" %} {% elif field.field.name == "other_contacts" %}
{% if all_contacts.count > 2 %} {% if all_contacts.count > 2 %}
<div class="readonly"> <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> <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 %} {% include "django/admin/includes/contact_detail_list.html" with user=original_object.senior_official no_title_top_padding=field.is_readonly %}
</div> </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 %} {% elif field.field.name == "other_contacts" and original_object.other_contacts.all %}
{% with all_contacts=original_object.other_contacts.all %} {% with all_contacts=original_object.other_contacts.all %}
{% if all_contacts.count > 2 %} {% if all_contacts.count > 2 %}

View file

@ -6,21 +6,30 @@
{% block domain_content %} {% block domain_content %}
<h1>Domain managers</h1> <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> <p>
Domain managers can update all information related to a domain within the 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> </p>
{% endif %}
<ul class="usa-list"> <ul class="usa-list">
<li>There is no limit to the number of domain managers you can add.</li> <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 <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> 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 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> {% 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 cant remove yourself as a domain manager if youre the only one assigned to this domain.</li> <li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain.
{% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}</li>
</ul> </ul>
{% if domain.permissions %} {% if domain_manager_roles %}
<section class="section-outlined"> <section class="section-outlined">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<h2 class> Domain managers </h2> <h2 class> Domain managers </h2>
@ -28,17 +37,18 @@
<thead> <thead>
<tr> <tr>
<th data-sortable scope="col" role="columnheader">Email</th> <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> <th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for permission in domain.permissions.all %} {% for item in domain_manager_roles %}
<tr> <tr>
<th scope="row" role="rowheader" data-sort-value="{{ permission.user.email }}" data-label="Email"> <th scope="row" role="rowheader" data-sort-value="{{ item.permission.user.email }}" data-label="Email">
{{ permission.user.email }} {{ item.permission.user.email }}
{% if item.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
</th> </th>
<td data-label="Role">{{ permission.role|title }}</td> {% if not portfolio %}<td data-label="Role">{{ item.permission.role|title }}</td>{% endif %}
<td> <td>
{% if can_delete_users %} {% if can_delete_users %}
<a <a
@ -52,7 +62,7 @@
Remove Remove
</a> </a>
{# Display a custom message if the user is trying to delete themselves #} {# 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 <div
class="usa-modal" class="usa-modal"
id="toggle-user-alert-{{ forloop.counter }}" id="toggle-user-alert-{{ forloop.counter }}"
@ -60,7 +70,7 @@
aria-describedby="You will be removed from this domain" aria-describedby="You will be removed from this domain"
data-force-action 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 %} {% 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 %} {% 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 %} {% endwith %}
@ -71,11 +81,11 @@
class="usa-modal" class="usa-modal"
id="toggle-user-alert-{{ forloop.counter }}" id="toggle-user-alert-{{ forloop.counter }}"
aria-labelledby="Are you sure you want to continue?" 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 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 email=permission.user.email|default:permission.user|force_escape domain_name=domain.name|force_escape %} {% 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 %} {% 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 %} {% endwith %}
</form> </form>
@ -111,7 +121,7 @@
</a> </a>
</section> </section>
{% if domain.invitations.exists %} {% if invitations %}
<section class="section-outlined"> <section class="section-outlined">
<h2>Invitations</h2> <h2>Invitations</h2>
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
@ -120,21 +130,22 @@
<tr> <tr>
<th data-sortable scope="col" role="columnheader">Email</th> <th data-sortable scope="col" role="columnheader">Email</th>
<th data-sortable scope="col" role="columnheader">Date created</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> <th class="grid-col-1" scope="col" role="columnheader"><span class="sr-only">Action</span></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for invitation in domain.invitations.all %} {% for invitation in invitations %}
<tr> <tr>
<th scope="row" role="rowheader" data-sort-value="{{ invitation.user.email }}" data-label="Email"> <th scope="row" role="rowheader" data-sort-value="{{ invitation.domain_invitation.user.email }}" data-label="Email">
{{ invitation.email }} {{ invitation.domain_invitation.email }}
{% if invitation.has_admin_flag %}<span class="usa-tag margin-left-1 bg-primary">Admin</span>{% endif %}
</th> </th>
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td> <td data-sort-value="{{ invitation.domain_invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.domain_invitation.created_at|date }} </td>
<td data-label="Status">{{ invitation.status|title }}</td> {% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
<td> <td>
{% if invitation.status == invitation.DomainInvitationStatus.INVITED %} {% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
<form method="POST" action="{% url "invitation-delete" pk=invitation.id %}"> <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"> {% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
</form> </form>
{% endif %} {% endif %}

View file

@ -200,7 +200,7 @@ def is_domain_subpage(path):
"domain-users-add", "domain-users-add",
"domain-request-delete", "domain-request-delete",
"domain-user-delete", "domain-user-delete",
"invitation-delete", "invitation-cancel",
] ]
return get_url_name(path) in url_names return get_url_name(path) in url_names

View file

@ -1526,7 +1526,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link # 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 # Test that Creator counts display properly
self.assertNotContains(response, "Approved domains") self.assertNotContains(response, "Approved domains")
@ -1626,6 +1626,17 @@ class TestDomainRequestAdmin(MockEppLib):
readonly_fields = self.admin.get_readonly_fields(request, domain_request) readonly_fields = self.admin.get_readonly_fields(request, domain_request)
expected_fields = [ 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", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
@ -1691,6 +1702,17 @@ class TestDomainRequestAdmin(MockEppLib):
readonly_fields = self.admin.get_readonly_fields(request) readonly_fields = self.admin.get_readonly_fields(request)
self.maxDiff = None self.maxDiff = None
expected_fields = [ 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", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",
@ -1723,6 +1745,17 @@ class TestDomainRequestAdmin(MockEppLib):
readonly_fields = self.admin.get_readonly_fields(request) readonly_fields = self.admin.get_readonly_fields(request)
expected_fields = [ 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", "other_contacts",
"current_websites", "current_websites",
"alternative_domains", "alternative_domains",

View file

@ -2,7 +2,8 @@ from django.urls import reverse
from django.test import TestCase, Client from django.test import TestCase, Client
from registrar.models import FederalAgency, SeniorOfficial, User, DomainRequest from registrar.models import FederalAgency, SeniorOfficial, User, DomainRequest
from django.contrib.auth import get_user_model 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 api.tests.common import less_console_noise_decorator
from registrar.utility.constants import BranchChoices from registrar.utility.constants import BranchChoices
@ -74,6 +75,79 @@ class GetSeniorOfficialJsonTest(TestCase):
self.assertEqual(data["error"], "Senior Official not found") 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): class GetFederalPortfolioTypeJsonTest(TestCase):
def setUp(self): def setUp(self):
self.client = Client() self.client = Client()

View file

@ -370,6 +370,17 @@ class TestDomainManagers(TestDomainOverview):
] ]
AllowedEmail.objects.bulk_create(allowed_emails) 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 @classmethod
def tearDownClass(cls): def tearDownClass(cls):
super().tearDownClass() super().tearDownClass()
@ -383,13 +394,22 @@ class TestDomainManagers(TestDomainOverview):
def test_domain_managers(self): def test_domain_managers(self):
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
self.assertContains(response, "Domain managers") 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 @less_console_noise_decorator
def test_domain_managers_add_link(self): @override_flag("organization_feature", active=True)
"""Button to get to user add page works.""" def test_domain_managers_portfolio_view(self):
management_page = self.app.get(reverse("domain-users", kwargs={"pk": self.domain.id})) response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
add_page = management_page.click("Add a domain manager") self.assertContains(response, "Domain managers")
self.assertContains(add_page, "Add a domain manager") 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 @less_console_noise_decorator
def test_domain_user_add(self): def test_domain_user_add(self):
@ -706,21 +726,18 @@ class TestDomainManagers(TestDomainOverview):
"""Posting to the delete view deletes an invitation.""" """Posting to the delete view deletes an invitation."""
email_address = "mayor@igorville.gov" email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
mock_client = MockSESClient() self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
with boto3_mocking.clients.handler_for("sesv2", mock_client): invitation = DomainInvitation.objects.get(id=invitation.id)
self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED)
mock_client.EMAILS_SENT.clear()
with self.assertRaises(DomainInvitation.DoesNotExist):
DomainInvitation.objects.get(id=invitation.id)
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_invitation_cancel_retrieved_invitation(self): 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" email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create( invitation, _ = DomainInvitation.objects.get_or_create(
domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED 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 # Assert that an error message is displayed to the user
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.") self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
# Assert that the Cancel link is not displayed # Assert that the Cancel link is not displayed
@ -731,7 +748,7 @@ class TestDomainManagers(TestDomainOverview):
@less_console_noise_decorator @less_console_noise_decorator
def test_domain_invitation_cancel_no_permissions(self): 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" email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address) 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) self.client.force_login(other_user)
mock_client = MagicMock() mock_client = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client): 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) self.assertEqual(result.status_code, 403)

View file

@ -4,6 +4,7 @@ from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape from django.utils.html import escape
from registrar.models.utility.generic_helper import value_of_attribute 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): def get_action_needed_reason_default_email(domain_request, action_needed_reason):
@ -94,3 +95,26 @@ def get_field_links_as_list(
else: else:
links = "".join(links) links = "".join(links)
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else msg_for_none 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)

View file

@ -11,7 +11,7 @@ from .domain import (
DomainSecurityEmailView, DomainSecurityEmailView,
DomainUsersView, DomainUsersView,
DomainAddUserView, DomainAddUserView,
DomainInvitationDeleteView, DomainInvitationCancelView,
DomainDeleteUserView, DomainDeleteUserView,
) )
from .user_profile import UserProfileView, FinishProfileSetupView from .user_profile import UserProfileView, FinishProfileSetupView

View file

@ -2,7 +2,7 @@
Authorization is handled by the `DomainPermissionView`. To ensure that only Authorization is handled by the `DomainPermissionView`. To ensure that only
authorized users can see information on a domain, every view here should 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 from datetime import date
@ -28,6 +28,7 @@ from registrar.models import (
UserPortfolioPermission, UserPortfolioPermission,
PublicContact, PublicContact,
) )
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.enums import DefaultEmail from registrar.utility.enums import DefaultEmail
from registrar.utility.errors import ( from registrar.utility.errors import (
GenericError, GenericError,
@ -62,7 +63,7 @@ from epplibwrapper import (
) )
from ..utility.email import send_templated_email, EmailSendingError from ..utility.email import send_templated_email, EmailSendingError
from .utility import DomainPermissionView, DomainInvitationPermissionDeleteView from .utility import DomainPermissionView, DomainInvitationPermissionCancelView
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -841,11 +842,88 @@ class DomainUsersView(DomainBaseView):
# Add modal buttons to the context (such as for delete) # Add modal buttons to the context (such as for delete)
context = self._add_modal_buttons_to_context(context) 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 # Get the email of the current user
context["current_user_email"] = self.request.user.email context["current_user_email"] = self.request.user.email
return context 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): def _add_booleans_to_context(self, context):
# Determine if the current user can delete managers # Determine if the current user can delete managers
domain_pk = None domain_pk = None
@ -909,6 +987,23 @@ class DomainAddUserView(DomainFormBaseView):
existing_org_invitation and existing_org_invitation.portfolio != requestor_org 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): def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
"""Performs the sending of the domain invitation email, """Performs the sending of the domain invitation email,
does not make a domain information object does not make a domain information object
@ -944,17 +1039,8 @@ class DomainAddUserView(DomainFormBaseView):
# Check to see if an invite has already been sent # Check to see if an invite has already been sent
try: try:
invite = DomainInvitation.objects.get(email=email, domain=self.object) invite = DomainInvitation.objects.get(email=email, domain=self.object)
# check if the invite has already been accepted # check if the invite has already been accepted or has a canceled invite
if invite.status == DomainInvitation.DomainInvitationStatus.RETRIEVED: add_success = self._check_invite_status(invite, email)
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")
except Exception: except Exception:
logger.error("An error occured") logger.error("An error occured")
@ -976,6 +1062,7 @@ class DomainAddUserView(DomainFormBaseView):
self.object, self.object,
exc_info=True, exc_info=True,
) )
logger.info(exc)
raise EmailSendingError("Could not send email invitation.") from exc raise EmailSendingError("Could not send email invitation.") from exc
else: else:
if add_success: if add_success:
@ -1051,11 +1138,9 @@ class DomainAddUserView(DomainFormBaseView):
return redirect(self.get_success_url()) return redirect(self.get_success_url())
# The order of the superclasses matters here. BaseDeleteView has a bug where the class DomainInvitationCancelView(SuccessMessageMixin, DomainInvitationPermissionCancelView):
# "form_valid" function does not call super, so it cannot use SuccessMessageMixin. object: DomainInvitation
# The workaround is to use SuccessMessageMixin first. fields = []
class DomainInvitationDeleteView(SuccessMessageMixin, DomainInvitationPermissionDeleteView):
object: DomainInvitation # workaround for type mismatch in DeleteView
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Override post method in order to error in the case when the """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() self.object = self.get_object()
form = self.get_form() form = self.get_form()
if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED: if form.is_valid() and self.object.status == self.object.DomainInvitationStatus.INVITED:
self.object.cancel_invitation()
self.object.save()
return self.form_valid(form) return self.form_valid(form)
else: else:
# Produce an error message if the domain invatation status is RETRIEVED # Produce an error message if the domain invatation status is RETRIEVED

View file

@ -5,9 +5,9 @@ from .permission_views import (
DomainPermissionView, DomainPermissionView,
DomainRequestPermissionView, DomainRequestPermissionView,
DomainRequestPermissionWithdrawView, DomainRequestPermissionWithdrawView,
DomainInvitationPermissionDeleteView,
DomainRequestWizardPermissionView, DomainRequestWizardPermissionView,
PortfolioMembersPermission, PortfolioMembersPermission,
DomainRequestPortfolioViewonlyView, DomainRequestPortfolioViewonlyView,
DomainInvitationPermissionCancelView,
) )
from .api_views import get_senior_official_from_federal_agency_json from .api_views import get_senior_official_from_federal_agency_json

View file

@ -39,6 +39,86 @@ def get_senior_official_from_federal_agency_json(request):
return JsonResponse({"error": "Senior Official not found"}, status=404) 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 @login_required
@staff_member_required @staff_member_required
def get_federal_and_portfolio_types_from_federal_agency_json(request): def get_federal_and_portfolio_types_from_federal_agency_json(request):

View file

@ -430,7 +430,6 @@ class DomainInvitationPermission(PermissionsLoginMixin):
id=self.kwargs["pk"], domain__permissions__user=self.request.user id=self.kwargs["pk"], domain__permissions__user=self.request.user
).exists(): ).exists():
return False return False
return True return True

View file

@ -2,7 +2,7 @@
import abc # abstract base class 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 import Domain, DomainRequest, DomainInvitation, Portfolio
from registrar.models.user import User from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole from registrar.models.user_domain_role import UserDomainRole
@ -156,17 +156,11 @@ class DomainRequestWizardPermissionView(DomainRequestWizardPermission, TemplateV
raise NotImplementedError raise NotImplementedError
class DomainInvitationPermissionDeleteView(DomainInvitationPermission, DeleteView, abc.ABC): class DomainInvitationPermissionCancelView(DomainInvitationPermission, UpdateView, abc.ABC):
"""Abstract view for deleting a domain invitation. """Abstract view for cancelling a DomainInvitation."""
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.
"""
model = DomainInvitation model = DomainInvitation
object: DomainInvitation # workaround for type mismatch in DeleteView object: DomainInvitation
class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC): class DomainRequestPermissionDeleteView(DomainRequestPermission, DeleteView, abc.ABC):