mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-22 10:46:06 +02:00
Merge branch 'main' of https://github.com/cisagov/manage.get.gov into hotgov/2594-design-review
This commit is contained in:
commit
f440f1ac1c
51 changed files with 3051 additions and 973 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,8 @@ The following set of rules should be followed while an incident is in progress.
|
||||||
- If downtime occurs outside of working hours, team members who are off for the day may still be pinged and called but are not required to join if unavailable to do so.
|
- If downtime occurs outside of working hours, team members who are off for the day may still be pinged and called but are not required to join if unavailable to do so.
|
||||||
- Uncomment the [banner on get.gov](https://github.com/cisagov/get.gov/blob/0365d3d34b041cc9353497b2b5f81b6ab7fe75a9/_includes/header.html#L9), so it is transparent to users that we know about the issue on manage.get.gov.
|
- Uncomment the [banner on get.gov](https://github.com/cisagov/get.gov/blob/0365d3d34b041cc9353497b2b5f81b6ab7fe75a9/_includes/header.html#L9), so it is transparent to users that we know about the issue on manage.get.gov.
|
||||||
- Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
|
- Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
|
||||||
|
- Uncomment the [banner on manage.get.gov's base template](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/base.html#L78).
|
||||||
|
- Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
|
||||||
- If the issue persists for three hours or more, follow the [instructions for enabling/disabling a redirect to get.gov](https://docs.google.com/document/d/1PiWXpjBzbiKsSYqEo9Rkl72HMytMp7zTte9CI-vvwYw/edit).
|
- If the issue persists for three hours or more, follow the [instructions for enabling/disabling a redirect to get.gov](https://docs.google.com/document/d/1PiWXpjBzbiKsSYqEo9Rkl72HMytMp7zTte9CI-vvwYw/edit).
|
||||||
|
|
||||||
## Post Incident
|
## Post Incident
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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, 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.
|
// 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();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,3 +1,4 @@
|
||||||
|
@use "uswds-core" as *;
|
||||||
@use "base" as *;
|
@use "base" as *;
|
||||||
|
|
||||||
// Fixes some font size disparities with the Figma
|
// Fixes some font size disparities with the Figma
|
||||||
|
@ -29,3 +30,24 @@
|
||||||
.usa-alert__body--widescreen {
|
.usa-alert__body--widescreen {
|
||||||
max-width: $widescreen-max-width !important;
|
max-width: $widescreen-max-width !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usa-site-alert--hot-pink {
|
||||||
|
.usa-alert {
|
||||||
|
background-color: $hot-pink;
|
||||||
|
border-left-color: $hot-pink;
|
||||||
|
.usa-alert__body {
|
||||||
|
color: color('base-darkest');
|
||||||
|
background-color: $hot-pink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports ((-webkit-mask:url()) or (mask:url())) {
|
||||||
|
.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before {
|
||||||
|
background-color: color('base-darkest');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before {
|
||||||
|
background-image: url('../img/usa-icons-bg/error.svg');
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
@use "cisa_colors" as *;
|
@use "cisa_colors" as *;
|
||||||
|
|
||||||
$widescreen-max-width: 1920px;
|
$widescreen-max-width: 1920px;
|
||||||
|
$hot-pink: #FFC3F9;
|
||||||
|
|
||||||
/* Styles for making visible to screen reader / AT users only. */
|
/* Styles for making visible to screen reader / AT users only. */
|
||||||
.sr-only {
|
.sr-only {
|
||||||
|
|
|
@ -119,7 +119,7 @@ in the form $setting: value,
|
||||||
/*---------------------------
|
/*---------------------------
|
||||||
## Emergency state
|
## Emergency state
|
||||||
----------------------------*/
|
----------------------------*/
|
||||||
$theme-color-emergency: #FFC3F9,
|
$theme-color-emergency: "red-warm-60v",
|
||||||
|
|
||||||
/*---------------------------
|
/*---------------------------
|
||||||
# Input settings
|
# Input settings
|
||||||
|
|
|
@ -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,
|
||||||
|
@ -91,6 +93,11 @@ urlpatterns = [
|
||||||
views.PortfolioMemberView.as_view(),
|
views.PortfolioMemberView.as_view(),
|
||||||
name="member",
|
name="member",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"member/<int:pk>/delete",
|
||||||
|
views.PortfolioMemberDeleteView.as_view(),
|
||||||
|
name="member-delete",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"member/<int:pk>/permissions",
|
"member/<int:pk>/permissions",
|
||||||
views.PortfolioMemberEditView.as_view(),
|
views.PortfolioMemberEditView.as_view(),
|
||||||
|
@ -106,6 +113,11 @@ urlpatterns = [
|
||||||
views.PortfolioInvitedMemberView.as_view(),
|
views.PortfolioInvitedMemberView.as_view(),
|
||||||
name="invitedmember",
|
name="invitedmember",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"invitedmember/<int:pk>/delete",
|
||||||
|
views.PortfolioInvitedMemberDeleteView.as_view(),
|
||||||
|
name="invitedmember-delete",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"invitedmember/<int:pk>/permissions",
|
"invitedmember/<int:pk>/permissions",
|
||||||
views.PortfolioInvitedMemberEditView.as_view(),
|
views.PortfolioInvitedMemberEditView.as_view(),
|
||||||
|
@ -201,6 +213,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 +349,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",
|
||||||
|
|
|
@ -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):
|
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
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from registrar.models import DomainInformation, UserDomainRole
|
from registrar.models import DomainInformation, UserDomainRole
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
|
||||||
from .domain_invitation import DomainInvitation
|
from .domain_invitation import DomainInvitation
|
||||||
from .portfolio_invitation import PortfolioInvitation
|
from .portfolio_invitation import PortfolioInvitation
|
||||||
|
@ -471,3 +472,42 @@ class User(AbstractUser):
|
||||||
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
|
return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True)
|
||||||
else:
|
else:
|
||||||
return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)
|
return UserDomainRole.objects.filter(user=self).values_list("id", flat=True)
|
||||||
|
|
||||||
|
def get_active_requests_count_in_portfolio(self, request):
|
||||||
|
"""Return count of active requests for the portfolio associated with the request."""
|
||||||
|
# Get the portfolio from the session using the existing method
|
||||||
|
|
||||||
|
portfolio = request.session.get("portfolio")
|
||||||
|
|
||||||
|
if not portfolio:
|
||||||
|
return 0 # No portfolio found
|
||||||
|
|
||||||
|
allowed_states = [
|
||||||
|
DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||||
|
DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Now filter based on the portfolio retrieved
|
||||||
|
active_requests_count = self.domain_requests_created.filter(
|
||||||
|
status__in=allowed_states, portfolio=portfolio
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return active_requests_count
|
||||||
|
|
||||||
|
def is_only_admin_of_portfolio(self, portfolio):
|
||||||
|
"""Check if the user is the only admin of the given portfolio."""
|
||||||
|
|
||||||
|
UserPortfolioPermission = apps.get_model("registrar", "UserPortfolioPermission")
|
||||||
|
|
||||||
|
admin_permission = UserPortfolioRoleChoices.ORGANIZATION_ADMIN
|
||||||
|
|
||||||
|
admins = UserPortfolioPermission.objects.filter(portfolio=portfolio, roles__contains=[admin_permission])
|
||||||
|
admin_count = admins.count()
|
||||||
|
|
||||||
|
# Check if the current user is in the list of admins
|
||||||
|
if admin_count == 1 and admins.first().user == self:
|
||||||
|
return True # The user is the only admin
|
||||||
|
|
||||||
|
# If there are other admins or the user is not the only one
|
||||||
|
return False
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
{% block header %}
|
{% block header %}
|
||||||
{% if not IS_PRODUCTION %}
|
{% if not IS_PRODUCTION %}
|
||||||
{% with add_body_class="margin-left-1" %}
|
{% with add_body_class="margin-left-1" %}
|
||||||
{% include "includes/non-production-alert.html" %}
|
{% include "includes/banner-non-production-alert.html" %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -72,9 +72,28 @@
|
||||||
<a class="usa-skipnav" href="#main-content">Skip to main content</a>
|
<a class="usa-skipnav" href="#main-content">Skip to main content</a>
|
||||||
|
|
||||||
{% if not IS_PRODUCTION %}
|
{% if not IS_PRODUCTION %}
|
||||||
{% include "includes/non-production-alert.html" %}
|
{% include "includes/banner-non-production-alert.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
<!-- Site banner / red alert banner / emergency banner / incident banner - Remove one of those includes and place outside the comment block to activate the banner.
|
||||||
|
DO NOT FORGET TO EDIT THE BANNER CONTENT -->
|
||||||
|
|
||||||
|
<!-- Red banner with exclamation mark in a circle: -->
|
||||||
|
{% include "includes/banner-error.html" %}
|
||||||
|
|
||||||
|
<!-- Blue banner with 'i'' mark in a circle: -->
|
||||||
|
{% include "includes/banner-info.html" %}
|
||||||
|
|
||||||
|
<!-- Marron banner with exclamation mark in a circle: -->
|
||||||
|
{% include "includes/banner-service-disruption.html" %}
|
||||||
|
{% include "includes/banner-site-alert.html" %}
|
||||||
|
{% include "includes/banner-system-outage.html" %}
|
||||||
|
|
||||||
|
<!-- Yellow banner with exclamation mark in a triangle: -->
|
||||||
|
{% include "includes/banner-warning.html" %}
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
<section class="usa-banner" aria-label="Official website of the United States government">
|
<section class="usa-banner" aria-label="Official website of the United States government">
|
||||||
<div class="usa-accordion">
|
<div class="usa-accordion">
|
||||||
<header class="usa-banner__header">
|
<header class="usa-banner__header">
|
||||||
|
|
|
@ -64,7 +64,7 @@
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<div class="usa-modal__main">
|
||||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
<h2 class="usa-modal__heading">
|
||||||
Are you sure you want to extend the expiration date?
|
Are you sure you want to extend the expiration date?
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
|
@ -128,7 +128,7 @@
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<div class="usa-modal__main">
|
||||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
<h2 class="usa-modal__heading">
|
||||||
Are you sure you want to place this domain on hold?
|
Are you sure you want to place this domain on hold?
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
|
@ -195,7 +195,7 @@
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<div class="usa-modal__main">
|
||||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
<h2 class="usa-modal__heading">
|
||||||
Are you sure you want to remove this domain from the registry?
|
Are you sure you want to remove this domain from the registry?
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
|
|
|
@ -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>
|
||||||
|
@ -50,7 +57,7 @@
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<div class="usa-modal__main">
|
||||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
<h2 class="usa-modal__heading">
|
||||||
Are you sure you want to select ineligible status?
|
Are you sure you want to select ineligible status?
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 %}
|
||||||
|
@ -341,7 +359,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="5">Other contact information</th>
|
<th colspan="4">Other contact information</th>
|
||||||
|
<th>Action</th>
|
||||||
<tr>
|
<tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Phone</th>
|
<th>Phone</th>
|
||||||
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Phone</th>
|
<th>Phone</th>
|
||||||
<th>Roles</th>
|
<th>Roles</th>
|
||||||
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
@ -41,14 +41,16 @@
|
||||||
|
|
||||||
{% include "includes/domain_dates.html" %}
|
{% include "includes/domain_dates.html" %}
|
||||||
|
|
||||||
{% if is_portfolio_user and not is_domain_manager %}
|
{% if analyst_action != 'edit' or analyst_action_location != domain.pk %}
|
||||||
<div class="usa-alert usa-alert--info usa-alert--slim">
|
{% if is_portfolio_user and not is_domain_manager %}
|
||||||
<div class="usa-alert__body">
|
<div class="usa-alert usa-alert--info usa-alert--slim">
|
||||||
<p class="usa-alert__text ">
|
<div class="usa-alert__body">
|
||||||
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
|
<p class="usa-alert__text ">
|
||||||
</p>
|
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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 can’t remove yourself as a domain manager if you’re the only one assigned 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.
|
||||||
|
{% 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 %}
|
||||||
|
|
12
src/registrar/templates/includes/banner-error.html
Normal file
12
src/registrar/templates/includes/banner-error.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<div class="margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||||
|
<div class="usa-alert usa-alert--error">
|
||||||
|
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||||
|
<h4 class="usa-alert__heading">
|
||||||
|
Header
|
||||||
|
</h4>
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
Text here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
12
src/registrar/templates/includes/banner-info.html
Normal file
12
src/registrar/templates/includes/banner-info.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<section class="usa-site-alert usa-site-alert--info margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||||
|
<div class="usa-alert">
|
||||||
|
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||||
|
<h4 class="usa-alert__heading">
|
||||||
|
Header
|
||||||
|
</h4>
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
Text here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<section class="usa-site-alert usa-site-alert--emergency usa-site-alert--hot-pink margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||||
|
<div class="usa-alert">
|
||||||
|
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %} {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
<strong>Attention:</strong> You are on a test site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
|
@ -0,0 +1,12 @@
|
||||||
|
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||||
|
<div class="usa-alert">
|
||||||
|
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||||
|
<h3 class="usa-alert__heading">
|
||||||
|
Service disruption
|
||||||
|
</h3>
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
Month day, time-in-24-hour-notation UTC: We're investigating a service disruption on the .gov registrar. The .gov zone and individual domains remain online. However, the registrar is running slower than usual.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
12
src/registrar/templates/includes/banner-site-alert.html
Normal file
12
src/registrar/templates/includes/banner-site-alert.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||||
|
<div class="usa-alert">
|
||||||
|
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||||
|
<h3 class="usa-alert__heading">
|
||||||
|
Header here
|
||||||
|
</h3>
|
||||||
|
<p class="usa-alert__tex maxw-none">
|
||||||
|
Text here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
12
src/registrar/templates/includes/banner-system-outage.html
Normal file
12
src/registrar/templates/includes/banner-system-outage.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||||
|
<div class="usa-alert">
|
||||||
|
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||||
|
<h3 class="usa-alert__heading">
|
||||||
|
System outage
|
||||||
|
</h3>
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
Oct 16, 24:00 UTC: We're investigating an outage on the .gov registrar. The .gov zone and individual domains remain online. However, you can't request a new domain or manage an existing one at this time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
12
src/registrar/templates/includes/banner-warning.html
Normal file
12
src/registrar/templates/includes/banner-warning.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<div class="margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
||||||
|
<div class="usa-alert usa-alert--warning">
|
||||||
|
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
||||||
|
<h4 class="usa-alert__heading">
|
||||||
|
Header
|
||||||
|
</h4>
|
||||||
|
<p class="usa-alert__text maxw-none">
|
||||||
|
Text here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,7 +1,7 @@
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
<!-- Embedding the portfolio value in a data attribute -->
|
<!-- Embedding the portfolio value in a data attribute -->
|
||||||
<span id="portfolio-js-value" class="display-none" data-portfolio="{{ portfolio.id }}"></span>
|
<span id="portfolio-js-value" class="display-none" data-portfolio="{{ portfolio.id }}" data-has-edit-permission="{{ has_edit_members_portfolio_permission }}"></span>
|
||||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||||
{% url 'get_portfolio_members_json' as url %}
|
{% url 'get_portfolio_members_json' as url %}
|
||||||
<span id="get_members_json_url" class="display-none">{{url}}</span>
|
<span id="get_members_json_url" class="display-none">{{url}}</span>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<div class="usa-modal__main">
|
||||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
<h2 class="usa-modal__heading">
|
||||||
{{ modal_heading }}
|
{{ modal_heading }}
|
||||||
{%if domain_name_modal is not None %}
|
{%if domain_name_modal is not None %}
|
||||||
<span class="domain-name-wrap">
|
<span class="domain-name-wrap">
|
||||||
|
@ -16,7 +16,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
<p id="modal-1-description">
|
<p>
|
||||||
{{ modal_description }}
|
{{ modal_description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
<div class="usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
|
|
||||||
<div class="usa-alert">
|
|
||||||
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %} {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
|
|
||||||
<b>Attention:</b> You are on a test site.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,7 +1,9 @@
|
||||||
{% extends 'portfolio_base.html' %}
|
{% extends 'portfolio_base.html' %}
|
||||||
{% load static field_helpers%}
|
{% load static field_helpers%}
|
||||||
|
|
||||||
{% block title %}Organization member {% endblock %}
|
{% block title %}
|
||||||
|
Organization member
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
|
@ -33,60 +35,30 @@
|
||||||
</h2>
|
</h2>
|
||||||
{% if has_edit_members_portfolio_permission %}
|
{% if has_edit_members_portfolio_permission %}
|
||||||
{% if member %}
|
{% if member %}
|
||||||
<a
|
<div id="wrapper-delete-action"
|
||||||
role="button"
|
data-member-name="{{ member.email }}"
|
||||||
href="#"
|
data-member-type="member"
|
||||||
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
data-member-id="{{ member.id }}"
|
||||||
>
|
data-num-domains="{{ portfolio_permission.get_managed_domains_count }}"
|
||||||
Remove member
|
data-member-email="{{ member.email }}"
|
||||||
</a>
|
>
|
||||||
{% else %}
|
<!-- JS should inject member kebob here -->
|
||||||
<a
|
|
||||||
role="button"
|
|
||||||
href="#"
|
|
||||||
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
|
||||||
>
|
|
||||||
Cancel invitation
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="usa-accordion usa-accordion--more-actions hidden-mobile-flex">
|
|
||||||
<div class="usa-accordion__heading">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-controls="more-actions"
|
|
||||||
>
|
|
||||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
|
||||||
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="more-actions" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
|
|
||||||
<h2>More options</h2>
|
|
||||||
{% if member %}
|
|
||||||
<a
|
|
||||||
role="button"
|
|
||||||
href="#"
|
|
||||||
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
|
||||||
>
|
|
||||||
Remove member
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<a
|
|
||||||
role="button"
|
|
||||||
href="#"
|
|
||||||
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
|
||||||
>
|
|
||||||
Cancel invitation
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% elif portfolio_invitation %}
|
||||||
|
<div id="wrapper-delete-action"
|
||||||
|
data-member-name="{{ portfolio_invitation.email }}"
|
||||||
|
data-member-type="invitedmember"
|
||||||
|
data-member-id="{{ portfolio_invitation.id }}"
|
||||||
|
data-num-domains="{{ portfolio_invitation.get_managed_domains_count }}"
|
||||||
|
data-member-email="{{ portfolio_invitation.email }}"
|
||||||
|
>
|
||||||
|
<!-- JS should inject invited kebob here -->
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form method="post" id="member-delete-form" action="{{ request.path }}/delete"> {% csrf_token %} </form>
|
||||||
<address>
|
<address>
|
||||||
<strong class="text-primary-dark">Last active:</strong>
|
<strong class="text-primary-dark">Last active:</strong>
|
||||||
{% if member and member.last_login %}
|
{% if member and member.last_login %}
|
||||||
|
|
|
@ -9,11 +9,15 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block portfolio_content %}
|
{% block portfolio_content %}
|
||||||
{% block messages %}
|
|
||||||
{% include "includes/form_messages.html" %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<div id="main-content">
|
<div id="main-content">
|
||||||
|
<div id="toggleable-alert" class="usa-alert usa-alert--slim margin-bottom-2 display-none">
|
||||||
|
<div class="usa-alert__body usa-alert__body--widescreen">
|
||||||
|
<p class="usa-alert__text ">
|
||||||
|
<!-- alert message will be conditionally populated by javascript -->
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="grid-row grid-gap">
|
<div class="grid-row grid-gap">
|
||||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||||
<h1 id="members-header">Members</h1>
|
<h1 id="members-header">Members</h1>
|
||||||
|
|
|
@ -51,11 +51,11 @@ Edit your User Profile |
|
||||||
>
|
>
|
||||||
<div class="usa-modal__content">
|
<div class="usa-modal__content">
|
||||||
<div class="usa-modal__main">
|
<div class="usa-modal__main">
|
||||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
<h2 class="usa-modal__heading">
|
||||||
Add contact information
|
Add contact information
|
||||||
</h2>
|
</h2>
|
||||||
<div class="usa-prose">
|
<div class="usa-prose">
|
||||||
<p id="modal-1-description">
|
<p>
|
||||||
.Gov domain registrants must maintain accurate contact information in the .gov registrar.
|
.Gov domain registrants must maintain accurate contact information in the .gov registrar.
|
||||||
Before you can manage your domain, we need you to add your contact information.
|
Before you can manage your domain, we need you to add your contact information.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -824,6 +824,92 @@ class TestUser(TestCase):
|
||||||
cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required."
|
cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_get_active_requests_count_in_portfolio_returns_zero_if_no_portfolio(self):
|
||||||
|
# There is no portfolio referenced in session so should return 0
|
||||||
|
request = self.factory.get("/")
|
||||||
|
request.session = {}
|
||||||
|
|
||||||
|
count = self.user.get_active_requests_count_in_portfolio(request)
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_get_active_requests_count_in_portfolio_returns_count_if_portfolio(self):
|
||||||
|
request = self.factory.get("/")
|
||||||
|
request.session = {"portfolio": self.portfolio}
|
||||||
|
|
||||||
|
# Create active requests
|
||||||
|
domain_1, _ = DraftDomain.objects.get_or_create(name="meoward1.gov")
|
||||||
|
domain_2, _ = DraftDomain.objects.get_or_create(name="meoward2.gov")
|
||||||
|
domain_3, _ = DraftDomain.objects.get_or_create(name="meoward3.gov")
|
||||||
|
domain_4, _ = DraftDomain.objects.get_or_create(name="meoward4.gov")
|
||||||
|
|
||||||
|
# Create 3 active requests + 1 that isn't
|
||||||
|
DomainRequest.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
requested_domain=domain_1,
|
||||||
|
status=DomainRequest.DomainRequestStatus.SUBMITTED,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
)
|
||||||
|
DomainRequest.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
requested_domain=domain_2,
|
||||||
|
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
)
|
||||||
|
DomainRequest.objects.create(
|
||||||
|
creator=self.user,
|
||||||
|
requested_domain=domain_3,
|
||||||
|
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
)
|
||||||
|
DomainRequest.objects.create( # This one should not be counted
|
||||||
|
creator=self.user,
|
||||||
|
requested_domain=domain_4,
|
||||||
|
status=DomainRequest.DomainRequestStatus.REJECTED,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = self.user.get_active_requests_count_in_portfolio(request)
|
||||||
|
self.assertEqual(count, 3)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_is_only_admin_of_portfolio_returns_true(self):
|
||||||
|
# Create user as the only admin of the portfolio
|
||||||
|
UserPortfolioPermission.objects.create(
|
||||||
|
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
self.assertTrue(self.user.is_only_admin_of_portfolio(self.portfolio))
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_is_only_admin_of_portfolio_returns_false_if_no_admins(self):
|
||||||
|
# No admin for the portfolio
|
||||||
|
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_is_only_admin_of_portfolio_returns_false_if_multiple_admins(self):
|
||||||
|
# Create multiple admins for the same portfolio
|
||||||
|
UserPortfolioPermission.objects.create(
|
||||||
|
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
# Create another user within this test
|
||||||
|
other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin")
|
||||||
|
UserPortfolioPermission.objects.create(
|
||||||
|
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
def test_is_only_admin_of_portfolio_returns_false_if_user_not_admin(self):
|
||||||
|
# Create other_user for same portfolio and is given admin access
|
||||||
|
other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin")
|
||||||
|
|
||||||
|
UserPortfolioPermission.objects.create(
|
||||||
|
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||||
|
)
|
||||||
|
# User doesn't have admin access so should return false
|
||||||
|
self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio))
|
||||||
|
|
||||||
|
|
||||||
class TestContact(TestCase):
|
class TestContact(TestCase):
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
|
|
@ -323,6 +323,27 @@ class TestDomainDetail(TestDomainOverview):
|
||||||
self.assertContains(detail_page, "noinformation.gov")
|
self.assertContains(detail_page, "noinformation.gov")
|
||||||
self.assertContains(detail_page, "Domain missing domain information")
|
self.assertContains(detail_page, "Domain missing domain information")
|
||||||
|
|
||||||
|
def test_domain_detail_with_analyst_managing_domain(self):
|
||||||
|
"""Test that domain management page returns 200 and does not display
|
||||||
|
blue error message when an analyst is managing the domain"""
|
||||||
|
with less_console_noise():
|
||||||
|
staff_user = create_user()
|
||||||
|
self.client.force_login(staff_user)
|
||||||
|
|
||||||
|
# need to set the analyst_action and analyst_action_location
|
||||||
|
# in the session to emulate user clicking Manage Domain
|
||||||
|
# in the admin interface
|
||||||
|
session = self.client.session
|
||||||
|
session["analyst_action"] = "edit"
|
||||||
|
session["analyst_action_location"] = self.domain.id
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
|
||||||
|
|
||||||
|
self.assertNotContains(
|
||||||
|
detail_page, "To manage information for this domain, you must add yourself as a domain manager."
|
||||||
|
)
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
def test_domain_readonly_on_detail_page(self):
|
def test_domain_readonly_on_detail_page(self):
|
||||||
|
@ -370,6 +391,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 +415,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 +747,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 +769,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 +778,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)
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,9 @@ from django.urls import reverse
|
||||||
from api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
from registrar.config import settings
|
from registrar.config import settings
|
||||||
from registrar.models import Portfolio, SeniorOfficial
|
from registrar.models import Portfolio, SeniorOfficial
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
from django_webtest import WebTest # type: ignore
|
from django_webtest import WebTest # type: ignore
|
||||||
|
from django.core.handlers.wsgi import WSGIRequest
|
||||||
from registrar.models import (
|
from registrar.models import (
|
||||||
DomainRequest,
|
DomainRequest,
|
||||||
Domain,
|
Domain,
|
||||||
|
@ -959,7 +960,7 @@ class TestPortfolio(WebTest):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert buttons and links within the page are correct
|
# Assert buttons and links within the page are correct
|
||||||
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
|
||||||
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||||
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||||
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||||
|
@ -1077,9 +1078,8 @@ class TestPortfolio(WebTest):
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assert buttons and links within the page are correct
|
# Assert buttons and links within the page are correct
|
||||||
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
|
||||||
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||||
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||||
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||||
|
@ -1392,6 +1392,510 @@ class TestPortfolio(WebTest):
|
||||||
self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists())
|
self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists())
|
||||||
domain_request.delete()
|
domain_request.delete()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_members_table_contains_hidden_permissions_js_hook(self):
|
||||||
|
# In the members_table.html we use data-has-edit-permission as a boolean
|
||||||
|
# to indicate if a user has permission to edit members in the specific portfolio
|
||||||
|
|
||||||
|
# 1. User w/ edit permission
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a member under same portfolio
|
||||||
|
member_email = "a_member@example.com"
|
||||||
|
member, _ = User.objects.get_or_create(username="a_member", email=member_email)
|
||||||
|
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=member,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
|
||||||
|
# I log in as the User so I can see the Members Table
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
# Specifically go to the Member Table page
|
||||||
|
response = self.client.get(reverse("members"))
|
||||||
|
|
||||||
|
self.assertContains(response, 'data-has-edit-permission="True"')
|
||||||
|
|
||||||
|
# 2. User w/o edit permission (additional permission of EDIT_MEMBERS removed)
|
||||||
|
permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
|
||||||
|
|
||||||
|
# Remove the EDIT_MEMBERS additional permission
|
||||||
|
permission.additional_permissions = [
|
||||||
|
perm for perm in permission.additional_permissions if perm != UserPortfolioPermissionChoices.EDIT_MEMBERS
|
||||||
|
]
|
||||||
|
|
||||||
|
# Save the updated permissions list
|
||||||
|
permission.save()
|
||||||
|
|
||||||
|
# Re-fetch the page to check for updated permissions
|
||||||
|
response = self.client.get(reverse("members"))
|
||||||
|
|
||||||
|
self.assertContains(response, 'data-has-edit-permission="False"')
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_member_page_has_kebab_wrapper_for_member_if_user_has_edit_permission(self):
|
||||||
|
"""Test that the kebab wrapper displays for a member with edit permissions"""
|
||||||
|
|
||||||
|
# I'm a user
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a member under same portfolio
|
||||||
|
member_email = "a_member@example.com"
|
||||||
|
member, _ = User.objects.get_or_create(username="a_member", email=member_email)
|
||||||
|
|
||||||
|
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=member,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
|
||||||
|
# I log in as the User so I can see the Manage Member page
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
# Specifically go to the Manage Member page
|
||||||
|
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Check for email AND member type (which here is just member)
|
||||||
|
self.assertContains(response, f'data-member-name="{member_email}"')
|
||||||
|
self.assertContains(response, 'data-member-type="member"')
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_member_page_has_kebab_wrapper_for_invited_member_if_user_has_edit_permission(self):
|
||||||
|
"""Test that the kebab wrapper displays for an invitedmember with edit permissions"""
|
||||||
|
|
||||||
|
# I'm a user
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invite a member under same portfolio
|
||||||
|
invited_member_email = "invited_member@example.com"
|
||||||
|
invitation = PortfolioInvitation.objects.create(
|
||||||
|
email=invited_member_email,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
|
||||||
|
# I log in as the User so I can see the Manage Member page
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("invitedmember", args=[invitation.id]), follow=True)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Assert the invited members email + invitedmember type
|
||||||
|
self.assertContains(response, f'data-member-name="{invited_member_email}"')
|
||||||
|
self.assertContains(response, 'data-member-type="invitedmember"')
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_member_page_does_not_have_kebab_wrapper(self):
|
||||||
|
"""Test that the kebab does not display."""
|
||||||
|
|
||||||
|
# I'm a user
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# That creates a member with only view access
|
||||||
|
member_email = "member_with_view_access@example.com"
|
||||||
|
member, _ = User.objects.get_or_create(username="test_member_with_view_access", email=member_email)
|
||||||
|
|
||||||
|
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=member,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# I log in as the Member with only view permissions to evaluate the pages behaviour
|
||||||
|
# when viewed by someone who doesn't have edit perms
|
||||||
|
self.client.force_login(member)
|
||||||
|
|
||||||
|
# Go to the Manage Member page
|
||||||
|
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Assert that the kebab edit options are unavailable
|
||||||
|
self.assertNotContains(response, 'data-member-type="member"')
|
||||||
|
self.assertNotContains(response, 'data-member-type="invitedmember"')
|
||||||
|
self.assertNotContains(response, f'data-member-name="{member_email}"')
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_member_page_has_correct_form_wrapper(self):
|
||||||
|
"""Test that the manage members page the right form wrapper"""
|
||||||
|
|
||||||
|
# I'm a user
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# That creates a member
|
||||||
|
member_email = "a_member@example.com"
|
||||||
|
member, _ = User.objects.get_or_create(email=member_email)
|
||||||
|
|
||||||
|
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=member,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Login as the User to see the Manage Member page
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
# Specifically go to the Manage Member page
|
||||||
|
response = self.client.get(reverse("member", args=[upp.id]), follow=True)
|
||||||
|
|
||||||
|
# Check for a 200 response
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Check for form method + that its "post" and id "member-delete-form"
|
||||||
|
self.assertContains(response, "<form")
|
||||||
|
self.assertContains(response, 'method="post"')
|
||||||
|
self.assertContains(response, 'id="member-delete-form"')
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_toggleable_alert_wrapper_exists_on_members_page(self):
|
||||||
|
# I'm a user
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# That creates a member
|
||||||
|
member_email = "a_member@example.com"
|
||||||
|
member, _ = User.objects.get_or_create(email=member_email)
|
||||||
|
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=member,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Login as the User to see the Members Table page
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
# Specifically go to the Members Table page
|
||||||
|
response = self.client.get(reverse("members"))
|
||||||
|
|
||||||
|
# Assert that the toggleable alert ID exists
|
||||||
|
self.assertContains(response, '<div id="toggleable-alert"')
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_portfolio_member_delete_view_members_table_active_requests(self):
|
||||||
|
"""Error state w/ deleting a member with active request on Members Table"""
|
||||||
|
# I'm a user
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# That creates a member
|
||||||
|
member_email = "a_member@example.com"
|
||||||
|
member, _ = User.objects.get_or_create(email=member_email)
|
||||||
|
|
||||||
|
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=member,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
# We check X_REQUESTED_WITH bc those return JSON responses
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("member-delete", kwargs={"pk": upp.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400) # Bad request due to active requests
|
||||||
|
support_url = "https://get.gov/contact/"
|
||||||
|
expected_error_message = (
|
||||||
|
f"This member has an active domain request and can't be removed from the organization. "
|
||||||
|
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, expected_error_message, status_code=400)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_portfolio_member_delete_view_members_table_only_admin(self):
|
||||||
|
"""Error state w/ deleting a member that's the only admin on Members Table"""
|
||||||
|
|
||||||
|
# I'm a user with admin permission
|
||||||
|
admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(User, "is_only_admin_of_portfolio", return_value=True):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
# We check X_REQUESTED_WITH bc those return JSON responses
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
expected_error_message = (
|
||||||
|
"There must be at least one admin in your organization. Give another member admin "
|
||||||
|
"permissions, make sure they log into the registrar, and then remove this member."
|
||||||
|
)
|
||||||
|
self.assertContains(response, expected_error_message, status_code=400)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_portfolio_member_table_delete_view_success(self):
|
||||||
|
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
|
||||||
|
|
||||||
|
# I'm a user
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Creating a member that can be deleted (see patch)
|
||||||
|
member_email = "deleteable_member@example.com"
|
||||||
|
member, _ = User.objects.get_or_create(email=member_email)
|
||||||
|
|
||||||
|
# Set up the member in the portfolio
|
||||||
|
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=member,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
|
||||||
|
# And set that the member has no active requests AND it's not the only admin
|
||||||
|
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
|
||||||
|
User, "is_only_admin_of_portfolio", return_value=False
|
||||||
|
):
|
||||||
|
|
||||||
|
# Attempt to delete
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
# We check X_REQUESTED_WITH bc those return JSON responses
|
||||||
|
reverse("member-delete", kwargs={"pk": upp.pk}),
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for a successful deletion
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
expected_success_message = f"You've removed {member.email} from the organization."
|
||||||
|
self.assertContains(response, expected_success_message, status_code=200)
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_portfolio_member_delete_view_manage_members_page_active_requests(self):
|
||||||
|
"""Error state when deleting a member with active requests on the Manage Members page"""
|
||||||
|
|
||||||
|
# I'm an admin user
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a member with active requests
|
||||||
|
member_email = "member_with_active_request@example.com"
|
||||||
|
member, _ = User.objects.get_or_create(email=member_email)
|
||||||
|
|
||||||
|
upp, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=member,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1):
|
||||||
|
with patch("django.contrib.messages.error") as mock_error:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("member-delete", kwargs={"pk": upp.pk}),
|
||||||
|
)
|
||||||
|
# We don't want to do follow=True in response bc that does automatic redirection
|
||||||
|
|
||||||
|
# We want 302 bc indicates redirect
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
support_url = "https://get.gov/contact/"
|
||||||
|
expected_error_message = (
|
||||||
|
f"This member has an active domain request and can't be removed from the organization. "
|
||||||
|
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
|
||||||
|
)
|
||||||
|
|
||||||
|
args, kwargs = mock_error.call_args
|
||||||
|
# Check if first arg is a WSGIRequest, confirms request object passed correctly
|
||||||
|
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
|
||||||
|
self.assertIsInstance(args[0], WSGIRequest)
|
||||||
|
# Check that the error message matches the expected error message
|
||||||
|
self.assertEqual(args[1], expected_error_message)
|
||||||
|
|
||||||
|
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
|
||||||
|
# and then confirm that we're still on the Manage Members page
|
||||||
|
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": upp.pk}))
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_portfolio_member_delete_view_manage_members_page_only_admin(self):
|
||||||
|
"""Error state when trying to delete the only admin on the Manage Members page"""
|
||||||
|
|
||||||
|
# Create an admin with admin user perms
|
||||||
|
admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set them to be the only admin and attempt to delete
|
||||||
|
with patch.object(User, "is_only_admin_of_portfolio", return_value=True):
|
||||||
|
with patch("django.contrib.messages.error") as mock_error:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
expected_error_message = (
|
||||||
|
"There must be at least one admin in your organization. Give another member admin "
|
||||||
|
"permissions, make sure they log into the registrar, and then remove this member."
|
||||||
|
)
|
||||||
|
|
||||||
|
args, kwargs = mock_error.call_args
|
||||||
|
# Check if first arg is a WSGIRequest, confirms request object passed correctly
|
||||||
|
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
|
||||||
|
self.assertIsInstance(args[0], WSGIRequest)
|
||||||
|
# Check that the error message matches the expected error message
|
||||||
|
self.assertEqual(args[1], expected_error_message)
|
||||||
|
|
||||||
|
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
|
||||||
|
# and then confirm that we're still on the Manage Members page
|
||||||
|
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": admin_perm_user.pk}))
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
|
@override_flag("organization_feature", active=True)
|
||||||
|
@override_flag("organization_members", active=True)
|
||||||
|
def test_portfolio_member_delete_view_manage_members_page_invitedmember(self):
|
||||||
|
"""Success state w/ deleting invited member on Manage Members page should redirect back to Members Table"""
|
||||||
|
|
||||||
|
# I'm a user
|
||||||
|
UserPortfolioPermission.objects.get_or_create(
|
||||||
|
user=self.user,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[
|
||||||
|
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||||
|
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invite a member under same portfolio
|
||||||
|
invited_member_email = "invited_member@example.com"
|
||||||
|
invitation = PortfolioInvitation.objects.create(
|
||||||
|
email=invited_member_email,
|
||||||
|
portfolio=self.portfolio,
|
||||||
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||||
|
)
|
||||||
|
with patch("django.contrib.messages.success") as mock_success:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("invitedmember-delete", kwargs={"pk": invitation.pk}),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
expected_success_message = f"You've removed {invitation.email} from the organization."
|
||||||
|
args, kwargs = mock_success.call_args
|
||||||
|
# Check if first arg is a WSGIRequest, confirms request object passed correctly
|
||||||
|
# WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete')
|
||||||
|
self.assertIsInstance(args[0], WSGIRequest)
|
||||||
|
# Check that the error message matches the expected error message
|
||||||
|
self.assertEqual(args[1], expected_success_message)
|
||||||
|
|
||||||
|
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
|
||||||
|
# and then confirm that we're now on Members Table page
|
||||||
|
self.assertEqual(response.headers["Location"], reverse("members"))
|
||||||
|
|
||||||
|
|
||||||
class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
|
class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -100,7 +100,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
user__permissions__domain__domain_info__portfolio=portfolio
|
user__permissions__domain__domain_info__portfolio=portfolio
|
||||||
), # only include domains in portfolio
|
), # only include domains in portfolio
|
||||||
),
|
),
|
||||||
source=Value("permission", output_field=CharField()),
|
type=Value("member", output_field=CharField()),
|
||||||
)
|
)
|
||||||
.values(
|
.values(
|
||||||
"id",
|
"id",
|
||||||
|
@ -112,7 +112,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
"additional_permissions_display",
|
"additional_permissions_display",
|
||||||
"member_display",
|
"member_display",
|
||||||
"domain_info",
|
"domain_info",
|
||||||
"source",
|
"type",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return permissions
|
return permissions
|
||||||
|
@ -140,7 +140,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
distinct=True,
|
distinct=True,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
source=Value("invitation", output_field=CharField()),
|
type=Value("invitedmember", output_field=CharField()),
|
||||||
).values(
|
).values(
|
||||||
"id",
|
"id",
|
||||||
"first_name",
|
"first_name",
|
||||||
|
@ -151,7 +151,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
"additional_permissions_display",
|
"additional_permissions_display",
|
||||||
"member_display",
|
"member_display",
|
||||||
"domain_info",
|
"domain_info",
|
||||||
"source",
|
"type",
|
||||||
)
|
)
|
||||||
return invitations
|
return invitations
|
||||||
|
|
||||||
|
@ -188,12 +188,12 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
||||||
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
||||||
|
|
||||||
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
|
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
|
||||||
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
|
action_url = reverse(item["type"], kwargs={"pk": item["id"]})
|
||||||
|
|
||||||
# Serialize member data
|
# Serialize member data
|
||||||
member_json = {
|
member_json = {
|
||||||
"id": item.get("id", ""),
|
"id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation
|
||||||
"source": item.get("source", ""),
|
"type": item.get("type", ""), # source is member or invitedmember
|
||||||
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||||
"email": item.get("email_display", ""),
|
"email": item.get("email_display", ""),
|
||||||
"member_display": item.get("member_display", ""),
|
"member_display": item.get("member_display", ""),
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import logging
|
import logging
|
||||||
from django.http import Http404
|
|
||||||
|
from django.http import Http404, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
|
||||||
from registrar.forms import portfolio as portfolioForms
|
from registrar.forms import portfolio as portfolioForms
|
||||||
from registrar.models import Portfolio, User
|
from registrar.models import Portfolio, User
|
||||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||||
|
from registrar.views.utility.mixins import PortfolioMemberPermission
|
||||||
from registrar.views.utility.permission_views import (
|
from registrar.views.utility.permission_views import (
|
||||||
PortfolioDomainRequestsPermissionView,
|
PortfolioDomainRequestsPermissionView,
|
||||||
PortfolioDomainsPermissionView,
|
PortfolioDomainsPermissionView,
|
||||||
|
@ -81,6 +85,58 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
"""
|
||||||
|
Find and delete the portfolio member using the provided primary key (pk).
|
||||||
|
Redirect to a success page after deletion (or any other appropriate page).
|
||||||
|
"""
|
||||||
|
portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||||
|
member = portfolio_member_permission.user
|
||||||
|
|
||||||
|
active_requests_count = member.get_active_requests_count_in_portfolio(request)
|
||||||
|
|
||||||
|
support_url = "https://get.gov/contact/"
|
||||||
|
|
||||||
|
error_message = ""
|
||||||
|
|
||||||
|
if active_requests_count > 0:
|
||||||
|
# If they have any in progress requests
|
||||||
|
error_message = mark_safe( # nosec
|
||||||
|
f"This member has an active domain request and can't be removed from the organization. "
|
||||||
|
f"<a href='{support_url}' target='_blank'>Contact the .gov team</a> to remove them."
|
||||||
|
)
|
||||||
|
elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio):
|
||||||
|
# If they are the last manager of a domain
|
||||||
|
error_message = (
|
||||||
|
"There must be at least one admin in your organization. Give another member admin "
|
||||||
|
"permissions, make sure they log into the registrar, and then remove this member."
|
||||||
|
)
|
||||||
|
|
||||||
|
# From the Members Table page Else the Member Page
|
||||||
|
if error_message:
|
||||||
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
|
return JsonResponse(
|
||||||
|
{"error": error_message},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.error(request, error_message)
|
||||||
|
return redirect(reverse("member", kwargs={"pk": pk}))
|
||||||
|
|
||||||
|
# passed all error conditions
|
||||||
|
portfolio_member_permission.delete()
|
||||||
|
|
||||||
|
# From the Members Table page Else the Member Page
|
||||||
|
success_message = f"You've removed {member.email} from the organization."
|
||||||
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
|
return JsonResponse({"success": success_message}, status=200)
|
||||||
|
else:
|
||||||
|
messages.success(request, success_message)
|
||||||
|
return redirect(reverse("members"))
|
||||||
|
|
||||||
|
|
||||||
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
|
|
||||||
template_name = "portfolio_member_permissions.html"
|
template_name = "portfolio_member_permissions.html"
|
||||||
|
@ -177,6 +233,26 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
"""
|
||||||
|
Find and delete the portfolio invited member using the provided primary key (pk).
|
||||||
|
Redirect to a success page after deletion (or any other appropriate page).
|
||||||
|
"""
|
||||||
|
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||||
|
|
||||||
|
portfolio_invitation.delete()
|
||||||
|
|
||||||
|
success_message = f"You've removed {portfolio_invitation.email} from the organization."
|
||||||
|
# From the Members Table page Else the Member Page
|
||||||
|
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||||
|
return JsonResponse({"success": success_message}, status=200)
|
||||||
|
else:
|
||||||
|
messages.success(request, success_message)
|
||||||
|
return redirect(reverse("members"))
|
||||||
|
|
||||||
|
|
||||||
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||||
|
|
||||||
template_name = "portfolio_member_permissions.html"
|
template_name = "portfolio_member_permissions.html"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue