mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-23 19:20:47 +02:00
Merge branch 'main' into za/2756-edit-member-access
This commit is contained in:
commit
c5184e5b29
17 changed files with 853 additions and 333 deletions
|
@ -20,7 +20,7 @@ applications:
|
||||||
# Tell Django where it is being hosted
|
# Tell Django where it is being hosted
|
||||||
DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov
|
DJANGO_BASE_URL: https://getgov-ms.app.cloud.gov
|
||||||
# Tell Django how much stuff to log
|
# Tell Django how much stuff to log
|
||||||
DJANGO_LOG_LEVEL: INFO
|
DJANGO_LOG_LEVEL: DEBUG
|
||||||
# default public site location
|
# default public site location
|
||||||
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
GETGOV_PUBLIC_SITE_URL: https://get.gov
|
||||||
# Flag to disable/enable features in prod environments
|
# Flag to disable/enable features in prod environments
|
||||||
|
|
|
@ -62,9 +62,11 @@ class RegistryError(Exception):
|
||||||
- 2501 - 2502 Something malicious or abusive may have occurred
|
- 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)
|
super().__init__(*args, **kwargs)
|
||||||
self.code = code
|
self.code = code
|
||||||
|
# note is a string that can be used to provide additional context
|
||||||
|
self.note = note
|
||||||
|
|
||||||
def should_retry(self):
|
def should_retry(self):
|
||||||
return self.code == ErrorCode.COMMAND_FAILED
|
return self.code == ErrorCode.COMMAND_FAILED
|
||||||
|
|
|
@ -220,6 +220,14 @@ class DomainInformationAdminForm(forms.ModelForm):
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
widgets = {
|
widgets = {
|
||||||
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
|
"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__"
|
fields = "__all__"
|
||||||
widgets = {
|
widgets = {
|
||||||
"other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False),
|
"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")]
|
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
|
# Filters
|
||||||
list_filter = [GenericOrgFilter]
|
list_filter = [GenericOrgFilter]
|
||||||
|
|
||||||
|
@ -1537,16 +1617,36 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
"fields": [
|
"fields": [
|
||||||
"portfolio",
|
|
||||||
"sub_organization",
|
|
||||||
"creator",
|
|
||||||
"domain_request",
|
"domain_request",
|
||||||
"notes",
|
"notes",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"Requested by",
|
||||||
|
{
|
||||||
|
"fields": [
|
||||||
|
"portfolio",
|
||||||
|
"sub_organization",
|
||||||
|
"creator",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
(".gov domain", {"fields": ["domain"]}),
|
(".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"]}),
|
("Background info", {"fields": ["anything_else"]}),
|
||||||
(
|
(
|
||||||
"Type of organization",
|
"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 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
|
# Read only that we'll leverage for CISA Analysts
|
||||||
analyst_readonly_fields = [
|
analyst_readonly_fields = [
|
||||||
|
@ -2575,7 +2723,72 @@ class DomainInformationInline(admin.StackedInline):
|
||||||
template = "django/admin/includes/domain_info_inline_stacked.html"
|
template = "django/admin/includes/domain_info_inline_stacked.html"
|
||||||
model = models.DomainInformation
|
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))
|
fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets))
|
||||||
|
readonly_fields = copy.deepcopy(DomainInformationAdmin.readonly_fields)
|
||||||
analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields)
|
analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields)
|
||||||
autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields)
|
autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields)
|
||||||
|
|
||||||
|
@ -3121,7 +3334,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
||||||
except RegistryError as err:
|
except RegistryError as err:
|
||||||
# Using variables to get past the linter
|
# Using variables to get past the linter
|
||||||
message1 = f"Cannot delete Domain when in state {obj.state}"
|
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.
|
# Human-readable mappings of ErrorCodes. Can be expanded.
|
||||||
error_messages = {
|
error_messages = {
|
||||||
# noqa on these items as black wants to reformat to an invalid length
|
# noqa on these items as black wants to reformat to an invalid length
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A function that appends target="_blank" to the domain_form buttons
|
* A function that appends target="_blank" to the domain_form buttons
|
||||||
*/
|
*/
|
||||||
|
@ -28,3 +30,14 @@ export function initDomainFormTargetBlankButtons() {
|
||||||
domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false));
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
* A function for dynamic DomainInformation fields
|
||||||
|
@ -6,12 +6,7 @@ import { handleSuborganizationFields } from './helpers-portfolio-dynamic-fields.
|
||||||
export function initDynamicDomainInformationFields(){
|
export function initDynamicDomainInformationFields(){
|
||||||
const domainInformationPage = document.getElementById("domaininformation_form");
|
const domainInformationPage = document.getElementById("domaininformation_form");
|
||||||
if (domainInformationPage) {
|
if (domainInformationPage) {
|
||||||
handleSuborganizationFields();
|
console.log("handling domain information page");
|
||||||
}
|
handlePortfolioSelection();
|
||||||
|
|
||||||
// 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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,57 +1,19 @@
|
||||||
import { hideElement, showElement } from './helpers-admin.js';
|
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
|
* This function handles the portfolio selection as well as display of
|
||||||
* portfolio-related fields in the DomainRequest Form.
|
* 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
|
// These dropdown are select2 fields so they must be interacted with via jquery
|
||||||
const portfolioDropdown = django.jQuery("#id_portfolio");
|
const portfolioDropdown = django.jQuery(portfolioDropdownSelector);
|
||||||
const suborganizationDropdown = django.jQuery("#id_sub_organization");
|
const suborganizationDropdown = django.jQuery(suborgDropdownSelector);
|
||||||
const suborganizationField = document.querySelector(".field-sub_organization");
|
const suborganizationField = document.querySelector(".field-sub_organization");
|
||||||
const requestedSuborganizationField = document.querySelector(".field-requested_suborganization");
|
const requestedSuborganizationField = document.querySelector(".field-requested_suborganization");
|
||||||
const suborganizationCity = document.querySelector(".field-suborganization_city");
|
const suborganizationCity = document.querySelector(".field-suborganization_city");
|
||||||
|
@ -440,8 +402,8 @@ export function handlePortfolioSelection() {
|
||||||
showElement(portfolioSeniorOfficialField);
|
showElement(portfolioSeniorOfficialField);
|
||||||
|
|
||||||
// Hide fields not applicable when a portfolio is selected
|
// Hide fields not applicable when a portfolio is selected
|
||||||
hideElement(otherEmployeesField);
|
if (otherEmployeesField) hideElement(otherEmployeesField);
|
||||||
hideElement(noOtherContactsRationaleField);
|
if (noOtherContactsRationaleField) hideElement(noOtherContactsRationaleField);
|
||||||
hideElement(cisaRepresentativeFirstNameField);
|
hideElement(cisaRepresentativeFirstNameField);
|
||||||
hideElement(cisaRepresentativeLastNameField);
|
hideElement(cisaRepresentativeLastNameField);
|
||||||
hideElement(cisaRepresentativeEmailField);
|
hideElement(cisaRepresentativeEmailField);
|
||||||
|
@ -463,8 +425,8 @@ export function handlePortfolioSelection() {
|
||||||
// Show fields that are relevant when no portfolio is selected
|
// Show fields that are relevant when no portfolio is selected
|
||||||
showElement(seniorOfficialField);
|
showElement(seniorOfficialField);
|
||||||
hideElement(portfolioSeniorOfficialField);
|
hideElement(portfolioSeniorOfficialField);
|
||||||
showElement(otherEmployeesField);
|
if (otherEmployeesField) showElement(otherEmployeesField);
|
||||||
showElement(noOtherContactsRationaleField);
|
if (noOtherContactsRationaleField) showElement(noOtherContactsRationaleField);
|
||||||
showElement(cisaRepresentativeFirstNameField);
|
showElement(cisaRepresentativeFirstNameField);
|
||||||
showElement(cisaRepresentativeLastNameField);
|
showElement(cisaRepresentativeLastNameField);
|
||||||
showElement(cisaRepresentativeEmailField);
|
showElement(cisaRepresentativeEmailField);
|
||||||
|
@ -504,14 +466,14 @@ export function handlePortfolioSelection() {
|
||||||
|
|
||||||
if (portfolio_id && !suborganization_id) {
|
if (portfolio_id && !suborganization_id) {
|
||||||
// Show suborganization request fields
|
// Show suborganization request fields
|
||||||
showElement(requestedSuborganizationField);
|
if (requestedSuborganizationField) showElement(requestedSuborganizationField);
|
||||||
showElement(suborganizationCity);
|
if (suborganizationCity) showElement(suborganizationCity);
|
||||||
showElement(suborganizationStateTerritory);
|
if (suborganizationStateTerritory) showElement(suborganizationStateTerritory);
|
||||||
} else {
|
} else {
|
||||||
// Hide suborganization request fields if suborganization is selected
|
// Hide suborganization request fields if suborganization is selected
|
||||||
hideElement(requestedSuborganizationField);
|
if (requestedSuborganizationField) hideElement(requestedSuborganizationField);
|
||||||
hideElement(suborganizationCity);
|
if (suborganizationCity) hideElement(suborganizationCity);
|
||||||
hideElement(suborganizationStateTerritory);
|
if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import { initDomainFormTargetBlankButtons } from './domain-form.js';
|
import { initDomainFormTargetBlankButtons } from './domain-form.js';
|
||||||
import { initDynamicPortfolioFields } from './portfolio-form.js';
|
import { initDynamicPortfolioFields } from './portfolio-form.js';
|
||||||
import { initDynamicDomainInformationFields } from './domain-information-form.js';
|
import { initDynamicDomainInformationFields } from './domain-information-form.js';
|
||||||
|
import { initDynamicDomainFields } from './domain-form.js';
|
||||||
|
|
||||||
// General
|
// General
|
||||||
initModals();
|
initModals();
|
||||||
|
@ -33,6 +34,7 @@ initDynamicDomainRequestFields();
|
||||||
|
|
||||||
// Domain
|
// Domain
|
||||||
initDomainFormTargetBlankButtons();
|
initDomainFormTargetBlankButtons();
|
||||||
|
initDynamicDomainFields();
|
||||||
|
|
||||||
// Portfolio
|
// Portfolio
|
||||||
initDynamicPortfolioFields();
|
initDynamicPortfolioFields();
|
||||||
|
|
|
@ -2,203 +2,212 @@ import { hideElement, showElement } from './helpers-admin.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A function for dynamically changing some fields on the portfolio admin model
|
* 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.
|
let isPageLoading = true
|
||||||
var isInitialPageLoad = 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");
|
* Fetches federal type data based on a selected agency using an AJAX call.
|
||||||
const federalAgencyContainer = document.querySelector(".field-federal_agency");
|
*
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
* @param {string} agency
|
||||||
|
* @returns {Promise<Object|null>} - A promise that resolves to the portfolio data object if successful,
|
||||||
let isPortfolioPage = document.getElementById("portfolio_form");
|
* or null if there was an error.
|
||||||
if (!isPortfolioPage) {
|
*/
|
||||||
return;
|
function getFederalTypeFromAgency(agency) {
|
||||||
}
|
return fetch(`${federalPortfolioApi}?&agency_name=${agency}`)
|
||||||
|
.then(response => {
|
||||||
// $ symbolically denotes that this is using jQuery
|
const statusCode = response.status;
|
||||||
let $federalAgency = django.jQuery("#id_federal_agency");
|
return response.json().then(data => ({ statusCode, data }));
|
||||||
let organizationType = document.getElementById("id_organization_type");
|
})
|
||||||
let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly");
|
.then(({ statusCode, data }) => {
|
||||||
|
if (data.error) {
|
||||||
let organizationNameContainer = document.querySelector(".field-organization_name");
|
console.error("Error in AJAX call: " + data.error);
|
||||||
let federalType = document.querySelector(".field-federal_type");
|
return;
|
||||||
|
}
|
||||||
if ($federalAgency && (organizationType || readonlyOrganizationType)) {
|
return data.federal_type
|
||||||
// Attach the change event listener
|
})
|
||||||
$federalAgency.on("change", function() {
|
.catch(error => {
|
||||||
handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType);
|
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() {
|
* Fetches senior official contact data based on a selected agency using an AJAX call.
|
||||||
handleStateTerritoryChange(stateTerritory, urbanizationField);
|
*
|
||||||
|
* @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.
|
* Handles the side effects of change on the organization type field
|
||||||
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
|
*
|
||||||
organizationType.addEventListener("change", function() {
|
* 1. If selection is federal, hide org name, show federal agency, show federal type if applicable
|
||||||
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
|
* 2. else show org name, hide federal agency, hide federal type if applicable
|
||||||
});
|
*/
|
||||||
});
|
function handleOrganizationTypeChange() {
|
||||||
|
if (organizationTypeDropdown && organizationNameField) {
|
||||||
function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) {
|
let selectedValue = organizationTypeDropdown.value;
|
||||||
if (organizationType && organizationNameContainer) {
|
|
||||||
let selectedValue = organizationType.value;
|
|
||||||
if (selectedValue === "federal") {
|
if (selectedValue === "federal") {
|
||||||
hideElement(organizationNameContainer);
|
hideElement(organizationNameField);
|
||||||
showElement(federalAgencyContainer);
|
showElement(federalAgencyField);
|
||||||
if (federalType) {
|
if (federalTypeField) {
|
||||||
showElement(federalType);
|
showElement(federalTypeField);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showElement(organizationNameContainer);
|
showElement(organizationNameField);
|
||||||
hideElement(federalAgencyContainer);
|
hideElement(federalAgencyField);
|
||||||
if (federalType) {
|
if (federalTypeField) {
|
||||||
hideElement(federalType);
|
hideElement(federalTypeField);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) {
|
/**
|
||||||
// Don't do anything on page load
|
* Handles the side effects of change on the federal agency field
|
||||||
if (isInitialPageLoad) {
|
*
|
||||||
isInitialPageLoad = false;
|
* 1. handle org type dropdown or readonly
|
||||||
return;
|
* 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 selectedFederalAgency = $federalAgencyDropdown.find("option:selected").text();
|
||||||
let selectedText = federalAgency.find("option:selected").text();
|
if (!selectedFederalAgency) {
|
||||||
|
|
||||||
// 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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateReadOnly(data.federal_type, '.field-federal_type');
|
|
||||||
})
|
|
||||||
.catch(error => console.error("Error fetching federal and portfolio types: ", error));
|
|
||||||
|
|
||||||
// Hide the contactList initially.
|
// 1. Handle organization type
|
||||||
// If we can update the contact information, it'll be shown again.
|
let organizationTypeValue = organizationTypeDropdown ? organizationTypeDropdown.value : organizationTypeReadonly.innerText.toLowerCase();
|
||||||
hideElement(contactList.parentElement);
|
if (selectedFederalAgency !== "Non-Federal Agency") {
|
||||||
|
if (organizationTypeValue !== "federal") {
|
||||||
let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value;
|
if (organizationTypeDropdown){
|
||||||
let $seniorOfficial = django.jQuery("#id_senior_official");
|
organizationTypeDropdown.value = "federal";
|
||||||
let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly");
|
} else {
|
||||||
let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value;
|
organizationTypeReadonly.innerText = "Federal"
|
||||||
fetch(`${seniorOfficialApi}?agency_name=${selectedText}`)
|
}
|
||||||
.then(response => {
|
}
|
||||||
const statusCode = response.status;
|
} else {
|
||||||
return response.json().then(data => ({ statusCode, data }));
|
if (organizationTypeValue === "federal") {
|
||||||
})
|
if (organizationTypeDropdown){
|
||||||
.then(({ statusCode, data }) => {
|
organizationTypeDropdown.value = "";
|
||||||
if (data.error) {
|
} else {
|
||||||
// Clear the field if the SO doesn't exist.
|
organizationTypeReadonly.innerText = "-"
|
||||||
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>`;
|
|
||||||
}
|
}
|
||||||
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
|
// 2. Handle organization type change side effects
|
||||||
updateContactInfo(data);
|
handleOrganizationTypeChange();
|
||||||
showElement(contactList.parentElement);
|
|
||||||
|
// 3. Handle federal type
|
||||||
|
getFederalTypeFromAgency(selectedFederalAgency).then((federalType) => updateReadOnly(federalType, '.field-federal_type'));
|
||||||
|
|
||||||
// Get the associated senior official with this federal agency
|
// 4. Handle senior official
|
||||||
let seniorOfficialId = data.id;
|
hideElement(seniorOfficialAddress.parentElement);
|
||||||
let seniorOfficialName = [data.first_name, data.last_name].join(" ");
|
getSeniorOfficialFromAgency(selectedFederalAgency).then((senior_official) => {
|
||||||
if ($seniorOfficial && $seniorOfficial.length > 0) {
|
// Update the "contact details" blurb beneath senior official
|
||||||
// If the senior official is a dropdown field, edit that
|
updateSeniorOfficialContactInfo(senior_official);
|
||||||
updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName);
|
showElement(seniorOfficialAddress.parentElement);
|
||||||
}else {
|
// Get the associated senior official with this federal agency
|
||||||
if (readonlySeniorOfficial) {
|
let seniorOfficialId = senior_official.id;
|
||||||
let seniorOfficialLink = `<a href=/admin/registrar/seniorofficial/${seniorOfficialId}/change/>${seniorOfficialName}</a>`
|
let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(" ");
|
||||||
readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-";
|
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 => {
|
||||||
.catch(error => console.error("Error fetching senior official: ", 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()){
|
if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){
|
||||||
// Clear the field if the SO doesn't exist
|
// Clear the field if the SO doesn't exist
|
||||||
dropdown.val("").trigger("change");
|
$seniorOfficialDropdown.val("").trigger("change");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the senior official to the dropdown.
|
// Add the senior official to the dropdown.
|
||||||
// This format supports select2 - if we decide to convert this field in the future.
|
// 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.
|
// Select the value that is associated with the current Senior Official.
|
||||||
dropdown.val(seniorOfficialId).trigger("change");
|
$seniorOfficialDropdown.val(seniorOfficialId).trigger("change");
|
||||||
} else {
|
} else {
|
||||||
// Create a DOM Option that matches the desired Senior Official. Then append it and select it.
|
// Create a DOM Option that matches the desired Senior Official. Then append it and select it.
|
||||||
let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true);
|
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") {
|
if (selectedValue === "PR") {
|
||||||
showElement(urbanizationField)
|
showElement(urbanizationField)
|
||||||
} else {
|
} else {
|
||||||
|
@ -207,11 +216,7 @@ export function initDynamicPortfolioFields(){
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility that selects a div from the DOM using selectorString,
|
* Helper for updating senior official dropdown
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
function updateReadOnly(updateText, selectorString) {
|
function updateReadOnly(updateText, selectorString) {
|
||||||
// find the div by selectorString
|
// find the div by selectorString
|
||||||
|
@ -226,34 +231,75 @@ export function initDynamicPortfolioFields(){
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateContactInfo(data) {
|
/**
|
||||||
if (!contactList) return;
|
* Helper for updating senior official contact info
|
||||||
|
*/
|
||||||
const titleSpan = contactList.querySelector(".contact_info_title");
|
function updateSeniorOfficialContactInfo(senior_official) {
|
||||||
const emailSpan = contactList.querySelector(".contact_info_email");
|
if (!seniorOfficialAddress) return;
|
||||||
const phoneSpan = contactList.querySelector(".contact_info_phone");
|
const titleSpan = seniorOfficialAddress.querySelector(".contact_info_title");
|
||||||
|
const emailSpan = seniorOfficialAddress.querySelector(".contact_info_email");
|
||||||
|
const phoneSpan = seniorOfficialAddress.querySelector(".contact_info_phone");
|
||||||
if (titleSpan) {
|
if (titleSpan) {
|
||||||
titleSpan.textContent = data.title || "None";
|
titleSpan.textContent = senior_official.title || "None";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the email field and the content for the clipboard
|
// Update the email field and the content for the clipboard
|
||||||
if (emailSpan) {
|
if (emailSpan) {
|
||||||
let copyButton = contactList.querySelector(".admin-icon-group");
|
let copyButton = seniorOfficialAddress.querySelector(".admin-icon-group");
|
||||||
emailSpan.textContent = data.email || "None";
|
emailSpan.textContent = senior_official.email || "None";
|
||||||
if (data.email) {
|
if (senior_official.email) {
|
||||||
const clipboardInput = contactList.querySelector(".admin-icon-group input");
|
const clipboardInput = seniorOfficialAddress.querySelector(".admin-icon-group input");
|
||||||
if (clipboardInput) {
|
if (clipboardInput) {
|
||||||
clipboardInput.value = data.email;
|
clipboardInput.value = senior_official.email;
|
||||||
};
|
};
|
||||||
showElement(copyButton);
|
showElement(copyButton);
|
||||||
}else {
|
}else {
|
||||||
hideElement(copyButton);
|
hideElement(copyButton);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phoneSpan) {
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -230,6 +230,12 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
"""Called during delete. Example: `del domain.registrant`."""
|
"""Called during delete. Example: `del domain.registrant`."""
|
||||||
super().__delete__(obj)
|
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
|
@classmethod
|
||||||
def available(cls, domain: str) -> bool:
|
def available(cls, domain: str) -> bool:
|
||||||
"""Check if a domain is available.
|
"""Check if a domain is available.
|
||||||
|
@ -253,7 +259,7 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
return not cls.available(domain)
|
return not cls.available(domain)
|
||||||
|
|
||||||
@Cache
|
@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.
|
Get a dictionary of registry IDs for the contacts for this domain.
|
||||||
|
|
||||||
|
@ -706,7 +712,7 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
@nameservers.setter # type: ignore
|
@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
|
"""Host should be a tuple of type str, str,... where the elements are
|
||||||
Fully qualified host name, addresses associated with the host
|
Fully qualified host name, addresses associated with the host
|
||||||
example: [(ns1.okay.gov, [127.0.0.1, others ips])]"""
|
example: [(ns1.okay.gov, [127.0.0.1, others ips])]"""
|
||||||
|
@ -743,7 +749,12 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
|
|
||||||
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
|
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:
|
if successTotalNameservers < 2:
|
||||||
try:
|
try:
|
||||||
self.dns_needed()
|
self.dns_needed()
|
||||||
|
@ -1029,6 +1040,47 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
def _delete_domain(self):
|
def _delete_domain(self):
|
||||||
"""This domain should be deleted from the registry
|
"""This domain should be deleted from the registry
|
||||||
may raises RegistryError, should be caught or handled correctly by caller"""
|
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)
|
request = commands.DeleteDomain(name=self.name)
|
||||||
registry.send(request, cleaned=True)
|
registry.send(request, cleaned=True)
|
||||||
|
|
||||||
|
@ -1096,7 +1148,7 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
Returns True if expired, False otherwise.
|
Returns True if expired, False otherwise.
|
||||||
"""
|
"""
|
||||||
if self.expiration_date is None:
|
if self.expiration_date is None:
|
||||||
return True
|
return self.state != self.State.DELETED
|
||||||
now = timezone.now().date()
|
now = timezone.now().date()
|
||||||
return self.expiration_date < now
|
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)
|
@transition(field="state", source=[State.ON_HOLD, State.DNS_NEEDED], target=State.DELETED)
|
||||||
def deletedInEpp(self):
|
def deletedInEpp(self):
|
||||||
"""Domain is deleted in epp but is saved in our database.
|
"""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."""
|
Error handling should be provided by the caller."""
|
||||||
# While we want to log errors, we want to preserve
|
# While we want to log errors, we want to preserve
|
||||||
# that information when this function is called.
|
# that information when this function is called.
|
||||||
|
@ -1439,8 +1493,9 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
logger.info("deletedInEpp()-> inside _delete_domain")
|
logger.info("deletedInEpp()-> inside _delete_domain")
|
||||||
self._delete_domain()
|
self._delete_domain()
|
||||||
self.deleted = timezone.now()
|
self.deleted = timezone.now()
|
||||||
|
self.expiration_date = None
|
||||||
except RegistryError as err:
|
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
|
raise err
|
||||||
except TransitionNotAllowed as err:
|
except TransitionNotAllowed as err:
|
||||||
logger.error("Could not delete domain. FSM failure: {err}")
|
logger.error("Could not delete domain. FSM failure: {err}")
|
||||||
|
@ -1745,7 +1800,6 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
"""delete the host object in registry,
|
"""delete the host object in registry,
|
||||||
will only delete the host object, if it's not being used by another domain
|
will only delete the host object, if it's not being used by another domain
|
||||||
Performs just the DeleteHost epp call
|
Performs just the DeleteHost epp call
|
||||||
Supresses regstry error, as registry can disallow delete for various reasons
|
|
||||||
Args:
|
Args:
|
||||||
hostsToDelete (list[str])- list of nameserver/host names to remove
|
hostsToDelete (list[str])- list of nameserver/host names to remove
|
||||||
Returns:
|
Returns:
|
||||||
|
@ -1764,6 +1818,8 @@ class Domain(TimeStampedModel, DomainHelper):
|
||||||
else:
|
else:
|
||||||
logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e))
|
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):
|
def _fix_unknown_state(self, cleaned):
|
||||||
"""
|
"""
|
||||||
_fix_unknown_state: Calls _add_missing_contacts_if_unknown
|
_fix_unknown_state: Calls _add_missing_contacts_if_unknown
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
{% extends 'admin/change_form.html' %}
|
{% extends 'admin/change_form.html' %}
|
||||||
{% load i18n static %}
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||||
|
{% url 'get-portfolio-json' as url %}
|
||||||
|
<input id="portfolio_json_url" class="display-none" value="{{url}}" />
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
{% block field_sets %}
|
{% block field_sets %}
|
||||||
<div class="display-flex flex-row flex-justify submit-row">
|
<div class="display-flex flex-row flex-justify submit-row">
|
||||||
<div class="flex-align-self-start button-list-mobile">
|
<div class="flex-align-self-start button-list-mobile">
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
{% extends 'admin/change_form.html' %}
|
{% extends 'admin/change_form.html' %}
|
||||||
{% load i18n static %}
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||||
|
{% url 'get-portfolio-json' as url %}
|
||||||
|
<input id="portfolio_json_url" class="display-none" value="{{url}}" />
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
{% block field_sets %}
|
{% block field_sets %}
|
||||||
{% for fieldset in adminform %}
|
{% for fieldset in adminform %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
|
|
|
@ -26,13 +26,17 @@
|
||||||
<h2>Next steps in this process</h2>
|
<h2>Next steps in this process</h2>
|
||||||
|
|
||||||
<p> We’ll 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> We’ll 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 we’ll verify that:</p>
|
{% if has_organization_feature_flag %}
|
||||||
<ul class="usa-list">
|
<p>During our review, we’ll verify that your requested domain meets our naming requirements.</p>
|
||||||
<li>Your organization is eligible for a .gov domain.</li>
|
{% else %}
|
||||||
<li>You work at the organization and/or can make requests on its behalf.</li>
|
<p>During our review, we’ll verify that:</p>
|
||||||
<li>Your requested domain meets our naming requirements.</li>
|
<ul class="usa-list">
|
||||||
</ul>
|
<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> We’ll email you if we have questions. We’ll 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>
|
<p> We’ll email you if we have questions. We’ll 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>
|
of your request at any time on the registrar.</p>
|
||||||
|
|
|
@ -12,12 +12,12 @@ STATUS: Submitted
|
||||||
NEXT STEPS
|
NEXT STEPS
|
||||||
We’ll 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.
|
We’ll 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 we’ll verify that:
|
During our review, we’ll verify that:
|
||||||
- Your organization is eligible for a .gov domain
|
- Your organization is eligible for a .gov domain
|
||||||
- You work at the organization and/or can make requests on its behalf
|
- You work at the organization and/or can make requests on its behalf
|
||||||
- Your requested domain meets our naming requirements
|
- Your requested domain meets our naming requirements
|
||||||
|
|
||||||
We’ll email you if we have questions. We’ll 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>
|
We’ll email you if we have questions. We’ll 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?
|
NEED TO MAKE CHANGES?
|
||||||
|
|
|
@ -1232,6 +1232,7 @@ class MockEppLib(TestCase):
|
||||||
common.Status(state="serverTransferProhibited", description="", lang="en"),
|
common.Status(state="serverTransferProhibited", description="", lang="en"),
|
||||||
common.Status(state="inactive", description="", lang="en"),
|
common.Status(state="inactive", description="", lang="en"),
|
||||||
],
|
],
|
||||||
|
registrant="regContact",
|
||||||
ex_date=date(2023, 5, 25),
|
ex_date=date(2023, 5, 25),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1394,6 +1395,15 @@ class MockEppLib(TestCase):
|
||||||
hosts=["fake.host.com"],
|
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(
|
infoDomainThreeHosts = fakedEppObject(
|
||||||
"my-nameserver.gov",
|
"my-nameserver.gov",
|
||||||
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
|
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
|
||||||
|
@ -1604,6 +1614,8 @@ class MockEppLib(TestCase):
|
||||||
return self.mockInfoContactCommands(_request, cleaned)
|
return self.mockInfoContactCommands(_request, cleaned)
|
||||||
case commands.CreateContact:
|
case commands.CreateContact:
|
||||||
return self.mockCreateContactCommands(_request, cleaned)
|
return self.mockCreateContactCommands(_request, cleaned)
|
||||||
|
case commands.DeleteContact:
|
||||||
|
return self.mockDeleteContactCommands(_request, cleaned)
|
||||||
case commands.UpdateDomain:
|
case commands.UpdateDomain:
|
||||||
return self.mockUpdateDomainCommands(_request, cleaned)
|
return self.mockUpdateDomainCommands(_request, cleaned)
|
||||||
case commands.CreateHost:
|
case commands.CreateHost:
|
||||||
|
@ -1611,10 +1623,7 @@ class MockEppLib(TestCase):
|
||||||
case commands.UpdateHost:
|
case commands.UpdateHost:
|
||||||
return self.mockUpdateHostCommands(_request, cleaned)
|
return self.mockUpdateHostCommands(_request, cleaned)
|
||||||
case commands.DeleteHost:
|
case commands.DeleteHost:
|
||||||
return MagicMock(
|
return self.mockDeleteHostCommands(_request, cleaned)
|
||||||
res_data=[self.mockDataHostChange],
|
|
||||||
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
|
|
||||||
)
|
|
||||||
case commands.CheckDomain:
|
case commands.CheckDomain:
|
||||||
return self.mockCheckDomainCommand(_request, cleaned)
|
return self.mockCheckDomainCommand(_request, cleaned)
|
||||||
case commands.DeleteDomain:
|
case commands.DeleteDomain:
|
||||||
|
@ -1667,6 +1676,15 @@ class MockEppLib(TestCase):
|
||||||
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
|
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):
|
def mockUpdateDomainCommands(self, _request, cleaned):
|
||||||
if getattr(_request, "name", None) == "dnssec-invalid.gov":
|
if getattr(_request, "name", None) == "dnssec-invalid.gov":
|
||||||
raise RegistryError(code=ErrorCode.PARAMETER_VALUE_RANGE_ERROR)
|
raise RegistryError(code=ErrorCode.PARAMETER_VALUE_RANGE_ERROR)
|
||||||
|
@ -1678,10 +1696,7 @@ class MockEppLib(TestCase):
|
||||||
|
|
||||||
def mockDeleteDomainCommands(self, _request, cleaned):
|
def mockDeleteDomainCommands(self, _request, cleaned):
|
||||||
if getattr(_request, "name", None) == "failDelete.gov":
|
if getattr(_request, "name", None) == "failDelete.gov":
|
||||||
name = getattr(_request, "name", None)
|
raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
|
||||||
fake_nameserver = "ns1.failDelete.gov"
|
|
||||||
if name in fake_nameserver:
|
|
||||||
raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def mockRenewDomainCommand(self, _request, cleaned):
|
def mockRenewDomainCommand(self, _request, cleaned):
|
||||||
|
@ -1721,6 +1736,7 @@ class MockEppLib(TestCase):
|
||||||
|
|
||||||
# Define a dictionary to map request names to data and extension values
|
# Define a dictionary to map request names to data and extension values
|
||||||
request_mappings = {
|
request_mappings = {
|
||||||
|
"fake.gov": (self.mockDataInfoDomain, None),
|
||||||
"security.gov": (self.infoDomainNoContact, None),
|
"security.gov": (self.infoDomainNoContact, None),
|
||||||
"dnssec-dsdata.gov": (
|
"dnssec-dsdata.gov": (
|
||||||
self.mockDataInfoDomain,
|
self.mockDataInfoDomain,
|
||||||
|
@ -1751,6 +1767,7 @@ class MockEppLib(TestCase):
|
||||||
"subdomainwoip.gov": (self.mockDataInfoDomainSubdomainNoIP, None),
|
"subdomainwoip.gov": (self.mockDataInfoDomainSubdomainNoIP, None),
|
||||||
"ddomain3.gov": (self.InfoDomainWithContacts, None),
|
"ddomain3.gov": (self.InfoDomainWithContacts, None),
|
||||||
"igorville.gov": (self.InfoDomainWithContacts, None),
|
"igorville.gov": (self.InfoDomainWithContacts, None),
|
||||||
|
"sharingiscaring.gov": (self.infoDomainSharedHost, None),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Retrieve the corresponding values from the dictionary
|
# Retrieve the corresponding values from the dictionary
|
||||||
|
@ -1801,6 +1818,15 @@ class MockEppLib(TestCase):
|
||||||
raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE)
|
raise ContactError(code=ContactErrorCodes.CONTACT_TYPE_NONE)
|
||||||
return MagicMock(res_data=[self.mockDataInfoHosts])
|
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):
|
def setUp(self):
|
||||||
"""mock epp send function as this will fail locally"""
|
"""mock epp send function as this will fail locally"""
|
||||||
self.mockSendPatch = patch("registrar.models.domain.registry.send")
|
self.mockSendPatch = patch("registrar.models.domain.registry.send")
|
||||||
|
|
|
@ -853,9 +853,9 @@ class TestDomainInformationAdmin(TestCase):
|
||||||
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
|
||||||
|
|
||||||
# Test for the copy link
|
# Test for the copy link
|
||||||
# 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
|
# 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
|
# cleanup this test
|
||||||
domain_info.delete()
|
domain_info.delete()
|
||||||
|
@ -871,6 +871,17 @@ class TestDomainInformationAdmin(TestCase):
|
||||||
readonly_fields = self.admin.get_readonly_fields(request)
|
readonly_fields = self.admin.get_readonly_fields(request)
|
||||||
|
|
||||||
expected_fields = [
|
expected_fields = [
|
||||||
|
"portfolio_senior_official",
|
||||||
|
"portfolio_organization_type",
|
||||||
|
"portfolio_federal_type",
|
||||||
|
"portfolio_organization_name",
|
||||||
|
"portfolio_federal_agency",
|
||||||
|
"portfolio_state_territory",
|
||||||
|
"portfolio_address_line1",
|
||||||
|
"portfolio_address_line2",
|
||||||
|
"portfolio_city",
|
||||||
|
"portfolio_zipcode",
|
||||||
|
"portfolio_urbanization",
|
||||||
"other_contacts",
|
"other_contacts",
|
||||||
"is_election_board",
|
"is_election_board",
|
||||||
"federal_agency",
|
"federal_agency",
|
||||||
|
|
|
@ -16,6 +16,7 @@ from registrar.models import (
|
||||||
Host,
|
Host,
|
||||||
Portfolio,
|
Portfolio,
|
||||||
)
|
)
|
||||||
|
from registrar.models.public_contact import PublicContact
|
||||||
from registrar.models.user_domain_role import UserDomainRole
|
from registrar.models.user_domain_role import UserDomainRole
|
||||||
from .common import (
|
from .common import (
|
||||||
MockSESClient,
|
MockSESClient,
|
||||||
|
@ -59,6 +60,7 @@ class TestDomainAdminAsStaff(MockEppLib):
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
Host.objects.all().delete()
|
Host.objects.all().delete()
|
||||||
|
PublicContact.objects.all().delete()
|
||||||
Domain.objects.all().delete()
|
Domain.objects.all().delete()
|
||||||
DomainInformation.objects.all().delete()
|
DomainInformation.objects.all().delete()
|
||||||
DomainRequest.objects.all().delete()
|
DomainRequest.objects.all().delete()
|
||||||
|
@ -170,7 +172,7 @@ class TestDomainAdminAsStaff(MockEppLib):
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
def test_deletion_is_successful(self):
|
def test_deletion_is_successful(self):
|
||||||
"""
|
"""
|
||||||
Scenario: Domain deletion is unsuccessful
|
Scenario: Domain deletion is successful
|
||||||
When the domain is deleted
|
When the domain is deleted
|
||||||
Then a user-friendly success message is returned for displaying on the web
|
Then a user-friendly success message is returned for displaying on the web
|
||||||
And `state` is set to `DELETED`
|
And `state` is set to `DELETED`
|
||||||
|
@ -221,6 +223,55 @@ class TestDomainAdminAsStaff(MockEppLib):
|
||||||
|
|
||||||
self.assertEqual(domain.state, Domain.State.DELETED)
|
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
|
@less_console_noise_decorator
|
||||||
def test_deletion_ready_fsm_failure(self):
|
def test_deletion_ready_fsm_failure(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -9,6 +9,7 @@ from django.db.utils import IntegrityError
|
||||||
from unittest.mock import MagicMock, patch, call
|
from unittest.mock import MagicMock, patch, call
|
||||||
import datetime
|
import datetime
|
||||||
from django.utils.timezone import make_aware
|
from django.utils.timezone import make_aware
|
||||||
|
from api.tests.common import less_console_noise_decorator
|
||||||
from registrar.models import Domain, Host, HostIP
|
from registrar.models import Domain, Host, HostIP
|
||||||
|
|
||||||
from unittest import skip
|
from unittest import skip
|
||||||
|
@ -1454,6 +1455,7 @@ class TestRegistrantNameservers(MockEppLib):
|
||||||
),
|
),
|
||||||
call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), cleaned=True),
|
call(commands.DeleteHost(name="ns1.cats-are-superior3.com"), cleaned=True),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True)
|
self.mockedSendFunction.assert_has_calls(expectedCalls, any_order=True)
|
||||||
self.assertFalse(self.domainWithThreeNS.is_active())
|
self.assertFalse(self.domainWithThreeNS.is_active())
|
||||||
|
|
||||||
|
@ -2582,12 +2584,32 @@ class TestAnalystDelete(MockEppLib):
|
||||||
"""
|
"""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
|
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)
|
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):
|
def tearDown(self):
|
||||||
|
Host.objects.all().delete()
|
||||||
|
PublicContact.objects.all().delete()
|
||||||
Domain.objects.all().delete()
|
Domain.objects.all().delete()
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
def test_analyst_deletes_domain(self):
|
def test_analyst_deletes_domain(self):
|
||||||
"""
|
"""
|
||||||
Scenario: Analyst permanently deletes a domain
|
Scenario: Analyst permanently deletes a domain
|
||||||
|
@ -2597,59 +2619,163 @@ class TestAnalystDelete(MockEppLib):
|
||||||
|
|
||||||
The deleted date is set.
|
The deleted date is set.
|
||||||
"""
|
"""
|
||||||
with less_console_noise():
|
# Put the domain in client hold
|
||||||
# Put the domain in client hold
|
self.domain.place_client_hold()
|
||||||
self.domain.place_client_hold()
|
# Delete it...
|
||||||
# Delete it...
|
self.domain.deletedInEpp()
|
||||||
self.domain.deletedInEpp()
|
self.domain.save()
|
||||||
self.domain.save()
|
self.mockedSendFunction.assert_has_calls(
|
||||||
self.mockedSendFunction.assert_has_calls(
|
[
|
||||||
[
|
call(
|
||||||
call(
|
commands.DeleteDomain(name="fake.gov"),
|
||||||
commands.DeleteDomain(name="fake.gov"),
|
cleaned=True,
|
||||||
cleaned=True,
|
)
|
||||||
)
|
]
|
||||||
]
|
)
|
||||||
)
|
# Domain itself should not be deleted
|
||||||
# Domain itself should not be deleted
|
self.assertNotEqual(self.domain, None)
|
||||||
self.assertNotEqual(self.domain, None)
|
# Domain should have the right state
|
||||||
# Domain should have the right state
|
self.assertEqual(self.domain.state, Domain.State.DELETED)
|
||||||
self.assertEqual(self.domain.state, Domain.State.DELETED)
|
# Domain should have a deleted
|
||||||
# Domain should have a deleted
|
self.assertNotEqual(self.domain.deleted, None)
|
||||||
self.assertNotEqual(self.domain.deleted, None)
|
# Cache should be invalidated
|
||||||
# Cache should be invalidated
|
self.assertEqual(self.domain._cache, {})
|
||||||
self.assertEqual(self.domain._cache, {})
|
|
||||||
|
|
||||||
|
@less_console_noise_decorator
|
||||||
def test_deletion_is_unsuccessful(self):
|
def test_deletion_is_unsuccessful(self):
|
||||||
"""
|
"""
|
||||||
Scenario: Domain deletion is unsuccessful
|
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
|
Then a client error is returned of code 2305
|
||||||
And `state` is not set to `DELETED`
|
And `state` is not set to `DELETED`
|
||||||
"""
|
"""
|
||||||
with less_console_noise():
|
# Desired domain
|
||||||
# Desired domain
|
domain, _ = Domain.objects.get_or_create(name="sharingiscaring.gov", state=Domain.State.ON_HOLD)
|
||||||
domain, _ = Domain.objects.get_or_create(name="failDelete.gov", state=Domain.State.ON_HOLD)
|
# Put the domain in client hold
|
||||||
# Put the domain in client hold
|
domain.place_client_hold()
|
||||||
domain.place_client_hold()
|
# Delete it
|
||||||
# Delete it
|
with self.assertRaises(RegistryError) as err:
|
||||||
with self.assertRaises(RegistryError) as err:
|
domain.deletedInEpp()
|
||||||
domain.deletedInEpp()
|
domain.save()
|
||||||
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)
|
|
||||||
|
|
||||||
|
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):
|
def test_deletion_ready_fsm_failure(self):
|
||||||
"""
|
"""
|
||||||
Scenario: Domain deletion is unsuccessful due to FSM rules
|
Scenario: Domain deletion is unsuccessful due to FSM rules
|
||||||
|
@ -2661,15 +2787,14 @@ class TestAnalystDelete(MockEppLib):
|
||||||
|
|
||||||
The deleted date is still null.
|
The deleted date is still null.
|
||||||
"""
|
"""
|
||||||
with less_console_noise():
|
self.assertEqual(self.domain.state, Domain.State.READY)
|
||||||
self.assertEqual(self.domain.state, Domain.State.READY)
|
with self.assertRaises(TransitionNotAllowed) as err:
|
||||||
with self.assertRaises(TransitionNotAllowed) as err:
|
self.domain.deletedInEpp()
|
||||||
self.domain.deletedInEpp()
|
self.domain.save()
|
||||||
self.domain.save()
|
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION)
|
||||||
self.assertTrue(err.is_client_error() and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION)
|
# Domain should not be deleted
|
||||||
# Domain should not be deleted
|
self.assertNotEqual(self.domain, None)
|
||||||
self.assertNotEqual(self.domain, None)
|
# Domain should have the right state
|
||||||
# Domain should have the right state
|
self.assertEqual(self.domain.state, Domain.State.READY)
|
||||||
self.assertEqual(self.domain.state, Domain.State.READY)
|
# deleted should be null
|
||||||
# deleted should be null
|
self.assertEqual(self.domain.deleted, None)
|
||||||
self.assertEqual(self.domain.deleted, None)
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue