mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-30 06:26:34 +02:00
merge main and integrate ModelForm for PortfolioInvitation
This commit is contained in:
commit
7658810b02
55 changed files with 2706 additions and 503 deletions
10
.github/ISSUE_TEMPLATE/issue-default.yml
vendored
10
.github/ISSUE_TEMPLATE/issue-default.yml
vendored
|
@ -1,18 +1,18 @@
|
|||
name: Issue
|
||||
description: Describe an idea, feature, content, or non-bug finding
|
||||
name: Issue / story
|
||||
description: Describe an idea, problem, feature, or story. (Report bugs in the Bug template.)
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
id: title-help
|
||||
attributes:
|
||||
value: |
|
||||
> Titles should be short, descriptive, and compelling. Use sentence case.
|
||||
> Titles should be short, descriptive, and compelling. Use sentence case: don't capitalize words unnecessarily.
|
||||
- type: textarea
|
||||
id: issue-description
|
||||
attributes:
|
||||
label: Issue description
|
||||
description: |
|
||||
Describe the issue so that someone who wasn't present for its discovery can understand why it matters. Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax).
|
||||
Describe the issue so that someone who wasn't present for its discovery can understand why it matters. For stories, use the user story format (e.g., As a user, I want, so that). Use full sentences, plain language, and [good formatting](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
@ -31,7 +31,7 @@ body:
|
|||
attributes:
|
||||
label: Links to other issues
|
||||
description: |
|
||||
"With a `-` to start the line, add issue #numbers this relates to and how (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to)."
|
||||
"Use a dash (`-`) to start the line. Add an issue by typing "`#`" then the issue number. Add information to describe any dependancies, blockers, etc. (e.g., 🚧 [construction] Blocks, ⛔️ [no_entry] Is blocked by, 🔄 [arrows_counterclockwise] Relates to). If this is a parent issue, use sub-issues instead of linking other issues here."
|
||||
placeholder: "- 🔄 Relates to..."
|
||||
- type: markdown
|
||||
id: note
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -224,6 +224,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"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
@ -235,6 +243,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"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
@ -1623,6 +1639,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]
|
||||
|
||||
|
@ -1637,16 +1717,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",
|
||||
|
@ -1695,10 +1795,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 = [
|
||||
|
@ -2675,7 +2823,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)
|
||||
|
||||
|
@ -3221,7 +3434,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
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,8 +15,8 @@ function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, a
|
|||
// Revert the dropdown to its previous value
|
||||
statusDropdown.value = valueToCheck;
|
||||
});
|
||||
}else {
|
||||
console.log("displayModalOnDropdownClick() -> Cancel button was null");
|
||||
} else {
|
||||
console.warn("displayModalOnDropdownClick() -> Cancel button was null");
|
||||
}
|
||||
|
||||
// Add a change event listener to the dropdown.
|
||||
|
|
|
@ -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");
|
||||
|
@ -440,8 +402,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);
|
||||
|
@ -463,8 +425,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);
|
||||
|
@ -504,14 +466,14 @@ 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);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// $ 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);
|
||||
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;
|
||||
/**
|
||||
* 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) {
|
||||
|
||||
let selectedFederalAgency = $federalAgencyDropdown.find("option:selected").text();
|
||||
if (!selectedFederalAgency) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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") {
|
||||
// 1. Handle organization type
|
||||
let organizationTypeValue = organizationTypeDropdown ? organizationTypeDropdown.value : organizationTypeReadonly.innerText.toLowerCase();
|
||||
if (selectedFederalAgency !== "Non-Federal Agency") {
|
||||
if (organizationTypeValue !== "federal") {
|
||||
if (organizationType){
|
||||
organizationType.value = "federal";
|
||||
}else {
|
||||
readonlyOrganizationType.innerText = "Federal"
|
||||
if (organizationTypeDropdown){
|
||||
organizationTypeDropdown.value = "federal";
|
||||
} else {
|
||||
organizationTypeReadonly.innerText = "Federal"
|
||||
}
|
||||
}
|
||||
}else {
|
||||
} else {
|
||||
if (organizationTypeValue === "federal") {
|
||||
if (organizationType){
|
||||
organizationType.value = "";
|
||||
}else {
|
||||
readonlyOrganizationType.innerText = "-"
|
||||
if (organizationTypeDropdown){
|
||||
organizationTypeDropdown.value = "";
|
||||
} else {
|
||||
organizationTypeReadonly.innerText = "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType);
|
||||
// 2. Handle organization type change side effects
|
||||
handleOrganizationTypeChange();
|
||||
|
||||
// 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;
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
console.warn("Record not found: " + data.error);
|
||||
}else {
|
||||
console.error("Error in AJAX call: " + data.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 3. Handle federal type
|
||||
getFederalTypeFromAgency(selectedFederalAgency).then((federalType) => updateReadOnly(federalType, '.field-federal_type'));
|
||||
|
||||
// 4. Handle senior official
|
||||
hideElement(seniorOfficialAddress.parentElement);
|
||||
getSeniorOfficialFromAgency(selectedFederalAgency).then((senior_official) => {
|
||||
// Update the "contact details" blurb beneath senior official
|
||||
updateContactInfo(data);
|
||||
showElement(contactList.parentElement);
|
||||
|
||||
updateSeniorOfficialContactInfo(senior_official);
|
||||
showElement(seniorOfficialAddress.parentElement);
|
||||
// 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) {
|
||||
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($seniorOfficial, seniorOfficialId, seniorOfficialName);
|
||||
}else {
|
||||
if (readonlySeniorOfficial) {
|
||||
updateSeniorOfficialDropdown(seniorOfficialId, seniorOfficialName);
|
||||
} else {
|
||||
if (seniorOfficialReadonly) {
|
||||
let seniorOfficialLink = `<a href=/admin/registrar/seniorofficial/${seniorOfficialId}/change/>${seniorOfficialName}</a>`
|
||||
readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-";
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,8 +9,8 @@ import { initDomainsTable } from './table-domains.js';
|
|||
import { initDomainRequestsTable } from './table-domain-requests.js';
|
||||
import { initMembersTable } from './table-members.js';
|
||||
import { initMemberDomainsTable } from './table-member-domains.js';
|
||||
import { initPortfolioMemberPageToggle } from './portfolio-member-page.js';
|
||||
import { initAddNewMemberPageListeners } from './portfolio-member-page.js';
|
||||
import { initEditMemberDomainsTable } from './table-edit-member-domains.js';
|
||||
import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js';
|
||||
|
||||
initDomainValidators();
|
||||
|
||||
|
@ -20,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',
|
||||
{
|
||||
'organization_admin': 'new-member-admin-permissions',
|
||||
'organization_member': 'new-member-basic-permissions'
|
||||
}
|
||||
);
|
||||
hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null);
|
||||
initializeUrbanizationToggle();
|
||||
|
||||
|
@ -41,6 +34,9 @@ initDomainsTable();
|
|||
initDomainRequestsTable();
|
||||
initMembersTable();
|
||||
initMemberDomainsTable();
|
||||
initEditMemberDomainsTable();
|
||||
|
||||
initPortfolioMemberPageToggle();
|
||||
// Init the portfolio new member page
|
||||
initPortfolioMemberPageRadio();
|
||||
initPortfolioNewMemberPageToggle();
|
||||
initAddNewMemberPageListeners();
|
||||
|
|
|
@ -2,10 +2,12 @@ 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", () => {
|
||||
console.log("initPortfolioNewMemberPageToggle");
|
||||
const wrapperDeleteAction = document.getElementById("wrapper-delete-action")
|
||||
if (wrapperDeleteAction) {
|
||||
const member_type = wrapperDeleteAction.getAttribute("data-member-type");
|
||||
|
@ -49,7 +51,8 @@ export function initPortfolioMemberPageToggle() {
|
|||
* on the Add New Member page.
|
||||
*/
|
||||
export function initAddNewMemberPageListeners() {
|
||||
let add_member_form = document.getElementById("add_member_form")
|
||||
console.log("initializing add new member page listeners");
|
||||
let add_member_form = document.getElementById("add_member_form");
|
||||
if (!add_member_form){
|
||||
return;
|
||||
}
|
||||
|
@ -170,3 +173,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){
|
||||
console.log("initializing listeners")
|
||||
hookupRadioTogglerListener(
|
||||
'member_access_level',
|
||||
{
|
||||
'organization_admin': 'new-member-admin-permissions',
|
||||
'organization_member': 'new-member-basic-permissions'
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -38,14 +38,14 @@ 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
|
||||
|
@ -65,7 +65,7 @@ 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);
|
||||
|
|
|
@ -126,6 +126,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
|
|||
export class BaseTable {
|
||||
constructor(itemName) {
|
||||
this.itemName = itemName;
|
||||
this.displayName = itemName;
|
||||
this.sectionSelector = itemName + 's';
|
||||
this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`);
|
||||
this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`);
|
||||
|
@ -183,7 +184,7 @@ export class BaseTable {
|
|||
// Counter should only be displayed if there is more than 1 item
|
||||
paginationSelectorEl.classList.toggle('display-none', totalItems < 1);
|
||||
|
||||
counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`;
|
||||
counterSelectorEl.innerHTML = `${totalItems} ${this.displayName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`;
|
||||
|
||||
// Helper function to create a pagination item
|
||||
const createPaginationItem = (page) => {
|
||||
|
@ -416,6 +417,11 @@ export class BaseTable {
|
|||
*/
|
||||
initShowMoreButtons(){}
|
||||
|
||||
/**
|
||||
* See function for more details
|
||||
*/
|
||||
initCheckboxListeners(){}
|
||||
|
||||
/**
|
||||
* Loads rows in the members list, as well as updates pagination around the members list
|
||||
* based on the supplied attributes.
|
||||
|
@ -431,7 +437,7 @@ export class BaseTable {
|
|||
let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio);
|
||||
|
||||
// --------- FETCH DATA
|
||||
// fetch json of page of domains, given params
|
||||
// fetch json of page of objects, given params
|
||||
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
|
||||
if (!baseUrlValue) return;
|
||||
|
||||
|
@ -462,6 +468,7 @@ export class BaseTable {
|
|||
});
|
||||
|
||||
this.initShowMoreButtons();
|
||||
this.initCheckboxListeners();
|
||||
|
||||
this.loadModals(data.page, data.total, data.unfiltered_total);
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ export class DomainRequestsTable extends BaseTable {
|
|||
|
||||
constructor() {
|
||||
super('domain-request');
|
||||
this.displayName = "domain request";
|
||||
}
|
||||
|
||||
getBaseUrl() {
|
||||
|
|
234
src/registrar/assets/src/js/getgov/table-edit-member-domains.js
Normal file
234
src/registrar/assets/src/js/getgov/table-edit-member-domains.js
Normal file
|
@ -0,0 +1,234 @@
|
|||
|
||||
import { BaseTable } from './table-base.js';
|
||||
|
||||
/**
|
||||
* EditMemberDomainsTable is used for PortfolioMember and PortfolioInvitedMember
|
||||
* Domain Editing.
|
||||
*
|
||||
* This table has additional functionality for tracking and making changes
|
||||
* to domains assigned to the member/invited member.
|
||||
*/
|
||||
export class EditMemberDomainsTable extends BaseTable {
|
||||
|
||||
constructor() {
|
||||
super('edit-member-domain');
|
||||
this.displayName = "domain";
|
||||
this.currentSortBy = 'name';
|
||||
this.initialDomainAssignments = []; // list of initially assigned domains
|
||||
this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains which are readonly
|
||||
this.addedDomains = []; // list of domains added to member
|
||||
this.removedDomains = []; // list of domains removed from member
|
||||
this.initializeDomainAssignments();
|
||||
this.initCancelEditDomainAssignmentButton();
|
||||
}
|
||||
getBaseUrl() {
|
||||
return document.getElementById("get_member_domains_json_url");
|
||||
}
|
||||
getDataObjects(data) {
|
||||
return data.domains;
|
||||
}
|
||||
/** getDomainAssignmentSearchParams is used to prepare search to populate
|
||||
* initialDomainAssignments and initialDomainAssignmentsOnlyMember
|
||||
*
|
||||
* searches with memberOnly True so that only domains assigned to the member are returned
|
||||
*/
|
||||
getDomainAssignmentSearchParams(portfolio) {
|
||||
let searchParams = new URLSearchParams();
|
||||
let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null;
|
||||
let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null;
|
||||
let memberOnly = true;
|
||||
if (portfolio)
|
||||
searchParams.append("portfolio", portfolio);
|
||||
if (emailValue)
|
||||
searchParams.append("email", emailValue);
|
||||
if (memberIdValue)
|
||||
searchParams.append("member_id", memberIdValue);
|
||||
if (memberOnly)
|
||||
searchParams.append("member_only", memberOnly);
|
||||
return searchParams;
|
||||
}
|
||||
/** getSearchParams extends base class getSearchParams.
|
||||
*
|
||||
* additional searchParam for this table is checkedDomains. This is used to allow
|
||||
* for backend sorting by domains which are 'checked' in the form.
|
||||
*/
|
||||
getSearchParams(page, sortBy, order, searchTerm, status, portfolio) {
|
||||
let searchParams = super.getSearchParams(page, sortBy, order, searchTerm, status, portfolio);
|
||||
// Add checkedDomains to searchParams
|
||||
// Clone the initial domains to avoid mutating them
|
||||
let checkedDomains = [...this.initialDomainAssignments];
|
||||
// Add IDs from addedDomains that are not already in checkedDomains
|
||||
this.addedDomains.forEach(domain => {
|
||||
if (!checkedDomains.includes(domain.id)) {
|
||||
checkedDomains.push(domain.id);
|
||||
}
|
||||
});
|
||||
// Remove IDs from removedDomains
|
||||
this.removedDomains.forEach(domain => {
|
||||
const index = checkedDomains.indexOf(domain.id);
|
||||
if (index !== -1) {
|
||||
checkedDomains.splice(index, 1);
|
||||
}
|
||||
});
|
||||
// Append updated checkedDomain IDs to searchParams
|
||||
if (checkedDomains.length > 0) {
|
||||
searchParams.append("checkedDomainIds", checkedDomains.join(","));
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
addRow(dataObject, tbody, customTableOptions) {
|
||||
const domain = dataObject;
|
||||
const row = document.createElement('tr');
|
||||
let checked = false;
|
||||
let disabled = false;
|
||||
if (
|
||||
(this.initialDomainAssignments.includes(domain.id) ||
|
||||
this.addedDomains.map(obj => obj.id).includes(domain.id)) &&
|
||||
!this.removedDomains.map(obj => obj.id).includes(domain.id)
|
||||
) {
|
||||
checked = true;
|
||||
}
|
||||
if (this.initialDomainAssignmentsOnlyMember.includes(domain.id)) {
|
||||
disabled = true;
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td data-label="Selection" data-sort-value="0" class="padding-right-105">
|
||||
<div class="usa-checkbox">
|
||||
<input
|
||||
class="usa-checkbox__input"
|
||||
id="${domain.id}"
|
||||
type="checkbox"
|
||||
name="${domain.name}"
|
||||
value="${domain.id}"
|
||||
${checked ? 'checked' : ''}
|
||||
${disabled ? 'disabled' : ''}
|
||||
/>
|
||||
<label class="usa-checkbox__label margin-top-0" for="${domain.id}">
|
||||
<span class="sr-only">${domain.id}</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Domain name">
|
||||
${domain.name}
|
||||
${disabled ? '<span class="display-block margin-top-05 text-gray-50">Domains must have one domain manager. To unassign this member, the domain needs another domain manager.</span>' : ''}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
/**
|
||||
* initializeDomainAssignments searches via ajax on page load for domains assigned to
|
||||
* member. It populates both initialDomainAssignments and initialDomainAssignmentsOnlyMember.
|
||||
* It is called once per page load, but not called with subsequent table changes.
|
||||
*/
|
||||
initializeDomainAssignments() {
|
||||
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
|
||||
if (!baseUrlValue) return;
|
||||
let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue);
|
||||
let url = baseUrlValue + "?" + searchParams.toString();
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
console.error('Error in AJAX call: ' + data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
let dataObjects = this.getDataObjects(data);
|
||||
// Map the id attributes of dataObjects to this.initialDomainAssignments
|
||||
this.initialDomainAssignments = dataObjects.map(obj => obj.id);
|
||||
this.initialDomainAssignmentsOnlyMember = dataObjects
|
||||
.filter(obj => obj.member_is_only_manager)
|
||||
.map(obj => obj.id);
|
||||
})
|
||||
.catch(error => console.error('Error fetching domain assignments:', error));
|
||||
}
|
||||
/**
|
||||
* Initializes listeners on checkboxes in the table. Checkbox listeners are used
|
||||
* in this case to track changes to domain assignments in js (addedDomains and removedDomains)
|
||||
* before changes are saved.
|
||||
* initCheckboxListeners is called each time table is loaded.
|
||||
*/
|
||||
initCheckboxListeners() {
|
||||
const checkboxes = this.tableWrapper.querySelectorAll('input[type="checkbox"]');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', () => {
|
||||
const domain = { id: +checkbox.value, name: checkbox.name };
|
||||
|
||||
if (checkbox.checked) {
|
||||
this.updateDomainLists(domain, this.removedDomains, this.addedDomains);
|
||||
} else {
|
||||
this.updateDomainLists(domain, this.addedDomains, this.removedDomains);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Helper function which updates domain lists. When called, if domain is in the fromList,
|
||||
* it removes it; if domain is not in the toList, it is added to the toList.
|
||||
* @param {*} domain - object containing the domain id and name
|
||||
* @param {*} fromList - list of domains
|
||||
* @param {*} toList - list of domains
|
||||
*/
|
||||
updateDomainLists(domain, fromList, toList) {
|
||||
const index = fromList.findIndex(item => item.id === domain.id && item.name === domain.name);
|
||||
|
||||
if (index > -1) {
|
||||
fromList.splice(index, 1); // Remove from the `fromList` if it exists
|
||||
} else {
|
||||
toList.push(domain); // Add to the `toList` if not already there
|
||||
}
|
||||
}
|
||||
/**
|
||||
* initializes the Cancel button on the Edit domains page.
|
||||
* Cancel triggers modal in certain conditions and the initialization for the modal is done
|
||||
* in this function.
|
||||
*/
|
||||
initCancelEditDomainAssignmentButton() {
|
||||
const cancelEditDomainAssignmentButton = document.getElementById('cancel-edit-domain-assignments');
|
||||
if (!cancelEditDomainAssignmentButton) {
|
||||
console.error("Expected element #cancel-edit-domain-assignments, but it does not exist.");
|
||||
return; // Exit early if the button doesn't exist
|
||||
}
|
||||
|
||||
// Find the last breadcrumb link
|
||||
const lastPageLinkElement = document.querySelector('.usa-breadcrumb__list-item:nth-last-child(2) a');
|
||||
const lastPageLink = lastPageLinkElement ? lastPageLinkElement.getAttribute('href') : null;
|
||||
|
||||
const hiddenModalTrigger = document.getElementById("hidden-cancel-edit-domain-assignments-modal-trigger");
|
||||
|
||||
if (!lastPageLink) {
|
||||
console.warn("Last breadcrumb link not found or missing href.");
|
||||
}
|
||||
if (!hiddenModalTrigger) {
|
||||
console.warn("Hidden modal trigger not found.");
|
||||
}
|
||||
|
||||
// Add click event listener
|
||||
cancelEditDomainAssignmentButton.addEventListener('click', () => {
|
||||
if (this.addedDomains.length || this.removedDomains.length) {
|
||||
console.log('Changes detected. Triggering modal...');
|
||||
hiddenModalTrigger.click();
|
||||
} else if (lastPageLink) {
|
||||
window.location.href = lastPageLink; // Redirect to the last breadcrumb link
|
||||
} else {
|
||||
console.warn("No changes detected, but no valid lastPageLink to navigate to.");
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function initEditMemberDomainsTable() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const isEditMemberDomainsPage = document.getElementById("edit-member-domains");
|
||||
if (isEditMemberDomainsPage) {
|
||||
const editMemberDomainsTable = new EditMemberDomainsTable();
|
||||
if (editMemberDomainsTable.tableWrapper) {
|
||||
// Initial load
|
||||
editMemberDomainsTable.loadTable(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
|
@ -5,6 +5,7 @@ export class MemberDomainsTable extends BaseTable {
|
|||
|
||||
constructor() {
|
||||
super('member-domain');
|
||||
this.displayName = "domain";
|
||||
this.currentSortBy = 'name';
|
||||
}
|
||||
getBaseUrl() {
|
||||
|
|
|
@ -73,11 +73,15 @@ th {
|
|||
}
|
||||
}
|
||||
|
||||
td, th,
|
||||
.usa-tabel th{
|
||||
td, th {
|
||||
padding: units(2) units(4) units(2) 0;
|
||||
}
|
||||
|
||||
// Hack fix to the overly specific selector above that broke utility class usefulness
|
||||
.padding-right-105 {
|
||||
padding-right: .75rem;
|
||||
}
|
||||
|
||||
thead tr:first-child th:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
|
|
@ -109,6 +109,11 @@ urlpatterns = [
|
|||
views.PortfolioMemberDomainsView.as_view(),
|
||||
name="member-domains",
|
||||
),
|
||||
path(
|
||||
"member/<int:pk>/domains/edit",
|
||||
views.PortfolioMemberDomainsEditView.as_view(),
|
||||
name="member-domains-edit",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>",
|
||||
views.PortfolioInvitedMemberView.as_view(),
|
||||
|
@ -129,6 +134,11 @@ urlpatterns = [
|
|||
views.PortfolioInvitedMemberDomainsView.as_view(),
|
||||
name="invitedmember-domains",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>/domains/edit",
|
||||
views.PortfolioInvitedMemberDomainsEditView.as_view(),
|
||||
name="invitedmember-domains-edit",
|
||||
),
|
||||
# path(
|
||||
# "no-organization-members/",
|
||||
# views.PortfolioNoMembersView.as_view(),
|
||||
|
|
|
@ -99,7 +99,7 @@ def portfolio_permissions(request):
|
|||
|
||||
|
||||
def is_widescreen_mode(request):
|
||||
widescreen_paths = []
|
||||
widescreen_paths = [] # If this list is meant to include specific paths, populate it.
|
||||
portfolio_widescreen_paths = [
|
||||
"/domains/",
|
||||
"/requests/",
|
||||
|
@ -108,10 +108,21 @@ def is_widescreen_mode(request):
|
|||
"/no-organization-domains/",
|
||||
"/domain-request/",
|
||||
]
|
||||
# widescreen_paths can be a bear as it trickles down sub-urls. exclude_paths gives us a way out.
|
||||
exclude_paths = [
|
||||
"/domains/edit",
|
||||
]
|
||||
|
||||
# Check if the current path matches a widescreen path or the root path.
|
||||
is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/"
|
||||
is_portfolio_widescreen = bool(
|
||||
|
||||
# Check if the user is an organization user and the path matches portfolio paths.
|
||||
is_portfolio_widescreen = (
|
||||
hasattr(request.user, "is_org_user")
|
||||
and request.user.is_org_user(request)
|
||||
and any(path in request.path for path in portfolio_widescreen_paths)
|
||||
and not any(exclude_path in request.path for exclude_path in exclude_paths)
|
||||
)
|
||||
|
||||
# Return a dictionary with the widescreen mode status.
|
||||
return {"is_widescreen_mode": is_widescreen or is_portfolio_widescreen}
|
||||
|
|
|
@ -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,
|
||||
|
@ -110,58 +111,518 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
|
|||
return cleaned_data
|
||||
|
||||
|
||||
class PortfolioMemberForm(forms.ModelForm):
|
||||
|
||||
class BasePortfolioMemberForm(forms.Form):
|
||||
"""Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm"""
|
||||
|
||||
class Meta:
|
||||
model = None
|
||||
fields = ["portfolio", "roles", "additional_permissions"]
|
||||
|
||||
# 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 __init__(self, *args, **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)
|
||||
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
|
||||
|
||||
|
||||
class PortfolioMemberForm(BasePortfolioMemberForm):
|
||||
"""
|
||||
Form for updating a portfolio member.
|
||||
"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Additional Permissions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserPortfolioPermission
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
]
|
||||
fields = BasePortfolioMemberForm.Meta.fields + ["user"]
|
||||
|
||||
|
||||
class PortfolioInvitedMemberForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating a portfolio invited member.
|
||||
"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
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,
|
||||
label="Additional Permissions",
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
ROLE_REQUIRED_FIELDS = {
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
|
||||
"domain_request_permission_admin",
|
||||
"member_permission_admin",
|
||||
],
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
|
||||
"domain_request_permission_member",
|
||||
],
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = PortfolioInvitation
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
fields = ["roles", "additional_permissions" ]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Override the form's initialization to map existing model values
|
||||
to custom form fields.
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["role"].descriptions = {
|
||||
"organization_admin": UserPortfolioRoleChoices.get_role_description(
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN
|
||||
),
|
||||
"organization_member": UserPortfolioRoleChoices.get_role_description(
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
|
||||
),
|
||||
}
|
||||
# Map model instance values to custom form fields
|
||||
logger.info(self.instance)
|
||||
logger.info(self.initial)
|
||||
if self.instance:
|
||||
self.map_instance_to_initial()
|
||||
|
||||
# def clean(self):
|
||||
# logger.info(self.cleaned_data)
|
||||
# # Lowercase the value of the 'email' field
|
||||
# email_value = self.cleaned_data.get("email")
|
||||
# if email_value:
|
||||
# self.cleaned_data["email"] = email_value.lower()
|
||||
|
||||
# # Get the selected role
|
||||
# role = self.cleaned_data.get("role")
|
||||
|
||||
# # If no member access level is selected, remove errors for hidden inputs
|
||||
# if not role:
|
||||
# self._remove_hidden_field_errors(exclude_fields=["email", "role"])
|
||||
# return self.cleaned_data
|
||||
|
||||
# # Define field names for validation cleanup
|
||||
# field_error_map = {
|
||||
# "organization_admin": ["domain_request_permission_member"], # Fields irrelevant to "admin"
|
||||
# "organization_member": ["domain_request_permission_admin", "member_permission_admin"], # Fields irrelevant to "basic"
|
||||
# }
|
||||
|
||||
# # Remove errors for irrelevant fields based on the selected access level
|
||||
# irrelevant_fields = field_error_map.get(role, [])
|
||||
# for field in irrelevant_fields:
|
||||
# if field in self.errors:
|
||||
# del self.errors[field]
|
||||
|
||||
# # Map roles and additional permissions to cleaned_data
|
||||
# self.cleaned_data["roles"] = [role]
|
||||
# additional_permissions = [
|
||||
# self.cleaned_data.get("domain_request_permission_member"),
|
||||
# self.cleaned_data.get("domain_request_permission_admin"),
|
||||
# self.cleaned_data.get("member_permission_admin"),
|
||||
# ]
|
||||
# # Filter out None values
|
||||
# self.cleaned_data["additional_permissions"] = [perm for perm in additional_permissions if perm]
|
||||
# logger.info(self.cleaned_data)
|
||||
# return super().clean()
|
||||
|
||||
# def _remove_hidden_field_errors(self, exclude_fields=None):
|
||||
# """
|
||||
# Helper method to remove errors for fields that are not relevant
|
||||
# (e.g., hidden inputs), except for explicitly excluded fields.
|
||||
# """
|
||||
# exclude_fields = exclude_fields or []
|
||||
# hidden_fields = [field for field in self.fields if field not in exclude_fields]
|
||||
# for field in hidden_fields:
|
||||
# if field in self.errors:
|
||||
# del self.errors[field]
|
||||
# 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."""
|
||||
logger.info("clean")
|
||||
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
|
||||
|
||||
|
||||
logger.info("map_cleaned_data_to_instance")
|
||||
logger.info(self.cleaned_data)
|
||||
role = cleaned_data.get("role")
|
||||
|
||||
# Handle roles
|
||||
cleaned_data["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(cleaned_data["roles"], [], get_list=False)
|
||||
cleaned_data["additional_permissions"] = list(additional_permissions - role_permissions)
|
||||
|
||||
logger.info(cleaned_data)
|
||||
return cleaned_data
|
||||
|
||||
def map_instance_to_initial(self):
|
||||
"""
|
||||
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
|
||||
}
|
||||
"""
|
||||
logger.info(self.instance)
|
||||
# Function variables
|
||||
if self.initial is None:
|
||||
self.initial = {}
|
||||
perms = UserPortfolioPermission.get_portfolio_permissions(
|
||||
self.instance.roles, self.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 = self.instance.roles or []
|
||||
selected_role = next((role for role in roles if role in roles), None)
|
||||
self.initial["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)
|
||||
self.initial["domain_request_permission_admin"] = selected_domain_permission
|
||||
self.initial["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")
|
||||
self.initial["domain_request_permission_member"] = selected_domain_permission
|
||||
logger.info(self.initial)
|
||||
|
||||
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
|
||||
"""
|
||||
logger.info("map_cleaned_data_to_instance")
|
||||
logger.info(self.cleaned_data)
|
||||
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
|
||||
|
||||
|
||||
|
||||
class PortfolioNewMemberForm(forms.ModelForm):
|
||||
|
@ -274,4 +735,3 @@ class PortfolioNewMemberForm(forms.ModelForm):
|
|||
for field in hidden_fields:
|
||||
if field in self.errors:
|
||||
del self.errors[field]
|
||||
|
||||
|
|
|
@ -231,6 +231,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.
|
||||
|
@ -254,7 +260,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.
|
||||
|
||||
|
@ -707,7 +713,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])]"""
|
||||
|
@ -744,7 +750,12 @@ class Domain(TimeStampedModel, DomainHelper):
|
|||
|
||||
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
|
||||
|
||||
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()
|
||||
|
@ -1030,6 +1041,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)
|
||||
|
||||
|
@ -1097,7 +1149,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
|
||||
|
||||
|
@ -1431,6 +1483,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.
|
||||
|
@ -1440,8 +1494,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}")
|
||||
|
@ -1746,7 +1801,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:
|
||||
|
@ -1765,6 +1819,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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -18,7 +18,28 @@ class UserPortfolioRoleChoices(models.TextChoices):
|
|||
|
||||
@classmethod
|
||||
def get_user_portfolio_role_label(cls, user_portfolio_role):
|
||||
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 can’t 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):
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{% block title %}{% translate "Unauthorized | " %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}">
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
<div class="grid-row grow-gap">
|
||||
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
|
||||
<h1>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{% block title %}{% translate "Forbidden | " %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}">
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
<div class="grid-row grow-gap">
|
||||
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
|
||||
<h1>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{% block title %}{% translate "Page not found | " %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}">
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
|
||||
<h1>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{% block title %}{% translate "Server error | " %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}">
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
|
||||
<h1>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -27,12 +27,16 @@
|
|||
|
||||
<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 %}
|
||||
<p>During our review, we’ll verify that your requested domain meets our naming requirements.</p>
|
||||
{% else %}
|
||||
<p>During our review, we’ll 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> 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>
|
||||
|
|
|
@ -12,12 +12,12 @@ STATUS: Submitted
|
|||
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.
|
||||
|
||||
During our review we’ll verify that:
|
||||
During our review, we’ll 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
|
||||
|
||||
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?
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
{% block title %} Home | {% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}">
|
||||
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
{% if user.is_authenticated %}
|
||||
{# the entire logged in page goes here #}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<footer class="usa-footer">
|
||||
<div class="usa-footer__secondary-section">
|
||||
<div class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}">
|
||||
<div class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
<div class="grid-row grid-gap">
|
||||
<div
|
||||
class="
|
||||
|
|
142
src/registrar/templates/includes/member_domains_edit_table.html
Normal file
142
src/registrar/templates/includes/member_domains_edit_table.html
Normal file
|
@ -0,0 +1,142 @@
|
|||
{% load static %}
|
||||
|
||||
{% if member %}
|
||||
<span
|
||||
id="portfolio-js-value"
|
||||
class="display-none"
|
||||
data-portfolio="{{ portfolio.id }}"
|
||||
data-email=""
|
||||
data-member-id="{{ member.id }}"
|
||||
data-member-only="false"
|
||||
></span>
|
||||
{% else %}
|
||||
<span
|
||||
id="portfolio-js-value"
|
||||
class="display-none"
|
||||
data-portfolio="{{ portfolio.id }}"
|
||||
data-email="{{ portfolio_invitation.email }}"
|
||||
data-member-id=""
|
||||
data-member-only="false"
|
||||
></span>
|
||||
{% endif %}
|
||||
|
||||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get_member_domains_json' as url %}
|
||||
<span id="get_member_domains_json_url" class="display-none">{{url}}</span>
|
||||
<section class="section-outlined member-domains margin-top-0 section-outlined--border-base-light" id="edit-member-domains">
|
||||
|
||||
<h2>
|
||||
Edit domains assigned to
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% else %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<div class="section-outlined__header margin-bottom-3 grid-row">
|
||||
<!-- ---------- SEARCH ---------- -->
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
||||
<section aria-label="Member domains search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<label class="usa-label display-block margin-bottom-05" for="edit-member-domains__search-field">
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
Search all domains
|
||||
{% else %}
|
||||
Search domains assigned to
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% else %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="usa-search--show-label__input-wrapper">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="edit-member-domains__reset-search" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
Reset
|
||||
</button>
|
||||
<input
|
||||
class="usa-input"
|
||||
id="edit-member-domains__search-field"
|
||||
type="search"
|
||||
name="member-domains-search"
|
||||
/>
|
||||
<button class="usa-button" type="submit" id="edit-member-domains__search-field-submit">
|
||||
<span class="usa-search__submit-text">Search </span>
|
||||
<img
|
||||
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
|
||||
class="usa-search__submit-icon"
|
||||
alt="Search"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------- MAIN TABLE ---------- -->
|
||||
<div class="display-none margin-top-0" id="edit-member-domains__table-wrapper">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<caption class="sr-only">member domains</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105"><span class="sr-only">Assigned domains</span></th>
|
||||
<!-- We override default sort to be name/ascending in the JSON endpoint. We add the correct aria-sort attribute here to reflect that in the UI -->
|
||||
<th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- AJAX will populate this tbody -->
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class="usa-sr-only usa-table__announcement-region" id="edit-member-domains__usa-table__announcement-region"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
<div class="display-none" id="edit-member-domains__no-data">
|
||||
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p>
|
||||
</div>
|
||||
<div class="display-none" id="edit-member-domains__no-search-results">
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
</section>
|
||||
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="edit-member-domains-pagination">
|
||||
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
|
||||
<!-- Count will be dynamically populated by JS -->
|
||||
</span>
|
||||
<ul class="usa-pagination__list">
|
||||
<!-- Pagination links will be dynamically populated by JS -->
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<a
|
||||
id="hidden-cancel-edit-domain-assignments-modal-trigger"
|
||||
href="#cancel-edit-domain-assignments-modal"
|
||||
class="usa-button usa-button--outline margin-top-1 display-none"
|
||||
aria-controls="cancel-edit-domain-assignments-modal"
|
||||
data-open-modal
|
||||
></a
|
||||
>
|
||||
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="cancel-edit-domain-assignments-modal"
|
||||
aria-labelledby="Are you sure you want to continue?"
|
||||
aria-describedby="You have unsaved changes that will be lost."
|
||||
>
|
||||
{% if portfolio_permission %}
|
||||
{% url 'member-domains' pk=portfolio_permission.id as url %}
|
||||
{% else %}
|
||||
{% url 'invitedmember-domains' pk=portfolio_invitation.id as url %}
|
||||
{% endif %}
|
||||
|
||||
{% include 'includes/modal.html' with modal_heading="Are you sure you want to continue?" modal_description="You have unsaved changes that will be lost." modal_button_url=url modal_button_text="Continue without saving" %}
|
||||
</div>
|
||||
|
||||
|
|
@ -36,21 +36,17 @@
|
|||
|
||||
<div class="section-outlined__header margin-bottom-3 grid-row">
|
||||
<!-- ---------- SEARCH ---------- -->
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6">
|
||||
<section aria-label="Members search component" class="margin-top-2">
|
||||
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
|
||||
<section aria-label="Member domains search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--show-label" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
<label class="usa-label display-block margin-bottom-05" for="member-domains__search-field">
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
Search all domains
|
||||
{% else %}
|
||||
Search domains assigned to
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% else %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</label>
|
||||
<div class="usa-search--show-label__input-wrapper">
|
||||
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="member-domains__reset-search" type="button">
|
||||
|
|
|
@ -23,18 +23,24 @@
|
|||
|
||||
<div class="usa-modal__footer">
|
||||
<ul class="usa-button-group">
|
||||
{% if not_form %}
|
||||
<li class="usa-button-group__item">
|
||||
{% if not_form and modal_button %}
|
||||
{{ modal_button }}
|
||||
</li>
|
||||
{% elif modal_button_url and modal_button_text %}
|
||||
<a
|
||||
href="{{ modal_button_url }}"
|
||||
type="button"
|
||||
class="usa-button"
|
||||
>
|
||||
{{ modal_button_text }}
|
||||
</a>
|
||||
{% else %}
|
||||
<li class="usa-button-group__item">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ modal_button }}
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
{% comment %} The cancel button the DS form actually triggers a context change in the view,
|
||||
in addition to being a close modal hook {% endcomment %}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<div id="wrapper" class="{% block wrapper_class %}wrapper--padding-top-6{% endblock %}">
|
||||
{% block content %}
|
||||
|
||||
<main class="grid-container {% if is_widescreen_mode %} grid-container--widescreen {% endif %}">
|
||||
<main class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
|
||||
{% if user.is_authenticated %}
|
||||
{# the entire logged in page goes here #}
|
||||
|
||||
|
|
|
@ -11,8 +11,10 @@
|
|||
{% url 'members' as url %}
|
||||
{% if portfolio_permission %}
|
||||
{% url 'member' pk=portfolio_permission.id as url2 %}
|
||||
{% url 'member-domains-edit' pk=portfolio_permission.id as url3 %}
|
||||
{% else %}
|
||||
{% url 'invitedmember' pk=portfolio_invitation.id as url2 %}
|
||||
{% url 'invitedmember-domains-edit' pk=portfolio_invitation.id as url3 %}
|
||||
{% endif %}
|
||||
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
|
@ -23,7 +25,7 @@
|
|||
<a href="{{ url2 }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Manage member</span>
|
||||
<span>Domain assignments</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
@ -35,7 +37,7 @@
|
|||
{% if has_edit_members_portfolio_permission %}
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-5">
|
||||
<p class="float-right-tablet tablet:margin-y-0">
|
||||
<a href="#" class="usa-button"
|
||||
<a href="{{ url3 }}" class="usa-button"
|
||||
>
|
||||
Edit domain assignments
|
||||
</a>
|
||||
|
|
69
src/registrar/templates/portfolio_member_domains_edit.html
Normal file
69
src/registrar/templates/portfolio_member_domains_edit.html
Normal file
|
@ -0,0 +1,69 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Edit organization member domains {% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
|
||||
{% url 'members' as url %}
|
||||
{% if portfolio_permission %}
|
||||
{% url 'member' pk=portfolio_permission.id as url2 %}
|
||||
{% url 'member-domains' pk=portfolio_permission.id as url3 %}
|
||||
{% else %}
|
||||
{% url 'invitedmember' pk=portfolio_invitation.id as url2 %}
|
||||
{% url 'invitedmember-domains' pk=portfolio_invitation.id as url3 %}
|
||||
{% endif %}
|
||||
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url2 }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url3 }}" class="usa-breadcrumb__link"><span>Domain assignments</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current edit-domain-assignments-breadcrumb" aria-current="page">
|
||||
<span>Edit domain assignments</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="margin-bottom-3">Edit domain assignments</h1>
|
||||
|
||||
<p class="margin-bottom-0">
|
||||
A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains.
|
||||
</p>
|
||||
<p>
|
||||
When you save this form the member will get an email to notify them of any changes.
|
||||
</p>
|
||||
|
||||
{% include "includes/member_domains_edit_table.html" %}
|
||||
|
||||
<ul class="usa-button-group">
|
||||
<li class="usa-button-group__item">
|
||||
<button
|
||||
id="cancel-edit-domain-assignments"
|
||||
type="button"
|
||||
class="usa-button usa-button--outline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -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 %}
|
||||
|
||||
<h1>Manage member</h1>
|
||||
|
||||
<p>
|
||||
<!-- 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 %}
|
||||
{{ member.email }}
|
||||
{% 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>
|
||||
|
||||
<!-- Page header -->
|
||||
<h1>Member access and permissions</h1>
|
||||
|
||||
{% include "includes/required_fields.html" with remove_margin_top=True %}
|
||||
|
||||
<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>
|
||||
|
||||
<hr>
|
||||
<!-- Member access radio buttons (Toggles other sections) -->
|
||||
<fieldset class="usa-fieldset">
|
||||
<legend>
|
||||
<h2 class="margin-top-0">Member Access</h2>
|
||||
</legend>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<!-- 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%}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 ""
|
||||
|
|
|
@ -1232,6 +1232,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),
|
||||
)
|
||||
|
||||
|
@ -1394,6 +1395,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)),
|
||||
|
@ -1604,6 +1614,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:
|
||||
|
@ -1611,10 +1623,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:
|
||||
|
@ -1667,6 +1676,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)
|
||||
|
@ -1678,9 +1696,6 @@ 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)
|
||||
return None
|
||||
|
||||
|
@ -1721,6 +1736,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,
|
||||
|
@ -1751,6 +1767,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
|
||||
|
@ -1801,6 +1818,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")
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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,7 +2619,6 @@ 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...
|
||||
|
@ -2620,36 +2641,141 @@ class TestAnalystDelete(MockEppLib):
|
|||
# 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)
|
||||
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.is_client_error() and err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
|
||||
self.mockedSendFunction.assert_has_calls(
|
||||
[
|
||||
call(
|
||||
commands.DeleteDomain(name="failDelete.gov"),
|
||||
cleaned=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
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,7 +2787,6 @@ 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()
|
||||
|
|
|
@ -58,10 +58,13 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
cls.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-03-01", state="ready")
|
||||
cls.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-03-01", state="ready")
|
||||
cls.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready")
|
||||
cls.domain4 = Domain.objects.create(name="example4.com", expiration_date="2024-03-01", state="ready")
|
||||
|
||||
# Add domain1 and domain2 to portfolio
|
||||
DomainInformation.objects.create(creator=cls.user, domain=cls.domain1, portfolio=cls.portfolio)
|
||||
DomainInformation.objects.create(creator=cls.user, domain=cls.domain2, portfolio=cls.portfolio)
|
||||
DomainInformation.objects.create(creator=cls.user, domain=cls.domain3, portfolio=cls.portfolio)
|
||||
DomainInformation.objects.create(creator=cls.user, domain=cls.domain4, portfolio=cls.portfolio)
|
||||
|
||||
# Assign user_member to view all domains
|
||||
UserPortfolioPermission.objects.create(
|
||||
|
@ -70,8 +73,10 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
# Add user_member as manager of domains
|
||||
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain1)
|
||||
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain2)
|
||||
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain1, role=UserDomainRole.Roles.MANAGER)
|
||||
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain2, role=UserDomainRole.Roles.MANAGER)
|
||||
UserDomainRole.objects.create(user=cls.user_member, domain=cls.domain3, role=UserDomainRole.Roles.MANAGER)
|
||||
UserDomainRole.objects.create(user=cls.user_no_perms, domain=cls.domain3, role=UserDomainRole.Roles.MANAGER)
|
||||
|
||||
# Add an invited member who has been invited to manage domains
|
||||
cls.invited_member_email = "invited@example.com"
|
||||
|
@ -123,11 +128,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 2)
|
||||
self.assertEqual(data["unfiltered_total"], 2)
|
||||
self.assertEqual(data["total"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 2)
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
|
@ -169,11 +174,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
|
@ -192,11 +197,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
|
@ -221,7 +226,7 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 1)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 1)
|
||||
|
@ -249,7 +254,7 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 1)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 1)
|
||||
|
@ -278,11 +283,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
# Check the name of the first domain is example1.com
|
||||
self.assertEqual(data["domains"][0]["name"], "example1.com")
|
||||
|
@ -306,14 +311,121 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
# Check the name of the first domain is example1.com
|
||||
self.assertEqual(data["domains"][0]["name"], "example3.com")
|
||||
self.assertEqual(data["domains"][0]["name"], "example4.com")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_member_domains_json_authenticated_sort_by_checked(self):
|
||||
"""Test that sort returns results in correct order."""
|
||||
# Test by checked in ascending order
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"email": self.user_member.id,
|
||||
"member_only": "false",
|
||||
"checkedDomainIds": f"{self.domain2.id},{self.domain3.id}",
|
||||
"sort_by": "checked",
|
||||
"order": "asc",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
||||
# Check pagination info
|
||||
self.assertEqual(data["page"], 1)
|
||||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
# Check the name of the first domain is the first unchecked domain sorted alphabetically
|
||||
self.assertEqual(data["domains"][0]["name"], "example1.com")
|
||||
self.assertEqual(data["domains"][1]["name"], "example4.com")
|
||||
|
||||
# Test by checked in descending order
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"email": self.user_member.id,
|
||||
"member_only": "false",
|
||||
"checkedDomainIds": f"{self.domain2.id},{self.domain3.id}",
|
||||
"sort_by": "checked",
|
||||
"order": "desc",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
||||
# Check pagination info
|
||||
self.assertEqual(data["page"], 1)
|
||||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
# Check the name of the first domain is the first checked domain sorted alphabetically
|
||||
self.assertEqual(data["domains"][0]["name"], "example2.com")
|
||||
self.assertEqual(data["domains"][1]["name"], "example3.com")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_member_domains_json_authenticated_member_is_only_manager(self):
|
||||
"""Test that sort returns member_is_only_manager when member_domain_role_exists
|
||||
and member_domain_role_count == 1"""
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"member_id": self.user_member.id,
|
||||
"member_only": "false",
|
||||
"sort_by": "name",
|
||||
"order": "asc",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
||||
# Check pagination info
|
||||
self.assertEqual(data["page"], 1)
|
||||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
self.assertEqual(data["domains"][0]["name"], "example1.com")
|
||||
self.assertEqual(data["domains"][1]["name"], "example2.com")
|
||||
self.assertEqual(data["domains"][2]["name"], "example3.com")
|
||||
self.assertEqual(data["domains"][3]["name"], "example4.com")
|
||||
|
||||
self.assertEqual(data["domains"][0]["member_is_only_manager"], True)
|
||||
self.assertEqual(data["domains"][1]["member_is_only_manager"], True)
|
||||
# domain3 has 2 managers
|
||||
self.assertEqual(data["domains"][2]["member_is_only_manager"], False)
|
||||
# no managers on this one
|
||||
self.assertEqual(data["domains"][3]["member_is_only_manager"], False)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
|
@ -339,11 +451,11 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
# Check the name of the first domain is example1.com
|
||||
self.assertEqual(data["domains"][0]["name"], "example1.com")
|
||||
|
@ -367,14 +479,79 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 3)
|
||||
self.assertEqual(data["unfiltered_total"], 3)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 3)
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
# Check the name of the first domain is example1.com
|
||||
self.assertEqual(data["domains"][0]["name"], "example3.com")
|
||||
self.assertEqual(data["domains"][0]["name"], "example4.com")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_invitedmember_domains_json_authenticated_sort_by_checked(self):
|
||||
"""Test that sort returns results in correct order."""
|
||||
# Test by checked in ascending order
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"email": self.invited_member_email,
|
||||
"member_only": "false",
|
||||
"checkedDomainIds": f"{self.domain2.id},{self.domain3.id}",
|
||||
"sort_by": "checked",
|
||||
"order": "asc",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
||||
# Check pagination info
|
||||
self.assertEqual(data["page"], 1)
|
||||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
# Check the name of the first domain is the first unchecked domain sorted alphabetically
|
||||
self.assertEqual(data["domains"][0]["name"], "example1.com")
|
||||
self.assertEqual(data["domains"][1]["name"], "example4.com")
|
||||
|
||||
# Test by checked in descending order
|
||||
response = self.app.get(
|
||||
reverse("get_member_domains_json"),
|
||||
params={
|
||||
"portfolio": self.portfolio.id,
|
||||
"email": self.invited_member_email,
|
||||
"member_only": "false",
|
||||
"checkedDomainIds": f"{self.domain2.id},{self.domain3.id}",
|
||||
"sort_by": "checked",
|
||||
"order": "desc",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
||||
# Check pagination info
|
||||
self.assertEqual(data["page"], 1)
|
||||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
|
||||
# Check the number of domains
|
||||
self.assertEqual(len(data["domains"]), 4)
|
||||
|
||||
# Check the name of the first domain is the first checked domain sorted alphabetically
|
||||
self.assertEqual(data["domains"][0]["name"], "example2.com")
|
||||
self.assertEqual(data["domains"][1]["name"], "example3.com")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
|
|
|
@ -2102,6 +2102,127 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
|
|||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
super().tearDownClass()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_domains_edit_authenticated(self):
|
||||
"""Tests that the portfolio member domains edit view is accessible."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("member-domains-edit", kwargs={"pk": self.permission.id}))
|
||||
|
||||
# Make sure the page loaded, and that we're on the right page
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.user_member.email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_domains_edit_no_perms(self):
|
||||
"""Tests that the portfolio member domains edit view is not accessible to user with no perms."""
|
||||
self.client.force_login(self.user_no_perms)
|
||||
|
||||
response = self.client.get(reverse("member-domains-edit", kwargs={"pk": self.permission.id}))
|
||||
|
||||
# Make sure the request returns forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_domains_edit_unauthenticated(self):
|
||||
"""Tests that the portfolio member domains edit view is not accessible when no authenticated user."""
|
||||
self.client.logout()
|
||||
|
||||
response = self.client.get(reverse("member-domains-edit", kwargs={"pk": self.permission.id}))
|
||||
|
||||
# Make sure the request returns redirect to openid login
|
||||
self.assertEqual(response.status_code, 302) # Redirect to openid login
|
||||
self.assertIn("/openid/login", response.url)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_domains_edit_not_found(self):
|
||||
"""Tests that the portfolio member domains edit view returns not found if user
|
||||
portfolio permission not found."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("member-domains-edit", kwargs={"pk": "0"}))
|
||||
|
||||
# Make sure the response is not found
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomainsView):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
super().tearDownClass()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_invitedmember_domains_edit_authenticated(self):
|
||||
"""Tests that the portfolio invited member domains edit view is accessible."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.id}))
|
||||
|
||||
# Make sure the page loaded, and that we're on the right page
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.invited_member_email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_invitedmember_domains_edit_no_perms(self):
|
||||
"""Tests that the portfolio invited member domains edit view is not accessible to user with no perms."""
|
||||
self.client.force_login(self.user_no_perms)
|
||||
|
||||
response = self.client.get(reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.id}))
|
||||
|
||||
# Make sure the request returns forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_invitedmember_domains_edit_unauthenticated(self):
|
||||
"""Tests that the portfolio invited member domains edit view is not accessible when no authenticated user."""
|
||||
self.client.logout()
|
||||
|
||||
response = self.client.get(reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.id}))
|
||||
|
||||
# Make sure the request returns redirect to openid login
|
||||
self.assertEqual(response.status_code, 302) # Redirect to openid login
|
||||
self.assertIn("/openid/login", response.url)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_member_domains_edit_not_found(self):
|
||||
"""Tests that the portfolio invited member domains edit view returns not found if user is not a member."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(reverse("invitedmember-domains-edit", kwargs={"pk": "0"}))
|
||||
|
||||
# Make sure the response is not found
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class TestRequestingEntity(WebTest):
|
||||
"""The requesting entity page is a domain request form that only exists
|
||||
within the context of a portfolio."""
|
||||
|
@ -2521,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"))
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
from django.db import models
|
||||
from django.http import JsonResponse
|
||||
from django.core.paginator import Paginator
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
@ -28,11 +29,12 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
|
|||
objects = self.apply_search(objects, request)
|
||||
objects = self.apply_sorting(objects, request)
|
||||
|
||||
paginator = Paginator(objects, 10)
|
||||
paginator = Paginator(objects, self.get_page_size(request))
|
||||
page_number = request.GET.get("page")
|
||||
page_obj = paginator.get_page(page_number)
|
||||
|
||||
domains = [self.serialize_domain(domain, request.user) for domain in page_obj.object_list]
|
||||
member_id = request.GET.get("member_id")
|
||||
domains = [self.serialize_domain(domain, member_id, request.user) for domain in page_obj.object_list]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
|
@ -46,6 +48,23 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
|
|||
}
|
||||
)
|
||||
|
||||
def get_page_size(self, request):
|
||||
"""Gets the page size.
|
||||
|
||||
If member_only, need to return the entire result set every time, so need
|
||||
to set to a very large page size. If not member_only, this can be adjusted
|
||||
to provide a smaller page size"""
|
||||
|
||||
member_only = request.GET.get("member_only", "false").lower() in ["true", "1"]
|
||||
if member_only:
|
||||
# This number needs to remain very high as the entire result set
|
||||
# must be returned when member_only
|
||||
return 1000
|
||||
else:
|
||||
# This number can be adjusted if we want to add pagination to the result page
|
||||
# later
|
||||
return 1000
|
||||
|
||||
def get_domain_ids_from_request(self, request):
|
||||
"""Get domain ids from request.
|
||||
|
||||
|
@ -86,13 +105,41 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
|
|||
return queryset
|
||||
|
||||
def apply_sorting(self, queryset, request):
|
||||
# Get the sorting parameters from the request
|
||||
sort_by = request.GET.get("sort_by", "name")
|
||||
order = request.GET.get("order", "asc")
|
||||
# Sort by 'checked' if specified, otherwise by the given field
|
||||
if sort_by == "checked":
|
||||
# Get list of checked ids from the request
|
||||
checked_ids = request.GET.get("checkedDomainIds")
|
||||
if checked_ids:
|
||||
# Split the comma-separated string into a list of integers
|
||||
checked_ids = [int(id.strip()) for id in checked_ids.split(",") if id.strip().isdigit()]
|
||||
else:
|
||||
# If no value is passed, set checked_ids to an empty list
|
||||
checked_ids = []
|
||||
# Annotate each object with a 'checked' value based on whether its ID is in checkedIds
|
||||
queryset = queryset.annotate(
|
||||
checked=models.Case(
|
||||
models.When(id__in=checked_ids, then=models.Value(True)),
|
||||
default=models.Value(False),
|
||||
output_field=models.BooleanField(),
|
||||
)
|
||||
)
|
||||
# Add ordering logic for 'checked'
|
||||
if order == "desc":
|
||||
queryset = queryset.order_by("-checked", "name")
|
||||
else:
|
||||
queryset = queryset.order_by("checked", "name")
|
||||
else:
|
||||
# Handle other fields as normal
|
||||
if order == "desc":
|
||||
sort_by = f"-{sort_by}"
|
||||
return queryset.order_by(sort_by)
|
||||
queryset = queryset.order_by(sort_by)
|
||||
|
||||
def serialize_domain(self, domain, user):
|
||||
return queryset
|
||||
|
||||
def serialize_domain(self, domain, member_id, user):
|
||||
suborganization_name = None
|
||||
try:
|
||||
domain_info = domain.domain_info
|
||||
|
@ -107,9 +154,22 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
|
|||
# Check if there is a UserDomainRole for this domain and user
|
||||
user_domain_role_exists = UserDomainRole.objects.filter(domain_id=domain.id, user=user).exists()
|
||||
view_only = not user_domain_role_exists or domain.state in [Domain.State.DELETED, Domain.State.ON_HOLD]
|
||||
|
||||
# Check if the specified member is the only member assigned as manager of domain
|
||||
only_member_assigned_to_domain = False
|
||||
if member_id:
|
||||
member_domain_role_count = UserDomainRole.objects.filter(
|
||||
domain_id=domain.id, role=UserDomainRole.Roles.MANAGER
|
||||
).count()
|
||||
member_domain_role_exists = UserDomainRole.objects.filter(
|
||||
domain_id=domain.id, user_id=member_id, role=UserDomainRole.Roles.MANAGER
|
||||
).exists()
|
||||
only_member_assigned_to_domain = member_domain_role_exists and member_domain_role_count == 1
|
||||
|
||||
return {
|
||||
"id": domain.id,
|
||||
"name": domain.name,
|
||||
"member_is_only_manager": only_member_assigned_to_domain,
|
||||
"expiration_date": domain.expiration_date,
|
||||
"state": domain.state,
|
||||
"state_display": domain.state_display(),
|
||||
|
|
|
@ -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
|
||||
|
@ -22,6 +21,7 @@ from registrar.views.utility.permission_views import (
|
|||
PortfolioBasePermissionView,
|
||||
NoPortfolioDomainsPermissionView,
|
||||
PortfolioMemberDomainsPermissionView,
|
||||
PortfolioMemberDomainsEditPermissionView,
|
||||
PortfolioMemberEditPermissionView,
|
||||
PortfolioMemberPermissionView,
|
||||
PortfolioMembersPermissionView,
|
||||
|
@ -165,12 +165,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,
|
||||
|
@ -200,6 +205,24 @@ class PortfolioMemberDomainsView(PortfolioMemberDomainsPermissionView, View):
|
|||
)
|
||||
|
||||
|
||||
class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_domains_edit.html"
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
member = portfolio_permission.user
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"portfolio_permission": portfolio_permission,
|
||||
"member": member,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member.html"
|
||||
|
@ -265,6 +288,7 @@ class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View):
|
|||
|
||||
def get(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
logger.info(portfolio_invitation)
|
||||
form = self.form_class(instance=portfolio_invitation)
|
||||
|
||||
return render(
|
||||
|
@ -281,6 +305,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(
|
||||
|
@ -309,6 +334,22 @@ class PortfolioInvitedMemberDomainsView(PortfolioMemberDomainsPermissionView, Vi
|
|||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_domains_edit.html"
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"portfolio_invitation": portfolio_invitation,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
|
||||
"""Some users have access to the underlying portfolio, but not any domains.
|
||||
This is a custom view which explains that to the user - and denotes who to contact.
|
||||
|
|
|
@ -572,3 +572,20 @@ class PortfolioMemberDomainsPermission(PortfolioBasePermission):
|
|||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioMemberDomainsEditPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio member or invited member domains edit pages if user
|
||||
has access to edit, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to member or invited member domains for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
|
|
@ -16,6 +16,7 @@ from .mixins import (
|
|||
PortfolioDomainRequestsPermission,
|
||||
PortfolioDomainsPermission,
|
||||
PortfolioMemberDomainsPermission,
|
||||
PortfolioMemberDomainsEditPermission,
|
||||
PortfolioMemberEditPermission,
|
||||
UserDeleteDomainRolePermission,
|
||||
UserProfilePermission,
|
||||
|
@ -279,3 +280,13 @@ class PortfolioMemberDomainsPermissionView(PortfolioMemberDomainsPermission, Por
|
|||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioMemberDomainsEditPermissionView(
|
||||
PortfolioMemberDomainsEditPermission, PortfolioBasePermissionView, abc.ABC
|
||||
):
|
||||
"""Abstract base view for portfolio member domains edit views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
|
|
@ -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/
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue