Merge branch 'main' into za/3025-add-new-suborgs

This commit is contained in:
zandercymatics 2024-12-20 09:50:37 -07:00
commit cda7316a71
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
32 changed files with 1551 additions and 426 deletions

View file

@ -20,7 +20,7 @@ applications:
# Tell Django where it is being hosted
DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov
# Tell Django how much stuff to log
DJANGO_LOG_LEVEL: INFO
DJANGO_LOG_LEVEL: DEBUG
# default public site location
GETGOV_PUBLIC_SITE_URL: https://get.gov
# Flag to disable/enable features in prod environments

View file

@ -62,9 +62,11 @@ class RegistryError(Exception):
- 2501 - 2502 Something malicious or abusive may have occurred
"""
def __init__(self, *args, code=None, **kwargs):
def __init__(self, *args, code=None, note="", **kwargs):
super().__init__(*args, **kwargs)
self.code = code
# note is a string that can be used to provide additional context
self.note = note
def should_retry(self):
return self.code == ErrorCode.COMMAND_FAILED

View file

@ -220,6 +220,14 @@ class DomainInformationAdminForm(forms.ModelForm):
fields = "__all__"
widgets = {
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
"portfolio": AutocompleteSelectWithPlaceholder(
DomainInformation._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"}
),
"sub_organization": AutocompleteSelectWithPlaceholder(
DomainInformation._meta.get_field("sub_organization"),
admin.site,
attrs={"data-placeholder": "---------", "ajax-url": "get-suborganization-list-json"},
),
}
@ -231,6 +239,14 @@ class DomainInformationInlineForm(forms.ModelForm):
fields = "__all__"
widgets = {
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
"portfolio": AutocompleteSelectWithPlaceholder(
DomainInformation._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"}
),
"sub_organization": AutocompleteSelectWithPlaceholder(
DomainInformation._meta.get_field("sub_organization"),
admin.site,
attrs={"data-placeholder": "---------", "ajax-url": "get-suborganization-list-json"},
),
}
@ -1523,6 +1539,70 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
orderable_fk_fields = [("domain", "name")]
# 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
# Filters
list_filter = [GenericOrgFilter]
@ -1537,16 +1617,36 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
None,
{
"fields": [
"portfolio",
"sub_organization",
"creator",
"domain_request",
"notes",
]
},
),
(
"Requested by",
{
"fields": [
"portfolio",
"sub_organization",
"creator",
]
},
),
(".gov domain", {"fields": ["domain"]}),
("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}),
(
"Contacts",
{
"fields": [
"senior_official",
"portfolio_senior_official",
"other_contacts",
"no_other_contacts_rationale",
"cisa_representative_first_name",
"cisa_representative_last_name",
"cisa_representative_email",
]
},
),
("Background info", {"fields": ["anything_else"]}),
(
"Type of organization",
@ -1595,10 +1695,58 @@ class DomainInformationAdmin(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 = ("other_contacts", "is_election_board")
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",
"is_election_board",
)
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
@ -2582,7 +2730,72 @@ class DomainInformationInline(admin.StackedInline):
template = "django/admin/includes/domain_info_inline_stacked.html"
model = models.DomainInformation
# 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
fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets))
readonly_fields = copy.deepcopy(DomainInformationAdmin.readonly_fields)
analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields)
autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields)
@ -3128,7 +3341,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
except RegistryError as err:
# Using variables to get past the linter
message1 = f"Cannot delete Domain when in state {obj.state}"
message2 = "This subdomain is being used as a hostname on another domain"
message2 = f"This subdomain is being used as a hostname on another domain: {err.note}"
# Human-readable mappings of ErrorCodes. Can be expanded.
error_messages = {
# noqa on these items as black wants to reformat to an invalid length

View file

@ -1,3 +1,5 @@
import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js';
/**
* A function that appends target="_blank" to the domain_form buttons
*/
@ -28,3 +30,14 @@ export function initDomainFormTargetBlankButtons() {
domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false));
}
}
/**
* A function for dynamic Domain fields
*/
export function initDynamicDomainFields(){
const domainPage = document.getElementById("domain_form");
if (domainPage) {
handlePortfolioSelection("#id_domain_info-0-portfolio",
"#id_domain_info-0-sub_organization");
}
}

View file

@ -1,4 +1,4 @@
import { handleSuborganizationFields } from './helpers-portfolio-dynamic-fields.js';
import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js';
/**
* A function for dynamic DomainInformation fields
@ -6,12 +6,7 @@ import { handleSuborganizationFields } from './helpers-portfolio-dynamic-fields.
export function initDynamicDomainInformationFields(){
const domainInformationPage = document.getElementById("domaininformation_form");
if (domainInformationPage) {
handleSuborganizationFields();
}
// DomainInformation is embedded inside domain so this should fire there too
const domainPage = document.getElementById("domain_form");
if (domainPage) {
handleSuborganizationFields(portfolioDropdownSelector="#id_domain_info-0-portfolio", suborgDropdownSelector="#id_domain_info-0-sub_organization");
console.log("handling domain information page");
handlePortfolioSelection();
}
}

View file

@ -1,57 +1,19 @@
import { hideElement, showElement } from './helpers-admin.js';
/**
* Helper function that handles business logic for the suborganization field.
* Can be used anywhere the suborganization dropdown exists
*/
export function handleSuborganizationFields(
portfolioDropdownSelector="#id_portfolio",
suborgDropdownSelector="#id_sub_organization",
requestedSuborgFieldSelector=".field-requested_suborganization",
suborgCitySelector=".field-suborganization_city",
suborgStateTerritorySelector=".field-suborganization_state_territory"
) {
// These dropdown are select2 fields so they must be interacted with via jquery
const portfolioDropdown = django.jQuery(portfolioDropdownSelector)
const suborganizationDropdown = django.jQuery(suborgDropdownSelector)
const requestedSuborgField = document.querySelector(requestedSuborgFieldSelector);
const suborgCity = document.querySelector(suborgCitySelector);
const suborgStateTerritory = document.querySelector(suborgStateTerritorySelector);
if (!suborganizationDropdown || !requestedSuborgField || !suborgCity || !suborgStateTerritory) {
console.error("Requested suborg fields not found.");
return;
}
function toggleSuborganizationFields() {
if (portfolioDropdown.val() && !suborganizationDropdown.val()) {
showElement(requestedSuborgField);
showElement(suborgCity);
showElement(suborgStateTerritory);
}else {
hideElement(requestedSuborgField);
hideElement(suborgCity);
hideElement(suborgStateTerritory);
}
}
// Run the function once on page startup, then attach an event listener
toggleSuborganizationFields();
suborganizationDropdown.on("change", toggleSuborganizationFields);
portfolioDropdown.on("change", toggleSuborganizationFields);
}
/**
*
* 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
* IMPORTANT NOTE: The business logic in this method is based on dynamicPortfolioFields
*/
export function handlePortfolioSelection() {
export function handlePortfolioSelection(
portfolioDropdownSelector="#id_portfolio",
suborgDropdownSelector="#id_sub_organization"
) {
// 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 portfolioDropdown = django.jQuery(portfolioDropdownSelector);
const suborganizationDropdown = django.jQuery(suborgDropdownSelector);
const suborganizationField = document.querySelector(".field-sub_organization");
const requestedSuborganizationField = document.querySelector(".field-requested_suborganization");
const suborganizationCity = document.querySelector(".field-suborganization_city");
@ -441,8 +403,8 @@ export function handlePortfolioSelection() {
showElement(portfolioSeniorOfficialField);
// Hide fields not applicable when a portfolio is selected
hideElement(otherEmployeesField);
hideElement(noOtherContactsRationaleField);
if (otherEmployeesField) hideElement(otherEmployeesField);
if (noOtherContactsRationaleField) hideElement(noOtherContactsRationaleField);
hideElement(cisaRepresentativeFirstNameField);
hideElement(cisaRepresentativeLastNameField);
hideElement(cisaRepresentativeEmailField);
@ -464,8 +426,8 @@ export function handlePortfolioSelection() {
// Show fields that are relevant when no portfolio is selected
showElement(seniorOfficialField);
hideElement(portfolioSeniorOfficialField);
showElement(otherEmployeesField);
showElement(noOtherContactsRationaleField);
if (otherEmployeesField) showElement(otherEmployeesField);
if (noOtherContactsRationaleField) showElement(noOtherContactsRationaleField);
showElement(cisaRepresentativeFirstNameField);
showElement(cisaRepresentativeLastNameField);
showElement(cisaRepresentativeEmailField);
@ -505,9 +467,9 @@ export function handlePortfolioSelection() {
if (portfolio_id && !suborganization_id) {
// Show suborganization request fields
showElement(requestedSuborganizationField);
showElement(suborganizationCity);
showElement(suborganizationStateTerritory);
if (requestedSuborganizationField) showElement(requestedSuborganizationField);
if (suborganizationCity) showElement(suborganizationCity);
if (suborganizationStateTerritory) showElement(suborganizationStateTerritory);
// Initially show / hide the clear button only if there is data to clear
let requestedSuborganizationField = document.getElementById("id_requested_suborganization");
@ -524,9 +486,9 @@ export function handlePortfolioSelection() {
}
} else {
// Hide suborganization request fields if suborganization is selected
hideElement(requestedSuborganizationField);
hideElement(suborganizationCity);
hideElement(suborganizationStateTerritory);
if (requestedSuborganizationField) hideElement(requestedSuborganizationField);
if (suborganizationCity) hideElement(suborganizationCity);
if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory);
hideElement(rejectSuborganizationButtonFieldset);
}
}

View file

@ -14,6 +14,7 @@ import {
import { initDomainFormTargetBlankButtons } from './domain-form.js';
import { initDynamicPortfolioFields } from './portfolio-form.js';
import { initDynamicDomainInformationFields } from './domain-information-form.js';
import { initDynamicDomainFields } from './domain-form.js';
// General
initModals();
@ -33,6 +34,7 @@ initDynamicDomainRequestFields();
// Domain
initDomainFormTargetBlankButtons();
initDynamicDomainFields();
// Portfolio
initDynamicPortfolioFields();

View file

@ -2,203 +2,212 @@ import { hideElement, showElement } from './helpers-admin.js';
/**
* A function for dynamically changing some fields on the portfolio admin model
* IMPORTANT NOTE: The logic in this function is paired handlePortfolioSelection and should be refactored once we solidify our requirements.
* IMPORTANT NOTE: The business logic in this function is related to handlePortfolioSelection
*/
export function initDynamicPortfolioFields(){
function handlePortfolioFields(){
// the federal agency change listener fires on page load, which we don't want.
var isInitialPageLoad = true
let isPageLoading = true
// $ symbolically denotes that this is using jQuery
const $seniorOfficialDropdown = django.jQuery("#id_senior_official");
const seniorOfficialField = document.querySelector(".field-senior_official");
const seniorOfficialAddress = seniorOfficialField.querySelector(".dja-address-contact-list");
const seniorOfficialReadonly = seniorOfficialField.querySelector(".readonly");
const $federalAgencyDropdown = django.jQuery("#id_federal_agency");
const federalAgencyField = document.querySelector(".field-federal_agency");
const organizationTypeField = document.querySelector(".field-organization_type");
const organizationTypeReadonly = organizationTypeField.querySelector(".readonly");
const organizationTypeDropdown = document.getElementById("id_organization_type");
const organizationNameField = document.querySelector(".field-organization_name");
const federalTypeField = document.querySelector(".field-federal_type");
const urbanizationField = document.querySelector(".field-urbanization");
const stateTerritoryDropdown = document.getElementById("id_state_territory");
const seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value;
const seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
const federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value;
// This is the additional information that exists beneath the SO element.
var contactList = document.querySelector(".field-senior_official .dja-address-contact-list");
const federalAgencyContainer = document.querySelector(".field-federal_agency");
document.addEventListener('DOMContentLoaded', function() {
let isPortfolioPage = document.getElementById("portfolio_form");
if (!isPortfolioPage) {
return;
}
// $ symbolically denotes that this is using jQuery
let $federalAgency = django.jQuery("#id_federal_agency");
let organizationType = document.getElementById("id_organization_type");
let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly");
let organizationNameContainer = document.querySelector(".field-organization_name");
let federalType = document.querySelector(".field-federal_type");
if ($federalAgency && (organizationType || readonlyOrganizationType)) {
// Attach the change event listener
$federalAgency.on("change", function() {
handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType);
/**
* Fetches federal type data based on a selected agency using an AJAX call.
*
* @param {string} agency
* @returns {Promise<Object|null>} - A promise that resolves to the portfolio data object if successful,
* or null if there was an error.
*/
function getFederalTypeFromAgency(agency) {
return fetch(`${federalPortfolioApi}?&agency_name=${agency}`)
.then(response => {
const statusCode = response.status;
return response.json().then(data => ({ statusCode, data }));
})
.then(({ statusCode, data }) => {
if (data.error) {
console.error("Error in AJAX call: " + data.error);
return;
}
return data.federal_type
})
.catch(error => {
console.error("Error fetching federal and portfolio types: ", error);
return null
});
}
// Handle dynamically hiding the urbanization field
let urbanizationField = document.querySelector(".field-urbanization");
let stateTerritory = document.getElementById("id_state_territory");
if (urbanizationField && stateTerritory) {
// Execute this function once on load
handleStateTerritoryChange(stateTerritory, urbanizationField);
}
// Attach the change event listener for state/territory
stateTerritory.addEventListener("change", function() {
handleStateTerritoryChange(stateTerritory, urbanizationField);
/**
* Fetches senior official contact data based on a selected agency using an AJAX call.
*
* @param {string} agency
* @returns {Promise<Object|null>} - A promise that resolves to the portfolio data object if successful,
* or null if there was an error.
*/
function getSeniorOfficialFromAgency(agency) {
return fetch(`${seniorOfficialApi}?agency_name=${agency}`)
.then(response => {
const statusCode = response.status;
return response.json().then(data => ({ statusCode, data }));
})
.then(({ statusCode, data }) => {
if (data.error) {
// Throw an error with status code and message
throw { statusCode, message: data.error };
} else {
return data;
}
})
.catch(error => {
console.error("Error fetching senior official: ", error);
throw error; // Re-throw for external handling
});
}
// Handle hiding the organization name field when the organization_type is federal.
// Run this first one page load, then secondly on a change event.
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
organizationType.addEventListener("change", function() {
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
});
});
function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) {
if (organizationType && organizationNameContainer) {
let selectedValue = organizationType.value;
}
/**
* Handles the side effects of change on the organization type field
*
* 1. If selection is federal, hide org name, show federal agency, show federal type if applicable
* 2. else show org name, hide federal agency, hide federal type if applicable
*/
function handleOrganizationTypeChange() {
if (organizationTypeDropdown && organizationNameField) {
let selectedValue = organizationTypeDropdown.value;
if (selectedValue === "federal") {
hideElement(organizationNameContainer);
showElement(federalAgencyContainer);
if (federalType) {
showElement(federalType);
hideElement(organizationNameField);
showElement(federalAgencyField);
if (federalTypeField) {
showElement(federalTypeField);
}
} else {
showElement(organizationNameContainer);
hideElement(federalAgencyContainer);
if (federalType) {
hideElement(federalType);
showElement(organizationNameField);
hideElement(federalAgencyField);
if (federalTypeField) {
hideElement(federalTypeField);
}
}
}
}
function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) {
// Don't do anything on page load
if (isInitialPageLoad) {
isInitialPageLoad = false;
return;
}
/**
* Handles the side effects of change on the federal agency field
*
* 1. handle org type dropdown or readonly
* 2. call handleOrganizationTypeChange
* 3. call getFederalTypeFromAgency and update federal type
* 4. call getSeniorOfficialFromAgency and update the SO fieldset
*/
function handleFederalAgencyChange() {
if (!isPageLoading) {
// Set the org type to federal if an agency is selected
let selectedText = federalAgency.find("option:selected").text();
// There isn't a federal senior official associated with null records
if (!selectedText) {
return;
}
let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase();
if (selectedText !== "Non-Federal Agency") {
if (organizationTypeValue !== "federal") {
if (organizationType){
organizationType.value = "federal";
}else {
readonlyOrganizationType.innerText = "Federal"
}
}
}else {
if (organizationTypeValue === "federal") {
if (organizationType){
organizationType.value = "";
}else {
readonlyOrganizationType.innerText = "-"
}
}
}
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
// Determine if any changes are necessary to the display of portfolio type or federal type
// based on changes to the Federal Agency
let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value;
fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`)
.then(response => {
const statusCode = response.status;
return response.json().then(data => ({ statusCode, data }));
})
.then(({ statusCode, data }) => {
if (data.error) {
console.error("Error in AJAX call: " + data.error);
let selectedFederalAgency = $federalAgencyDropdown.find("option:selected").text();
if (!selectedFederalAgency) {
return;
}
updateReadOnly(data.federal_type, '.field-federal_type');
})
.catch(error => console.error("Error fetching federal and portfolio types: ", error));
// Hide the contactList initially.
// If we can update the contact information, it'll be shown again.
hideElement(contactList.parentElement);
let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value;
let $seniorOfficial = django.jQuery("#id_senior_official");
let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly");
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
.then(response => {
const statusCode = response.status;
return response.json().then(data => ({ statusCode, data }));
})
.then(({ statusCode, data }) => {
if (data.error) {
// Clear the field if the SO doesn't exist.
if (statusCode === 404) {
if ($seniorOfficial && $seniorOfficial.length > 0) {
$seniorOfficial.val("").trigger("change");
}else {
// Show the "create one now" text if this field is none in readonly mode.
readonlySeniorOfficial.innerHTML = `<a href="${seniorOfficialAddUrl}">No senior official found. Create one now.</a>`;
// 1. Handle organization type
let organizationTypeValue = organizationTypeDropdown ? organizationTypeDropdown.value : organizationTypeReadonly.innerText.toLowerCase();
if (selectedFederalAgency !== "Non-Federal Agency") {
if (organizationTypeValue !== "federal") {
if (organizationTypeDropdown){
organizationTypeDropdown.value = "federal";
} else {
organizationTypeReadonly.innerText = "Federal"
}
}
} else {
if (organizationTypeValue === "federal") {
if (organizationTypeDropdown){
organizationTypeDropdown.value = "";
} else {
organizationTypeReadonly.innerText = "-"
}
console.warn("Record not found: " + data.error);
}else {
console.error("Error in AJAX call: " + data.error);
}
return;
}
// Update the "contact details" blurb beneath senior official
updateContactInfo(data);
showElement(contactList.parentElement);
// 2. Handle organization type change side effects
handleOrganizationTypeChange();
// 3. Handle federal type
getFederalTypeFromAgency(selectedFederalAgency).then((federalType) => updateReadOnly(federalType, '.field-federal_type'));
// Get the associated senior official with this federal agency
let seniorOfficialId = data.id;
let seniorOfficialName = [data.first_name, data.last_name].join(" ");
if ($seniorOfficial && $seniorOfficial.length > 0) {
// If the senior official is a dropdown field, edit that
updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName);
}else {
if (readonlySeniorOfficial) {
let seniorOfficialLink = `<a href=/admin/registrar/seniorofficial/${seniorOfficialId}/change/>${seniorOfficialName}</a>`
readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-";
// 4. Handle senior official
hideElement(seniorOfficialAddress.parentElement);
getSeniorOfficialFromAgency(selectedFederalAgency).then((senior_official) => {
// Update the "contact details" blurb beneath senior official
updateSeniorOfficialContactInfo(senior_official);
showElement(seniorOfficialAddress.parentElement);
// Get the associated senior official with this federal agency
let seniorOfficialId = senior_official.id;
let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(" ");
if ($seniorOfficialDropdown && $seniorOfficialDropdown.length > 0) {
// If the senior official is a dropdown field, edit that
updateSeniorOfficialDropdown(seniorOfficialId, seniorOfficialName);
} else {
if (seniorOfficialReadonly) {
let seniorOfficialLink = `<a href=/admin/registrar/seniorofficial/${seniorOfficialId}/change/>${seniorOfficialName}</a>`
seniorOfficialReadonly.innerHTML = seniorOfficialName ? seniorOfficialLink : "-";
}
}
}
})
.catch(error => console.error("Error fetching senior official: ", error));
})
.catch(error => {
if (error.statusCode === 404) {
// Handle "not found" senior official
if ($seniorOfficialDropdown && $seniorOfficialDropdown.length > 0) {
$seniorOfficialDropdown.val("").trigger("change");
} else {
seniorOfficialReadonly.innerHTML = `<a href="${seniorOfficialAddUrl}">No senior official found. Create one now.</a>`;
}
} else {
// Handle other errors
console.error("An error occurred:", error.message);
}
});
} else {
isPageLoading = false;
}
}
function updateSeniorOfficialDropdown(dropdown, seniorOfficialId, seniorOfficialName) {
/**
* Helper for updating federal type field
*/
function updateSeniorOfficialDropdown(seniorOfficialId, seniorOfficialName) {
if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){
// Clear the field if the SO doesn't exist
dropdown.val("").trigger("change");
$seniorOfficialDropdown.val("").trigger("change");
return;
}
// Add the senior official to the dropdown.
// This format supports select2 - if we decide to convert this field in the future.
if (dropdown.find(`option[value='${seniorOfficialId}']`).length) {
if ($seniorOfficialDropdown.find(`option[value='${seniorOfficialId}']`).length) {
// Select the value that is associated with the current Senior Official.
dropdown.val(seniorOfficialId).trigger("change");
$seniorOfficialDropdown.val(seniorOfficialId).trigger("change");
} else {
// Create a DOM Option that matches the desired Senior Official. Then append it and select it.
let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true);
dropdown.append(userOption).trigger("change");
$seniorOfficialDropdown.append(userOption).trigger("change");
}
}
function handleStateTerritoryChange(stateTerritory, urbanizationField) {
let selectedValue = stateTerritory.value;
/**
* Handle urbanization
*/
function handleStateTerritoryChange() {
let selectedValue = stateTerritoryDropdown.value;
if (selectedValue === "PR") {
showElement(urbanizationField)
} else {
@ -207,11 +216,7 @@ export function initDynamicPortfolioFields(){
}
/**
* Utility that selects a div from the DOM using selectorString,
* and updates a div within that div which has class of 'readonly'
* so that the text of the div is updated to updateText
* @param {*} updateText
* @param {*} selectorString
* Helper for updating senior official dropdown
*/
function updateReadOnly(updateText, selectorString) {
// find the div by selectorString
@ -226,34 +231,75 @@ export function initDynamicPortfolioFields(){
}
}
function updateContactInfo(data) {
if (!contactList) return;
const titleSpan = contactList.querySelector(".contact_info_title");
const emailSpan = contactList.querySelector(".contact_info_email");
const phoneSpan = contactList.querySelector(".contact_info_phone");
/**
* Helper for updating senior official contact info
*/
function updateSeniorOfficialContactInfo(senior_official) {
if (!seniorOfficialAddress) return;
const titleSpan = seniorOfficialAddress.querySelector(".contact_info_title");
const emailSpan = seniorOfficialAddress.querySelector(".contact_info_email");
const phoneSpan = seniorOfficialAddress.querySelector(".contact_info_phone");
if (titleSpan) {
titleSpan.textContent = data.title || "None";
titleSpan.textContent = senior_official.title || "None";
};
// Update the email field and the content for the clipboard
if (emailSpan) {
let copyButton = contactList.querySelector(".admin-icon-group");
emailSpan.textContent = data.email || "None";
if (data.email) {
const clipboardInput = contactList.querySelector(".admin-icon-group input");
let copyButton = seniorOfficialAddress.querySelector(".admin-icon-group");
emailSpan.textContent = senior_official.email || "None";
if (senior_official.email) {
const clipboardInput = seniorOfficialAddress.querySelector(".admin-icon-group input");
if (clipboardInput) {
clipboardInput.value = data.email;
clipboardInput.value = senior_official.email;
};
showElement(copyButton);
}else {
hideElement(copyButton);
}
}
if (phoneSpan) {
phoneSpan.textContent = data.phone || "None";
phoneSpan.textContent = senior_official.phone || "None";
};
}
/**
* Initializes necessary data and display configurations for the portfolio fields.
*/
function initializePortfolioSettings() {
if (urbanizationField && stateTerritoryDropdown) {
handleStateTerritoryChange();
}
handleOrganizationTypeChange();
}
/**
* Sets event listeners for key UI elements.
*/
function setEventListeners() {
if ($federalAgencyDropdown && (organizationTypeDropdown || organizationTypeReadonly)) {
$federalAgencyDropdown.on("change", function() {
handleFederalAgencyChange();
});
}
if (urbanizationField && stateTerritoryDropdown) {
stateTerritoryDropdown.addEventListener("change", function() {
handleStateTerritoryChange();
});
}
organizationTypeDropdown.addEventListener("change", function() {
handleOrganizationTypeChange();
});
}
// Run initial setup functions
initializePortfolioSettings();
setEventListeners();
}
export function initDynamicPortfolioFields() {
document.addEventListener('DOMContentLoaded', function() {
let isPortfolioPage = document.getElementById("portfolio_form");
if (isPortfolioPage) {
handlePortfolioFields();
}
});
}

View file

@ -10,8 +10,7 @@ import { initDomainRequestsTable } from './table-domain-requests.js';
import { initMembersTable } from './table-members.js';
import { initMemberDomainsTable } from './table-member-domains.js';
import { initEditMemberDomainsTable } from './table-edit-member-domains.js';
import { initPortfolioMemberPageToggle } from './portfolio-member-page.js';
import { initAddNewMemberPageListeners } from './portfolio-member-page.js';
import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js';
initDomainValidators();
@ -21,13 +20,6 @@ nameserversFormListener();
hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees');
hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null);
hookupRadioTogglerListener(
'member_access_level',
{
'admin': 'new-member-admin-permissions',
'basic': 'new-member-basic-permissions'
}
);
hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null);
initializeUrbanizationToggle();
@ -44,5 +36,7 @@ initMembersTable();
initMemberDomainsTable();
initEditMemberDomainsTable();
initPortfolioMemberPageToggle();
// Init the portfolio new member page
initPortfolioMemberPageRadio();
initPortfolioNewMemberPageToggle();
initAddNewMemberPageListeners();

View file

@ -2,9 +2,10 @@ import { uswdsInitializeModals } from './helpers-uswds.js';
import { getCsrfToken } from './helpers.js';
import { generateKebabHTML } from './table-base.js';
import { MembersTable } from './table-members.js';
import { hookupRadioTogglerListener } from './radios.js';
// This is specifically for the Member Profile (Manage Member) Page member/invitation removal
export function initPortfolioMemberPageToggle() {
export function initPortfolioNewMemberPageToggle() {
document.addEventListener("DOMContentLoaded", () => {
const wrapperDeleteAction = document.getElementById("wrapper-delete-action")
if (wrapperDeleteAction) {
@ -169,4 +170,29 @@ export function initAddNewMemberPageListeners() {
}
}
}
}
// Initalize the radio for the member pages
export function initPortfolioMemberPageRadio() {
document.addEventListener("DOMContentLoaded", () => {
let memberForm = document.getElementById("member_form");
let newMemberForm = document.getElementById("add_member_form")
if (memberForm) {
hookupRadioTogglerListener(
'role',
{
'organization_admin': 'member-admin-permissions',
'organization_member': 'member-basic-permissions'
}
);
}else if (newMemberForm){
hookupRadioTogglerListener(
'member_access_level',
{
'admin': 'new-member-admin-permissions',
'basic': 'new-member-basic-permissions'
}
);
}
});
}

View file

@ -38,21 +38,21 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme
**/
export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) {
// Get the radio buttons
let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]');
let radioButtons = document.querySelectorAll(`input[name="${radioButtonName}"]`);
// Extract the list of all element IDs from the valueToElementMap
let allElementIds = Object.values(valueToElementMap);
function handleRadioButtonChange() {
// Find the checked radio button
let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked');
let radioButtonChecked = document.querySelector(`input[name="${radioButtonName}"]:checked`);
let selectedValue = radioButtonChecked ? radioButtonChecked.value : null;
// Hide all elements by default
allElementIds.forEach(function (elementId) {
let element = document.getElementById(elementId);
if (element) {
hideElement(element);
hideElement(element);
}
});
@ -64,8 +64,8 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) {
}
}
}
if (radioButtons.length) {
if (radioButtons && radioButtons.length) {
// Add event listener to each radio button
radioButtons.forEach(function (radioButton) {
radioButton.addEventListener('change', handleRadioButtonChange);

View file

@ -4,6 +4,7 @@ import logging
from django import forms
from django.core.validators import RegexValidator
from django.core.validators import MaxLengthValidator
from django.utils.safestring import mark_safe
from registrar.models import (
PortfolioInvitation,
@ -271,3 +272,210 @@ class NewMemberForm(forms.ModelForm):
if admin_member_error in self.errors:
del self.errors[admin_member_error]
return cleaned_data
class BasePortfolioMemberForm(forms.Form):
"""Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm"""
# The label for each of these has a red "required" star. We can just embed that here for simplicity.
required_star = '<abbr class="usa-hint usa-hint--required" title="required">*</abbr>'
role = forms.ChoiceField(
choices=[
# Uses .value because the choice has a different label (on /admin)
(UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"),
(UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"),
],
widget=forms.RadioSelect,
required=True,
error_messages={
"required": "Member access level is required",
},
)
domain_request_permission_admin = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Admin domain request permission is required",
},
)
member_permission_admin = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"),
(UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Admin member permission is required",
},
)
domain_request_permission_member = forms.ChoiceField(
label=mark_safe(f"Select permission {required_star}"), # nosec
choices=[
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"),
("no_access", "No access"),
],
widget=forms.RadioSelect,
required=False,
error_messages={
"required": "Basic member permission is required",
},
)
# Tracks what form elements are required for a given role choice.
# All of the fields included here have "required=False" by default as they are conditionally required.
# see def clean() for more details.
ROLE_REQUIRED_FIELDS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
"domain_request_permission_admin",
"member_permission_admin",
],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
"domain_request_permission_member",
],
}
def __init__(self, *args, instance=None, **kwargs):
"""Initialize self.instance, self.initial, and descriptions under each radio button.
Uses map_instance_to_initial to set the initial dictionary."""
super().__init__(*args, **kwargs)
if instance:
self.instance = instance
self.initial = self.map_instance_to_initial(self.instance)
# Adds a <p> description beneath each role option
self.fields["role"].descriptions = {
"organization_admin": UserPortfolioRoleChoices.get_role_description(
UserPortfolioRoleChoices.ORGANIZATION_ADMIN
),
"organization_member": UserPortfolioRoleChoices.get_role_description(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
),
}
def save(self):
"""Saves self.instance by grabbing data from self.cleaned_data.
Uses map_cleaned_data_to_instance.
"""
self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance)
self.instance.save()
return self.instance
def clean(self):
"""Validates form data based on selected role and its required fields."""
cleaned_data = super().clean()
role = cleaned_data.get("role")
# Get required fields for the selected role. Then validate all required fields for the role.
required_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
for field_name in required_fields:
# Helpful error for if this breaks
if field_name not in self.fields:
raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.")
if not cleaned_data.get(field_name):
self.add_error(field_name, self.fields.get(field_name).error_messages.get("required"))
# Edgecase: Member uses a special form value for None called "no_access".
if cleaned_data.get("domain_request_permission_member") == "no_access":
cleaned_data["domain_request_permission_member"] = None
return cleaned_data
# Explanation of how map_instance_to_initial / map_cleaned_data_to_instance work:
# map_instance_to_initial => called on init to set self.initial.
# Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission)
# into a dictionary representation for the form to use automatically.
# map_cleaned_data_to_instance => called on save() to save the instance to the db.
# Takes the self.cleaned_data dict, and converts this dict back to the object.
def map_instance_to_initial(self, instance):
"""
Maps self.instance to self.initial, handling roles and permissions.
Returns form data dictionary with appropriate permission levels based on user role:
{
"role": "organization_admin" or "organization_member",
"member_permission_admin": permission level if admin,
"domain_request_permission_admin": permission level if admin,
"domain_request_permission_member": permission level if member
}
"""
# Function variables
form_data = {}
perms = UserPortfolioPermission.get_portfolio_permissions(
instance.roles, instance.additional_permissions, get_list=False
)
# Get the available options for roles, domains, and member.
roles = [
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
]
domain_perms = [
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
]
member_perms = [
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.VIEW_MEMBERS,
]
# Build form data based on role (which options are available).
# Get which one should be "selected" by assuming that EDIT takes precedence over view,
# and ADMIN takes precedence over MEMBER.
roles = instance.roles or []
selected_role = next((role for role in roles if role in roles), None)
form_data = {"role": selected_role}
is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN
if is_admin:
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None)
selected_member_permission = next((perm for perm in member_perms if perm in perms), None)
form_data["domain_request_permission_admin"] = selected_domain_permission
form_data["member_permission_admin"] = selected_member_permission
else:
# Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection.
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access")
form_data["domain_request_permission_member"] = selected_domain_permission
return form_data
def map_cleaned_data_to_instance(self, cleaned_data, instance):
"""
Maps self.cleaned_data to self.instance, setting roles and permissions.
Args:
cleaned_data (dict): Cleaned data containing role and permission choices
instance: Instance to update
Returns:
instance: Updated instance
"""
role = cleaned_data.get("role")
# Handle roles
instance.roles = [role]
# Handle additional_permissions
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
# Handle EDIT permissions (should be accompanied with a view permission)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
# Only set unique permissions not already defined in the base role
role_permissions = UserPortfolioPermission.get_portfolio_permissions(instance.roles, [], get_list=False)
instance.additional_permissions = list(additional_permissions - role_permissions)
return instance

View file

@ -230,6 +230,12 @@ class Domain(TimeStampedModel, DomainHelper):
"""Called during delete. Example: `del domain.registrant`."""
super().__delete__(obj)
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
# If the domain is deleted we don't want the expiration date to be set
if self.state == self.State.DELETED and self.expiration_date:
self.expiration_date = None
super().save(force_insert, force_update, using, update_fields)
@classmethod
def available(cls, domain: str) -> bool:
"""Check if a domain is available.
@ -253,7 +259,7 @@ class Domain(TimeStampedModel, DomainHelper):
return not cls.available(domain)
@Cache
def contacts(self) -> dict[str, str]:
def registry_contacts(self) -> dict[str, str]:
"""
Get a dictionary of registry IDs for the contacts for this domain.
@ -706,7 +712,7 @@ class Domain(TimeStampedModel, DomainHelper):
raise e
@nameservers.setter # type: ignore
def nameservers(self, hosts: list[tuple[str, list]]):
def nameservers(self, hosts: list[tuple[str, list]]): # noqa
"""Host should be a tuple of type str, str,... where the elements are
Fully qualified host name, addresses associated with the host
example: [(ns1.okay.gov, [127.0.0.1, others ips])]"""
@ -743,7 +749,12 @@ class Domain(TimeStampedModel, DomainHelper):
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
try:
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
except Exception as e:
# we don't need this part to succeed in order to continue.
logger.error("Failed to delete nameserver hosts: %s", e)
if successTotalNameservers < 2:
try:
self.dns_needed()
@ -1029,6 +1040,47 @@ class Domain(TimeStampedModel, DomainHelper):
def _delete_domain(self):
"""This domain should be deleted from the registry
may raises RegistryError, should be caught or handled correctly by caller"""
logger.info("Deleting subdomains for %s", self.name)
# check if any subdomains are in use by another domain
hosts = Host.objects.filter(name__regex=r".+{}".format(self.name))
for host in hosts:
if host.domain != self:
logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain)
raise RegistryError(
code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION,
note=f"Host {host.name} is in use by {host.domain}",
)
(
deleted_values,
updated_values,
new_values,
oldNameservers,
) = self.getNameserverChanges(hosts=[])
_ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors
addToDomainList, _ = self.createNewHostList(new_values)
deleteHostList, _ = self.createDeleteHostList(deleted_values)
responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
# if unable to update domain raise error and stop
if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
raise NameserverError(code=nsErrorCodes.BAD_DATA)
# addAndRemoveHostsFromDomain removes the hosts from the domain object,
# but we still need to delete the object themselves
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
logger.debug("Deleting non-registrant contacts for %s", self.name)
contacts = PublicContact.objects.filter(domain=self)
for contact in contacts:
if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
self._update_domain_with_contact(contact, rem=True)
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
logger.info("Deleting domain %s", self.name)
request = commands.DeleteDomain(name=self.name)
registry.send(request, cleaned=True)
@ -1096,7 +1148,7 @@ class Domain(TimeStampedModel, DomainHelper):
Returns True if expired, False otherwise.
"""
if self.expiration_date is None:
return True
return self.state != self.State.DELETED
now = timezone.now().date()
return self.expiration_date < now
@ -1430,6 +1482,8 @@ class Domain(TimeStampedModel, DomainHelper):
@transition(field="state", source=[State.ON_HOLD, State.DNS_NEEDED], target=State.DELETED)
def deletedInEpp(self):
"""Domain is deleted in epp but is saved in our database.
Subdomains will be deleted first if not in use by another domain.
Contacts for this domain will also be deleted.
Error handling should be provided by the caller."""
# While we want to log errors, we want to preserve
# that information when this function is called.
@ -1439,8 +1493,9 @@ class Domain(TimeStampedModel, DomainHelper):
logger.info("deletedInEpp()-> inside _delete_domain")
self._delete_domain()
self.deleted = timezone.now()
self.expiration_date = None
except RegistryError as err:
logger.error(f"Could not delete domain. Registry returned error: {err}")
logger.error(f"Could not delete domain. Registry returned error: {err}. {err.note}")
raise err
except TransitionNotAllowed as err:
logger.error("Could not delete domain. FSM failure: {err}")
@ -1745,7 +1800,6 @@ class Domain(TimeStampedModel, DomainHelper):
"""delete the host object in registry,
will only delete the host object, if it's not being used by another domain
Performs just the DeleteHost epp call
Supresses regstry error, as registry can disallow delete for various reasons
Args:
hostsToDelete (list[str])- list of nameserver/host names to remove
Returns:
@ -1764,6 +1818,8 @@ class Domain(TimeStampedModel, DomainHelper):
else:
logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e))
raise e
def _fix_unknown_state(self, cleaned):
"""
_fix_unknown_state: Calls _add_missing_contacts_if_unknown

View file

@ -110,8 +110,13 @@ class UserPortfolioPermission(TimeStampedModel):
return self.get_portfolio_permissions(self.roles, self.additional_permissions)
@classmethod
def get_portfolio_permissions(cls, roles, additional_permissions):
"""Class method to return a list of permissions based on roles and addtl permissions"""
def get_portfolio_permissions(cls, roles, additional_permissions, get_list=True):
"""Class method to return a list of permissions based on roles and addtl permissions.
Params:
roles => An array of roles
additional_permissions => An array of additional_permissions
get_list => If true, returns a list of perms. If false, returns a set of perms.
"""
# Use a set to avoid duplicate permissions
portfolio_permissions = set()
if roles:
@ -119,7 +124,7 @@ class UserPortfolioPermission(TimeStampedModel):
portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
if additional_permissions:
portfolio_permissions.update(additional_permissions)
return list(portfolio_permissions)
return list(portfolio_permissions) if get_list else portfolio_permissions
@classmethod
def get_domain_request_permission_display(cls, roles, additional_permissions):

View file

@ -4,6 +4,9 @@ from django.apps import apps
from django.forms import ValidationError
from registrar.utility.waffle import flag_is_active_for_user
from django.contrib.auth import get_user_model
import logging
logger = logging.getLogger(__name__)
class UserPortfolioRoleChoices(models.TextChoices):
@ -16,7 +19,28 @@ class UserPortfolioRoleChoices(models.TextChoices):
@classmethod
def get_user_portfolio_role_label(cls, user_portfolio_role):
return cls(user_portfolio_role).label if user_portfolio_role else None
try:
return cls(user_portfolio_role).label if user_portfolio_role else None
except ValueError:
logger.warning(f"Invalid portfolio role: {user_portfolio_role}")
return f"Unknown ({user_portfolio_role})"
@classmethod
def get_role_description(cls, user_portfolio_role):
"""Returns a detailed description for a given role."""
descriptions = {
cls.ORGANIZATION_ADMIN: (
"Grants this member access to the organization-wide information "
"on domains, domain requests, and members. Domain management can be assigned separately."
),
cls.ORGANIZATION_MEMBER: (
"Grants this member access to the organization. They can be given extra permissions to view all "
"organization domain requests and submit domain requests on behalf of the organization. Basic access "
"members cant view all members of an organization or manage them. "
"Domain management can be assigned separately."
),
}
return descriptions.get(user_portfolio_role)
class UserPortfolioPermissionChoices(models.TextChoices):

View file

@ -1,6 +1,13 @@
{% extends 'admin/change_form.html' %}
{% 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 %}
<div class="display-flex flex-row flex-justify submit-row">
<div class="flex-align-self-start button-list-mobile">

View file

@ -1,6 +1,13 @@
{% extends 'admin/change_form.html' %}
{% 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 %}
{% for fieldset in adminform %}
{% comment %}

View file

@ -1,3 +1,5 @@
{% load static custom_filters %}
<div class="{{ uswds_input_class }}">
{% for group, options, index in widget.optgroups %}
{% if group %}<div><label>{{ group }}</label>{% endif %}
@ -13,7 +15,17 @@
<label
class="{{ uswds_input_class }}__label{% if label_classes %} {{ label_classes }}{% endif %}"
for="{{ option.attrs.id }}"
>{{ option.label }}</label>
>
{{ option.label }}
{% comment %} Add a description on each, if available {% endcomment %}
{% if field and field.field and field.field.descriptions %}
{% with description=field.field.descriptions|get_dict_value:option.value %}
{% if description %}
<p class="margin-0 margin-top-1">{{ description }}</p>
{% endif %}
{% endwith %}
{% endif %}
</label>
{% endfor %}
{% if group %}</div>{% endif %}
{% endfor %}

View file

@ -26,13 +26,17 @@
<h2>Next steps in this process</h2>
<p> Well review your request. This review period can take 30 business days. Due to the volume of requests, the wait time is longer than usual. We appreciate your patience.</p>
<p>During our review well verify that:</p>
<ul class="usa-list">
<li>Your organization is eligible for a .gov domain.</li>
<li>You work at the organization and/or can make requests on its behalf.</li>
<li>Your requested domain meets our naming requirements.</li>
</ul>
{% if has_organization_feature_flag %}
<p>During our review, well verify that your requested domain meets our naming requirements.</p>
{% else %}
<p>During our review, well verify that:</p>
<ul class="usa-list">
<li>Your organization is eligible for a .gov domain.</li>
<li>You work at the organization and/or can make requests on its behalf.</li>
<li>Your requested domain meets our naming requirements.</li>
</ul>
{% endif %}
<p> Well email you if we have questions. Well also email you as soon as we complete our review. You can <a href="{% if portfolio %}{% url 'domain-requests' %}{% else %}{% url 'home' %}{% endif %}">check the status</a>
of your request at any time on the registrar.</p>

View file

@ -12,12 +12,12 @@ STATUS: Submitted
NEXT STEPS
Well review your request. This review period can take 30 business days. Due to the volume of requests, the wait time is longer than usual. We appreciate your patience.
During our review well verify that:
During our review, well verify that:
- Your organization is eligible for a .gov domain
- You work at the organization and/or can make requests on its behalf
- Your requested domain meets our naming requirements
Well email you if we have questions. Well also email you as soon as we complete our review. You can check the status of your request at any time on the registrar homepage. <https://manage.get.gov>
Well email you if we have questions. Well also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <https://manage.get.gov>
NEED TO MAKE CHANGES?

View file

@ -1,42 +1,132 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% load static url_helpers %}
{% load field_helpers %}
{% block title %}Organization member {% endblock %}
{% block title %}Organization member{% endblock %}
{% load static %}
{% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %}
<div class="grid-row grid-gap">
<div class="tablet:grid-col-9" id="main-content">
{% include "includes/form_errors.html" with form=form %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<!-- Navigation breadcrumbs -->
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{% url 'members' %}" class="usa-breadcrumb__link"><span>Members</span></a>
</li>
<li class="usa-breadcrumb__list-item">
{% if member %}
{% url 'member' pk=member.pk as back_url %}
{% elif invitation %}
{% url 'invitedmember' pk=invitation.pk as back_url %}
{% endif %}
<a href="{{ back_url }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
</li>
{% comment %} Manage members {% endcomment %}
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Member access and permissions</span>
</li>
</ol>
</nav>
<h1>Manage member</h1>
<p>
{% if member %}
{{ member.email }}
{% elif invitation %}
{{ invitation.email }}
{% endif %}
</p>
<!-- Page header -->
<h1>Member access and permissions</h1>
<hr>
{% include "includes/required_fields.html" with remove_margin_top=True %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}
{% input_with_errors form.roles %}
{% input_with_errors form.additional_permissions %}
<button
type="submit"
class="usa-button"
>Submit</button>
</form>
<form class="usa-form usa-form--large" method="post" id="member_form" novalidate>
{% csrf_token %}
<fieldset class="usa-fieldset">
<legend>
{% if member and member.email or invitation and invitation.email %}
<h2 class="margin-top-1">Member email</h2>
{% else %}
<h2 class="margin-top-1">Member</h2>
{% endif %}
</legend>
<p class="margin-top-0">
{% comment %}
Show member email if possible, then invitation email.
If neither of these are true, show the name or as a last resort just "None".
{% endcomment %}
{% if member %}
{% if member.email %}
{{ member.email }}
{% else %}
{{ member.get_formatted_name }}
{% endif %}
{% elif invitation %}
{% if invitation.email %}
{{ invitation.email }}
{% else %}
None
{% endif %}
{% endif %}
</p>
<!-- Member email -->
</fieldset>
<!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset">
<legend>
<h2 class="margin-top-0">Member Access</h2>
</legend>
</div>
</div>
{% endblock %}
<em>Select the level of access for this member. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors form.role %}
{% endwith %}
</fieldset>
<!-- Admin access form -->
<div id="member-admin-permissions" class="margin-top-2">
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p>
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.domain_request_permission_admin %}
{% endwith %}
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0
margin-top-3">Organization members</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.member_permission_admin %}
{% endwith %}
</div>
<!-- Basic access form -->
<div id="member-basic-permissions" class="margin-top-2">
<h2>Basic member permissions</h2>
<p>Member permissions available for basic-level acccess.</p>
<h3 class="margin-bottom-0 summary-item__title text-primary-dark">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.domain_request_permission_member %}
{% endwith %}
</div>
<!-- Submit/cancel buttons -->
<div class="margin-top-3">
<a
type="button"
href="{{ back_url }}"
class="usa-button usa-button--outline"
name="btn-cancel-click"
aria-label="Cancel editing member"
>
Cancel
</a>
<button type="submit" class="usa-button">Update Member</button>
</div>
</form>
{% endblock portfolio_content%}

View file

@ -5,12 +5,6 @@
{% block title %} Domains | {% endblock %}
{% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div id="main-content">
<h1 id="domains-header">Domains</h1>
<section class="section-outlined">

View file

@ -282,3 +282,11 @@ def display_requesting_entity(domain_request):
)
return display
@register.filter
def get_dict_value(dictionary, key):
"""Get a value from a dictionary. Returns a string on empty."""
if isinstance(dictionary, dict):
return dictionary.get(key, "")
return ""

View file

@ -1248,6 +1248,7 @@ class MockEppLib(TestCase):
common.Status(state="serverTransferProhibited", description="", lang="en"),
common.Status(state="inactive", description="", lang="en"),
],
registrant="regContact",
ex_date=date(2023, 5, 25),
)
@ -1410,6 +1411,15 @@ class MockEppLib(TestCase):
hosts=["fake.host.com"],
)
infoDomainSharedHost = fakedEppObject(
"sharedHost.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=[
"ns1.sharedhost.com",
],
)
infoDomainThreeHosts = fakedEppObject(
"my-nameserver.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
@ -1620,6 +1630,8 @@ class MockEppLib(TestCase):
return self.mockInfoContactCommands(_request, cleaned)
case commands.CreateContact:
return self.mockCreateContactCommands(_request, cleaned)
case commands.DeleteContact:
return self.mockDeleteContactCommands(_request, cleaned)
case commands.UpdateDomain:
return self.mockUpdateDomainCommands(_request, cleaned)
case commands.CreateHost:
@ -1627,10 +1639,7 @@ class MockEppLib(TestCase):
case commands.UpdateHost:
return self.mockUpdateHostCommands(_request, cleaned)
case commands.DeleteHost:
return MagicMock(
res_data=[self.mockDataHostChange],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
return self.mockDeleteHostCommands(_request, cleaned)
case commands.CheckDomain:
return self.mockCheckDomainCommand(_request, cleaned)
case commands.DeleteDomain:
@ -1683,6 +1692,15 @@ class MockEppLib(TestCase):
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
def mockDeleteHostCommands(self, _request, cleaned):
host = getattr(_request, "name", None)
if "sharedhost.com" in host:
raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, note="ns1.sharedhost.com")
return MagicMock(
res_data=[self.mockDataHostChange],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
def mockUpdateDomainCommands(self, _request, cleaned):
if getattr(_request, "name", None) == "dnssec-invalid.gov":
raise RegistryError(code=ErrorCode.PARAMETER_VALUE_RANGE_ERROR)
@ -1694,10 +1712,7 @@ class MockEppLib(TestCase):
def mockDeleteDomainCommands(self, _request, cleaned):
if getattr(_request, "name", None) == "failDelete.gov":
name = getattr(_request, "name", None)
fake_nameserver = "ns1.failDelete.gov"
if name in fake_nameserver:
raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
return None
def mockRenewDomainCommand(self, _request, cleaned):
@ -1737,6 +1752,7 @@ class MockEppLib(TestCase):
# Define a dictionary to map request names to data and extension values
request_mappings = {
"fake.gov": (self.mockDataInfoDomain, None),
"security.gov": (self.infoDomainNoContact, None),
"dnssec-dsdata.gov": (
self.mockDataInfoDomain,
@ -1767,6 +1783,7 @@ class MockEppLib(TestCase):
"subdomainwoip.gov": (self.mockDataInfoDomainSubdomainNoIP, None),
"ddomain3.gov": (self.InfoDomainWithContacts, None),
"igorville.gov": (self.InfoDomainWithContacts, None),
"sharingiscaring.gov": (self.infoDomainSharedHost, None),
}
# Retrieve the corresponding values from the dictionary
@ -1817,6 +1834,15 @@ class MockEppLib(TestCase):
raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE)
return MagicMock(res_data=[self.mockDataInfoHosts])
def mockDeleteContactCommands(self, _request, cleaned):
if getattr(_request, "id", None) == "fail":
raise RegistryError(code=ErrorCode.OBJECT_EXISTS)
else:
return MagicMock(
res_data=[self.mockDataInfoContact],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
def setUp(self):
"""mock epp send function as this will fail locally"""
self.mockSendPatch = patch("registrar.models.domain.registry.send")

View file

@ -853,9 +853,9 @@ class TestDomainInformationAdmin(TestCase):
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
# We expect 3 in the form + 2 from the js module copy-to-clipboard.js
# We expect 4 in the form + 2 from the js module copy-to-clipboard.js
# that gets pulled in the test in django.contrib.staticfiles.finders.FileSystemFinder
self.assertContains(response, "copy-to-clipboard", count=5)
self.assertContains(response, "copy-to-clipboard", count=6)
# cleanup this test
domain_info.delete()
@ -871,6 +871,17 @@ class TestDomainInformationAdmin(TestCase):
readonly_fields = self.admin.get_readonly_fields(request)
expected_fields = [
"portfolio_senior_official",
"portfolio_organization_type",
"portfolio_federal_type",
"portfolio_organization_name",
"portfolio_federal_agency",
"portfolio_state_territory",
"portfolio_address_line1",
"portfolio_address_line2",
"portfolio_city",
"portfolio_zipcode",
"portfolio_urbanization",
"other_contacts",
"is_election_board",
"federal_agency",

View file

@ -16,6 +16,7 @@ from registrar.models import (
Host,
Portfolio,
)
from registrar.models.public_contact import PublicContact
from registrar.models.user_domain_role import UserDomainRole
from .common import (
MockSESClient,
@ -59,6 +60,7 @@ class TestDomainAdminAsStaff(MockEppLib):
def tearDown(self):
super().tearDown()
Host.objects.all().delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
@ -170,7 +172,7 @@ class TestDomainAdminAsStaff(MockEppLib):
@less_console_noise_decorator
def test_deletion_is_successful(self):
"""
Scenario: Domain deletion is unsuccessful
Scenario: Domain deletion is successful
When the domain is deleted
Then a user-friendly success message is returned for displaying on the web
And `state` is set to `DELETED`
@ -221,6 +223,55 @@ class TestDomainAdminAsStaff(MockEppLib):
self.assertEqual(domain.state, Domain.State.DELETED)
# @less_console_noise_decorator
def test_deletion_is_unsuccessful(self):
"""
Scenario: Domain deletion is unsuccessful
When the domain is deleted and has shared subdomains
Then a user-friendly success message is returned for displaying on the web
And `state` is not set to `DELETED`
"""
domain, _ = Domain.objects.get_or_create(name="sharingiscaring.gov", state=Domain.State.ON_HOLD)
# Put in client hold
domain.place_client_hold()
# Ensure everything is displaying correctly
response = self.client.get(
"/admin/registrar/domain/{}/change/".format(domain.pk),
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Remove from registry")
# The contents of the modal should exist before and after the post.
# Check for the header
self.assertContains(response, "Are you sure you want to remove this domain from the registry?")
# Check for some of its body
self.assertContains(response, "When a domain is removed from the registry:")
# Check for some of the button content
self.assertContains(response, "Yes, remove from registry")
# Test the info dialog
request = self.factory.post(
"/admin/registrar/domain/{}/change/".format(domain.pk),
{"_delete_domain": "Remove from registry", "name": domain.name},
follow=True,
)
request.user = self.client
with patch("django.contrib.messages.add_message") as mock_add_message:
self.admin.do_delete_domain(request, domain)
mock_add_message.assert_called_once_with(
request,
messages.ERROR,
"Error deleting this Domain: This subdomain is being used as a hostname on another domain: ns1.sharedhost.com", # noqa
extra_tags="",
fail_silently=False,
)
self.assertEqual(domain.state, Domain.State.ON_HOLD)
@less_console_noise_decorator
def test_deletion_ready_fsm_failure(self):
"""

View file

@ -9,6 +9,7 @@ from django.db.utils import IntegrityError
from unittest.mock import MagicMock, patch, call
import datetime
from django.utils.timezone import make_aware
from api.tests.common import less_console_noise_decorator
from registrar.models import Domain, Host, HostIP
from unittest import skip
@ -1454,6 +1455,7 @@ class TestRegistrantNameservers(MockEppLib):
),
call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), cleaned=True),
]
self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True)
self.assertFalse(self.domainWithThreeNS.is_active())
@ -2582,12 +2584,32 @@ class TestAnalystDelete(MockEppLib):
"""
super().setUp()
self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
self.domain_with_contacts, _ = Domain.objects.get_or_create(name="freeman.gov", state=Domain.State.READY)
self.domain_on_hold, _ = Domain.objects.get_or_create(name="fake-on-hold.gov", state=Domain.State.ON_HOLD)
Host.objects.create(name="ns1.sharingiscaring.gov", domain=self.domain_on_hold)
PublicContact.objects.create(
registry_id="regContact",
contact_type=PublicContact.ContactTypeChoices.REGISTRANT,
domain=self.domain_with_contacts,
)
PublicContact.objects.create(
registry_id="adminContact",
contact_type=PublicContact.ContactTypeChoices.ADMINISTRATIVE,
domain=self.domain_with_contacts,
)
PublicContact.objects.create(
registry_id="techContact",
contact_type=PublicContact.ContactTypeChoices.TECHNICAL,
domain=self.domain_with_contacts,
)
def tearDown(self):
Host.objects.all().delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
super().tearDown()
@less_console_noise_decorator
def test_analyst_deletes_domain(self):
"""
Scenario: Analyst permanently deletes a domain
@ -2597,59 +2619,163 @@ class TestAnalystDelete(MockEppLib):
The deleted date is set.
"""
with less_console_noise():
# Put the domain in client hold
self.domain.place_client_hold()
# Delete it...
self.domain.deletedInEpp()
self.domain.save()
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteDomain(name="fake.gov"),
cleaned=True,
)
]
)
# Domain itself should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.DELETED)
# Domain should have a deleted
self.assertNotEqual(self.domain.deleted, None)
# Cache should be invalidated
self.assertEqual(self.domain._cache, {})
# Put the domain in client hold
self.domain.place_client_hold()
# Delete it...
self.domain.deletedInEpp()
self.domain.save()
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteDomain(name="fake.gov"),
cleaned=True,
)
]
)
# Domain itself should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.DELETED)
# Domain should have a deleted
self.assertNotEqual(self.domain.deleted, None)
# Cache should be invalidated
self.assertEqual(self.domain._cache, {})
@less_console_noise_decorator
def test_deletion_is_unsuccessful(self):
"""
Scenario: Domain deletion is unsuccessful
When a subdomain exists
When a subdomain exists that is in use by another domain
Then a client error is returned of code 2305
And `state` is not set to `DELETED`
"""
with less_console_noise():
# Desired domain
domain, _ = Domain.objects.get_or_create(name="failDelete.gov", state=Domain.State.ON_HOLD)
# Put the domain in client hold
domain.place_client_hold()
# Delete it
with self.assertRaises(RegistryError) as err:
domain.deletedInEpp()
domain.save()
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteDomain(name="failDelete.gov"),
cleaned=True,
)
]
)
# Domain itself should not be deleted
self.assertNotEqual(domain, None)
# State should not have changed
self.assertEqual(domain.state, Domain.State.ON_HOLD)
# Desired domain
domain, _ = Domain.objects.get_or_create(name="sharingiscaring.gov", state=Domain.State.ON_HOLD)
# Put the domain in client hold
domain.place_client_hold()
# Delete it
with self.assertRaises(RegistryError) as err:
domain.deletedInEpp()
domain.save()
self.assertTrue(err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
self.assertEqual(err.msg, "Host ns1.sharingiscaring.gov is in use by: fake-on-hold.gov")
# Domain itself should not be deleted
self.assertNotEqual(domain, None)
# State should not have changed
self.assertEqual(domain.state, Domain.State.ON_HOLD)
@less_console_noise_decorator
def test_deletion_with_host_and_contacts(self):
"""
Scenario: Domain with related Host and Contacts is Deleted
When a contact and host exists that is tied to this domain
Then all the needed commands are sent to the registry
And `state` is set to `DELETED`
"""
# Put the domain in client hold
self.domain_with_contacts.place_client_hold()
# Delete it
self.domain_with_contacts.deletedInEpp()
self.domain_with_contacts.save()
# Check that the host and contacts are deleted
self.mockedSendFunction.assert_has_calls(
[
call(
commands.UpdateDomain(
name="freeman.gov",
add=[common.Status(state=Domain.Status.CLIENT_HOLD, description="", lang="en")],
rem=[],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
),
]
)
self.mockedSendFunction.assert_has_calls(
[
call(
commands.InfoDomain(name="freeman.gov", auth_info=None),
cleaned=True,
),
call(
commands.InfoHost(name="fake.host.com"),
cleaned=True,
),
call(
commands.UpdateDomain(
name="freeman.gov",
add=[],
rem=[common.HostObjSet(hosts=["fake.host.com"])],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
),
]
)
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteHost(name="fake.host.com"),
cleaned=True,
),
call(
commands.UpdateDomain(
name="freeman.gov",
add=[],
rem=[common.DomainContact(contact="adminContact", type="admin")],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
),
call(
commands.DeleteContact(id="adminContact"),
cleaned=True,
),
call(
commands.UpdateDomain(
name="freeman.gov",
add=[],
rem=[common.DomainContact(contact="techContact", type="tech")],
nsset=None,
keyset=None,
registrant=None,
auth_info=None,
),
cleaned=True,
),
call(
commands.DeleteContact(id="techContact"),
cleaned=True,
),
],
any_order=True,
)
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteDomain(name="freeman.gov"),
cleaned=True,
),
],
)
# Domain itself should not be deleted
self.assertNotEqual(self.domain_with_contacts, None)
# State should have changed
self.assertEqual(self.domain_with_contacts.state, Domain.State.DELETED)
@less_console_noise_decorator
def test_deletion_ready_fsm_failure(self):
"""
Scenario: Domain deletion is unsuccessful due to FSM rules
@ -2661,15 +2787,14 @@ class TestAnalystDelete(MockEppLib):
The deleted date is still null.
"""
with less_console_noise():
self.assertEqual(self.domain.state, Domain.State.READY)
with self.assertRaises(TransitionNotAllowed) as err:
self.domain.deletedInEpp()
self.domain.save()
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION)
# Domain should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.READY)
# deleted should be null
self.assertEqual(self.domain.deleted, None)
self.assertEqual(self.domain.state, Domain.State.READY)
with self.assertRaises(TransitionNotAllowed) as err:
self.domain.deletedInEpp()
self.domain.save()
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION)
# Domain should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.READY)
# deleted should be null
self.assertEqual(self.domain.deleted, None)

View file

@ -71,8 +71,8 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open()
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
]
@ -93,8 +93,8 @@ class CsvReportsTest(MockDbForSharedTests):
fake_open = mock_open()
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain1.gov,Federal - Executive,Portfolio 1 Federal Agency,,,,(blank)\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("zdomain12.gov,Interstate,,,,,(blank)\r\n"),
@ -493,17 +493,17 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# sorted alphabetially by domain name
expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n"
"cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n"
"adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n"
"ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
"zdomain12.gov,Interstate,,,,,(blank)\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator
@ -533,16 +533,16 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# sorted alphabetially by domain name
expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"defaultsecurity.gov,Federal - Executive,Portfolio1FederalAgency,,,,(blank)\n"
"cdomain11.gov,Federal - Executive,WorldWarICentennialCommission,,,,(blank)\n"
"adomain10.gov,Federal,ArmedForcesRetirementHome,,,,(blank)\n"
"ddomain3.gov,Federal,ArmedForcesRetirementHome,,,,security@mail.gov\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator

View file

@ -2642,3 +2642,160 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)
class TestEditPortfolioMemberView(WebTest):
"""Tests for the edit member page on portfolios"""
def setUp(self):
self.user = create_user()
# Create Portfolio
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
# Add an invited member who has been invited to manage domains
self.invited_member_email = "invited@example.com"
self.invitation = PortfolioInvitation.objects.create(
email=self.invited_member_email,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
def tearDown(self):
PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_edit_member_permissions_basic_to_admin(self):
"""Tests converting a basic member to admin with full permissions."""
self.client.force_login(self.user)
# Create a basic member to edit
basic_member = create_test_user()
basic_permission = UserPortfolioPermission.objects.create(
user=basic_member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
response = self.client.post(
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
# Verify redirect and success message
self.assertEqual(response.status_code, 302)
# Verify database changes
basic_permission.refresh_from_db()
self.assertEqual(basic_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
self.assertEqual(
set(basic_permission.additional_permissions),
{
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_edit_member_permissions_validation(self):
"""Tests form validation for required fields based on role."""
self.client.force_login(self.user)
member = create_test_user()
permission = UserPortfolioPermission.objects.create(
user=member, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
# Test missing required admin permissions
response = self.client.post(
reverse("member-permissions", kwargs={"pk": permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
# Missing required admin fields
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context["form"].errors["domain_request_permission_admin"][0],
"Admin domain request permission is required",
)
self.assertEqual(
response.context["form"].errors["member_permission_admin"][0], "Admin member permission is required"
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_edit_invited_member_permissions(self):
"""Tests editing permissions for an invited (but not yet joined) member."""
self.client.force_login(self.user)
# Test updating invitation permissions
response = self.client.post(
reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.EDIT_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
self.assertEqual(response.status_code, 302)
# Verify invitation was updated
updated_invitation = PortfolioInvitation.objects.get(pk=self.invitation.id)
self.assertEqual(updated_invitation.roles, [UserPortfolioRoleChoices.ORGANIZATION_ADMIN])
self.assertEqual(
set(updated_invitation.additional_permissions),
{
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
},
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_admin_removing_own_admin_role(self):
"""Tests an admin removing their own admin role redirects to home."""
self.client.force_login(self.user)
# Get the user's admin permission
admin_permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
response = self.client.post(
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("home"))

View file

@ -744,30 +744,45 @@ class DomainExport(BaseExport):
):
security_contact_email = "(blank)"
model["status"] = human_readable_status
model["first_ready_on"] = first_ready_on
model["expiration_date"] = expiration_date
model["domain_type"] = domain_type
model["security_contact_email"] = security_contact_email
# create a dictionary of fields which can be included in output.
# "extra_fields" are precomputed fields (generated in the DB or parsed).
FIELDS = cls.get_fields(model)
row = [FIELDS.get(column, "") for column in columns]
return row
# NOTE - this override is temporary.
# We are running into a problem where DomainDataFull and DomainDataFederal are
# pulling the wrong data.
# For example, the portfolio name, rather than the suborganization name.
# This can be removed after that gets fixed.
@classmethod
def get_fields(cls, model):
FIELDS = {
"Domain name": model.get("domain__name"),
"Status": human_readable_status,
"First ready on": first_ready_on,
"Expiration date": expiration_date,
"Domain type": domain_type,
"Status": model.get("status"),
"First ready on": model.get("first_ready_on"),
"Expiration date": model.get("expiration_date"),
"Domain type": model.get("domain_type"),
"Agency": model.get("converted_federal_agency"),
"Organization name": model.get("converted_organization_name"),
"City": model.get("converted_city"),
"State": model.get("converted_state_territory"),
"SO": model.get("converted_so_name"),
"SO email": model.get("converted_so_email"),
"Security contact email": security_contact_email,
"Security contact email": model.get("security_contact_email"),
"Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"),
"Invited domain managers": model.get("invited_users"),
}
row = [FIELDS.get(column, "") for column in columns]
return row
return FIELDS
def get_filtered_domain_infos_by_org(domain_infos_to_filter, org_to_filter_by):
"""Returns a list of Domain Requests that has been filtered by the given organization value."""
@ -1077,6 +1092,39 @@ class DomainDataFull(DomainExport):
Inherits from BaseExport -> DomainExport
"""
# NOTE - this override is temporary.
# We are running into a problem where DomainDataFull is
# pulling the wrong data.
# For example, the portfolio name, rather than the suborganization name.
# This can be removed after that gets fixed.
# The following fields are changed from DomainExport:
# converted_organization_name => organization_name
# converted_city => city
# converted_state_territory => state_territory
# converted_so_name => so_name
# converted_so_email => senior_official__email
@classmethod
def get_fields(cls, model):
FIELDS = {
"Domain name": model.get("domain__name"),
"Status": model.get("status"),
"First ready on": model.get("first_ready_on"),
"Expiration date": model.get("expiration_date"),
"Domain type": model.get("domain_type"),
"Agency": model.get("federal_agency__agency"),
"Organization name": model.get("organization_name"),
"City": model.get("city"),
"State": model.get("state_territory"),
"SO": model.get("so_name"),
"SO email": model.get("senior_official__email"),
"Security contact email": model.get("security_contact_email"),
"Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"),
"Invited domain managers": model.get("invited_users"),
}
return FIELDS
@classmethod
def get_columns(cls):
"""
@ -1106,9 +1154,9 @@ class DomainDataFull(DomainExport):
"""
# Coalesce is used to replace federal_type of None with ZZZZZ
return [
"converted_generic_org_type",
Coalesce("converted_federal_type", Value("ZZZZZ")),
"converted_federal_agency",
"organization_type",
Coalesce("federal_type", Value("ZZZZZ")),
"federal_agency",
"domain__name",
]
@ -1164,6 +1212,39 @@ class DomainDataFederal(DomainExport):
Inherits from BaseExport -> DomainExport
"""
# NOTE - this override is temporary.
# We are running into a problem where DomainDataFull is
# pulling the wrong data.
# For example, the portfolio name, rather than the suborganization name.
# This can be removed after that gets fixed.
# The following fields are changed from DomainExport:
# converted_organization_name => organization_name
# converted_city => city
# converted_state_territory => state_territory
# converted_so_name => so_name
# converted_so_email => senior_official__email
@classmethod
def get_fields(cls, model):
FIELDS = {
"Domain name": model.get("domain__name"),
"Status": model.get("status"),
"First ready on": model.get("first_ready_on"),
"Expiration date": model.get("expiration_date"),
"Domain type": model.get("domain_type"),
"Agency": model.get("federal_agency__agency"),
"Organization name": model.get("organization_name"),
"City": model.get("city"),
"State": model.get("state_territory"),
"SO": model.get("so_name"),
"SO email": model.get("senior_official__email"),
"Security contact email": model.get("security_contact_email"),
"Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"),
"Invited domain managers": model.get("invited_users"),
}
return FIELDS
@classmethod
def get_columns(cls):
"""
@ -1193,9 +1274,9 @@ class DomainDataFederal(DomainExport):
"""
# Coalesce is used to replace federal_type of None with ZZZZZ
return [
"converted_generic_org_type",
Coalesce("converted_federal_type", Value("ZZZZZ")),
"converted_federal_agency",
"organization_type",
Coalesce("federal_type", Value("ZZZZZ")),
"federal_agency",
"domain__name",
]

View file

@ -6,7 +6,6 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.contrib import messages
from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User
from registrar.models.portfolio_invitation import PortfolioInvitation
@ -144,7 +143,7 @@ class PortfolioMemberDeleteView(PortfolioMemberPermission, View):
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html"
form_class = portfolioForms.PortfolioMemberForm
form_class = portfolioForms.BasePortfolioMemberForm
def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
@ -164,12 +163,17 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
def post(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
user = portfolio_permission.user
form = self.form_class(request.POST, instance=portfolio_permission)
if form.is_valid():
# Check if user is removing their own admin or edit role
removing_admin_role_on_self = (
request.user == user
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_permission.roles
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in form.cleaned_data.get("role", [])
)
form.save()
return redirect("member", pk=pk)
messages.success(self.request, "The member access and permission changes have been saved.")
return redirect("member", pk=pk) if not removing_admin_role_on_self else redirect("home")
return render(
request,
@ -278,7 +282,7 @@ class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View):
class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
template_name = "portfolio_member_permissions.html"
form_class = portfolioForms.PortfolioInvitedMemberForm
form_class = portfolioForms.BasePortfolioMemberForm
def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
@ -298,6 +302,7 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
form = self.form_class(request.POST, instance=portfolio_invitation)
if form.is_valid():
form.save()
messages.success(self.request, "The member access and permission changes have been saved.")
return redirect("invitedmember", pk=pk)
return render(

View file

@ -70,6 +70,7 @@
10038 OUTOFSCOPE http://app:8080/org-name-address
10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/
10038 OUTOFSCOPE http://app:8080/domains/edit
10038 OUTOFSCOPE http://app:8080/organization/
10038 OUTOFSCOPE http://app:8080/permissions
10038 OUTOFSCOPE http://app:8080/suborganization/