Merge branch 'ms/3212-FEB-purpose-questions' into ms/3212-FEB-questions

This commit is contained in:
matthewswspence 2025-03-11 16:06:47 -05:00
commit 6bd35c6131
No known key found for this signature in database
GPG key ID: FB458202A7852BA4
87 changed files with 2880 additions and 853 deletions

View file

@ -204,38 +204,177 @@ class MyUserAdminForm(UserChangeForm):
)
class UserPortfolioPermissionsForm(forms.ModelForm):
class Meta:
model = models.UserPortfolioPermission
fields = "__all__"
widgets = {
"roles": FilteredSelectMultipleArrayWidget(
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"additional_permissions": FilteredSelectMultipleArrayWidget(
"additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
}
class PortfolioPermissionsForm(forms.ModelForm):
"""
Form for managing portfolio permissions in Django admin. This form class is used
for both UserPortfolioPermission and PortfolioInvitation models.
Allows selecting a portfolio, assigning a role, and managing specific permissions
related to requests, domains, and members.
"""
# Define available permissions for requests, domains, and members
REQUEST_PERMISSIONS = [
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
]
DOMAIN_PERMISSIONS = [
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
]
MEMBER_PERMISSIONS = [
UserPortfolioPermissionChoices.VIEW_MEMBERS,
]
# Dropdown to select a portfolio
portfolio = forms.ModelChoiceField(
queryset=models.Portfolio.objects.all(),
label="Portfolio",
widget=AutocompleteSelectWithPlaceholder(
models.PortfolioInvitation._meta.get_field("portfolio"),
admin.site,
attrs={"data-placeholder": "---------"}, # Customize placeholder
),
)
# Dropdown for selecting the user role (e.g., Admin or Basic)
role = forms.ChoiceField(
choices=[("", "---------")] + UserPortfolioRoleChoices.choices,
required=True,
widget=forms.Select(attrs={"class": "admin-dropdown"}),
label="Member access",
help_text="Only admins can manage member permissions and organization metadata.",
)
# Dropdown for selecting request permissions, with a default "No access" option
request_permissions = forms.ChoiceField(
choices=[(None, "No access")] + [(perm.value, perm.label) for perm in REQUEST_PERMISSIONS],
required=False,
widget=forms.Select(attrs={"class": "admin-dropdown"}),
label="Domain requests",
)
# Dropdown for selecting domain permissions
domain_permissions = forms.ChoiceField(
choices=[(perm.value, perm.label) for perm in DOMAIN_PERMISSIONS],
required=False,
widget=forms.Select(attrs={"class": "admin-dropdown"}),
label="Domains",
)
# Dropdown for selecting member permissions, with a default "No access" option
member_permissions = forms.ChoiceField(
choices=[(None, "No access")] + [(perm.value, perm.label) for perm in MEMBER_PERMISSIONS],
required=False,
widget=forms.Select(attrs={"class": "admin-dropdown"}),
label="Members",
)
def __init__(self, *args, **kwargs):
"""
Initialize the form and set default values based on the existing instance.
"""
super().__init__(*args, **kwargs)
# If an instance exists, populate the form fields with existing data
if self.instance and self.instance.pk:
# Set the initial value for the role field
if self.instance.roles:
self.fields["role"].initial = self.instance.roles[0] # Assuming a single role per user
# Set the initial values for permissions based on the instance data
if self.instance.additional_permissions:
for perm in self.instance.additional_permissions:
if perm in self.REQUEST_PERMISSIONS:
self.fields["request_permissions"].initial = perm
elif perm in self.DOMAIN_PERMISSIONS:
self.fields["domain_permissions"].initial = perm
elif perm in self.MEMBER_PERMISSIONS:
self.fields["member_permissions"].initial = perm
def clean(self):
"""
Custom validation and processing of form data before saving.
"""
cleaned_data = super().clean()
# Store the selected role as a list (assuming single role assignment)
self.instance.roles = [cleaned_data.get("role")] if cleaned_data.get("role") else []
cleaned_data["roles"] = self.instance.roles
# If the selected role is "organization_member," store additional permissions
if self.instance.roles == [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]:
self.instance.additional_permissions = list(
filter(
None,
[
cleaned_data.get("request_permissions"),
cleaned_data.get("domain_permissions"),
cleaned_data.get("member_permissions"),
],
)
)
else:
# If the user is an admin, clear any additional permissions
self.instance.additional_permissions = []
cleaned_data["additional_permissions"] = self.instance.additional_permissions
return cleaned_data
class PortfolioInvitationAdminForm(UserChangeForm):
"""This form utilizes the custom widget for its class's ManyToMany UIs."""
class UserPortfolioPermissionsForm(PortfolioPermissionsForm):
"""
Form for managing user portfolio permissions in Django admin.
Extends PortfolioPermissionsForm to include a user field, allowing administrators
to assign roles and permissions to specific users within a portfolio.
"""
# Dropdown to select a user from the database
user = forms.ModelChoiceField(
queryset=models.User.objects.all(),
label="User",
widget=AutocompleteSelectWithPlaceholder(
models.UserPortfolioPermission._meta.get_field("user"),
admin.site,
attrs={"data-placeholder": "---------"}, # Customize placeholder
),
)
class Meta:
model = models.PortfolioInvitation
fields = "__all__"
widgets = {
"roles": FilteredSelectMultipleArrayWidget(
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
),
"additional_permissions": FilteredSelectMultipleArrayWidget(
"additional_permissions",
is_stacked=False,
choices=UserPortfolioPermissionChoices.choices,
),
}
"""
Meta class defining the model and fields to be used in the form.
"""
model = models.UserPortfolioPermission # Uses the UserPortfolioPermission model
fields = ["user", "portfolio", "role", "domain_permissions", "request_permissions", "member_permissions"]
class PortfolioInvitationForm(PortfolioPermissionsForm):
"""
Form for sending portfolio invitations in Django admin.
Extends PortfolioPermissionsForm to include an email field for inviting users,
allowing them to be assigned a role and permissions within a portfolio before they join.
"""
class Meta:
"""
Meta class defining the model and fields to be used in the form.
"""
model = models.PortfolioInvitation # Uses the PortfolioInvitation model
fields = [
"email",
"portfolio",
"role",
"domain_permissions",
"request_permissions",
"member_permissions",
"status",
]
class DomainInformationAdminForm(forms.ModelForm):
@ -1345,12 +1484,13 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
change_form_template = "django/admin/user_portfolio_permission_change_form.html"
delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.html"
delete_selected_confirmation_template = "django/admin/user_portfolio_permission_delete_selected_confirmation.html"
def get_roles(self, obj):
readable_roles = obj.get_readable_roles()
return ", ".join(readable_roles)
get_roles.short_description = "Roles" # type: ignore
get_roles.short_description = "Member access" # type: ignore
def delete_queryset(self, request, queryset):
"""We override the delete method in the model.
@ -1643,7 +1783,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
class PortfolioInvitationAdmin(BaseInvitationAdmin):
"""Custom portfolio invitation admin class."""
form = PortfolioInvitationAdminForm
form = PortfolioInvitationForm
class Meta:
model = models.PortfolioInvitation
@ -1655,8 +1795,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
list_display = [
"email",
"portfolio",
"roles",
"additional_permissions",
"get_roles",
"status",
]
@ -1681,6 +1820,13 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
change_form_template = "django/admin/portfolio_invitation_change_form.html"
delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html"
delete_selected_confirmation_template = "django/admin/portfolio_invitation_delete_selected_confirmation.html"
def get_roles(self, obj):
readable_roles = obj.get_readable_roles()
return ", ".join(readable_roles)
get_roles.short_description = "Member access" # type: ignore
def save_model(self, request, obj, form, change):
"""
@ -2612,17 +2758,16 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"investigator",
"portfolio",
"sub_organization",
"senior_official",
]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
# Table ordering
# NOTE: This impacts the select2 dropdowns (combobox)
# Currentl, there's only one for requests on DomainInfo
# Currently, there's only one for requests on DomainInfo
ordering = ["-last_submitted_date", "requested_domain__name"]
change_form_template = "django/admin/domain_request_change_form.html"
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
@ -4225,21 +4370,21 @@ class PortfolioAdmin(ListHeaderAdmin):
if admin_count > 0:
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count
return format_html(f'<a href="{url}">{admin_count} administrators</a>')
return "No administrators found."
return format_html(f'<a href="{url}">{admin_count} admins</a>')
return "No admins found."
display_admins.short_description = "Administrators" # type: ignore
display_admins.short_description = "Admins" # type: ignore
def display_members(self, obj):
"""Returns the number of members for this portfolio"""
"""Returns the number of basic members for this portfolio"""
member_count = len(self.get_user_portfolio_permission_non_admins(obj))
if member_count > 0:
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}"
# Create a clickable link with the count
return format_html(f'<a href="{url}">{member_count} members</a>')
return "No additional members found."
return format_html(f'<a href="{url}">{member_count} basic members</a>')
return "No basic members found."
display_members.short_description = "Members" # type: ignore
display_members.short_description = "Basic members" # type: ignore
# Creates select2 fields (with search bars)
autocomplete_fields = [

View file

@ -5284,7 +5284,10 @@ const setUpModal = baseComponent => {
overlayDiv.classList.add(OVERLAY_CLASSNAME);
// Set attributes
modalWrapper.setAttribute("role", "dialog");
// DOTGOV
// Removes the dialog role as this causes a double readout bug with screenreaders
// modalWrapper.setAttribute("role", "dialog");
// END DOTGOV
modalWrapper.setAttribute("id", modalID);
if (ariaLabelledBy) {
modalWrapper.setAttribute("aria-labelledby", ariaLabelledBy);

View file

@ -0,0 +1,60 @@
/*
This function intercepts all select2 dropdowns and adds aria content.
It relies on an override in detail_table_fieldset.html that provides
a span with a corresponding id for aria-describedby content.
This allows us to avoid overriding aria-label, which is used by select2
to send the current dropdown selection to ANDI.
*/
export function initAriaInjectionsForSelect2Dropdowns() {
document.addEventListener('DOMContentLoaded', function () {
// Find all spans with "--aria-description" in their id
const descriptionSpans = document.querySelectorAll('span[id*="--aria-description"]');
descriptionSpans.forEach(function (span) {
// Extract the base ID from the span's id (remove "--aria-description")
const fieldId = span.id.replace('--aria-description', '');
const field = document.getElementById(fieldId);
if (field) {
// If Select2 is already initialized, apply aria-describedby immediately
if (field.classList.contains('select2-hidden-accessible')) {
applyAriaDescribedBy(field, span.id);
return;
}
// Use MutationObserver to detect Select2 initialization
const observer = new MutationObserver(function (mutations) {
if (document.getElementById(fieldId)?.classList.contains("select2-hidden-accessible")) {
applyAriaDescribedBy(field, span.id);
observer.disconnect(); // Stop observing after applying attributes
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
});
// Function to apply aria-describedby to Select2 UI
function applyAriaDescribedBy(field, descriptionId) {
let select2ElementDetected = false;
const select2Id = "select2-" + field.id + "-container";
// Find the Select2 selection box
const select2SpanThatTriggersAria = document.querySelector(`span[aria-labelledby='${select2Id}']`);
if (select2SpanThatTriggersAria) {
select2SpanThatTriggersAria.setAttribute('aria-describedby', descriptionId);
select2ElementDetected = true;
}
// If no Select2 component was detected, apply aria-describedby directly to the field
if (!select2ElementDetected) {
field.setAttribute('aria-describedby', descriptionId);
}
}
});
}

View file

@ -1,4 +1,4 @@
import { hideElement, showElement, addOrRemoveSessionBoolean } from './helpers-admin.js';
import { hideElement, showElement, addOrRemoveSessionBoolean, announceForScreenReaders } from './helpers-admin.js';
import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js';
function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){
@ -684,3 +684,33 @@ export function initDynamicDomainRequestFields(){
handleSuborgFieldsAndButtons();
}
}
export function initFilterFocusListeners() {
document.addEventListener("DOMContentLoaded", function() {
let filters = document.querySelectorAll("#changelist-filter li a"); // Get list of all filter links
let clickedFilter = false; // Used to determine if we are truly navigating away or not
// Restore focus from localStorage
let lastClickedFilterId = localStorage.getItem("admin_filter_focus_id");
if (lastClickedFilterId) {
let focusedElement = document.getElementById(lastClickedFilterId);
if (focusedElement) {
//Focus the element
focusedElement.setAttribute("tabindex", "0");
focusedElement.focus({ preventScroll: true });
// Announce focus change for screen readers
announceForScreenReaders("Filter refocused on " + focusedElement.textContent);
localStorage.removeItem("admin_filter_focus_id");
}
}
// Capture clicked filter and store its ID
filters.forEach(filter => {
filter.addEventListener("click", function() {
localStorage.setItem("admin_filter_focus_id", this.id);
clickedFilter = true; // Mark that a filter was clicked
});
});
});
}

View file

@ -32,3 +32,22 @@ export function getParameterByName(name, url) {
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
/**
* Creates a temporary live region to announce messages for screen readers.
*/
export function announceForScreenReaders(message) {
let liveRegion = document.createElement("div");
liveRegion.setAttribute("aria-live", "assertive");
liveRegion.setAttribute("role", "alert");
liveRegion.setAttribute("class", "usa-sr-only");
document.body.appendChild(liveRegion);
// Delay the update slightly to ensure it's recognized
setTimeout(() => {
liveRegion.textContent = message;
setTimeout(() => {
document.body.removeChild(liveRegion);
}, 1000);
}, 100);
}

View file

@ -10,13 +10,16 @@ import {
initRejectedEmail,
initApprovedDomain,
initCopyRequestSummary,
initDynamicDomainRequestFields } from './domain-request-form.js';
initDynamicDomainRequestFields,
initFilterFocusListeners } from './domain-request-form.js';
import { initDomainFormTargetBlankButtons } from './domain-form.js';
import { initDynamicPortfolioFields } from './portfolio-form.js';
import { initDynamicPortfolioPermissionFields } from './portfolio-permissions-form.js'
import { initDynamicDomainInformationFields } from './domain-information-form.js';
import { initDynamicDomainFields } from './domain-form.js';
import { initAnalyticsDashboard } from './analytics.js';
import { initButtonLinks } from './button-utils.js';
import { initAriaInjectionsForSelect2Dropdowns } from './andi.js'
// General
initModals();
@ -24,6 +27,7 @@ initCopyToClipboard();
initFilterHorizontalWidget();
initDescriptions();
initSubmitBar();
initAriaInjectionsForSelect2Dropdowns();
initButtonLinks();
// Domain request
@ -34,6 +38,7 @@ initRejectedEmail();
initApprovedDomain();
initCopyRequestSummary();
initDynamicDomainRequestFields();
initFilterFocusListeners();
// Domain
initDomainFormTargetBlankButtons();
@ -42,6 +47,9 @@ initDynamicDomainFields();
// Portfolio
initDynamicPortfolioFields();
// Portfolio permissions
initDynamicPortfolioPermissionFields();
// Domain information
initDynamicDomainInformationFields();

View file

@ -0,0 +1,67 @@
import { hideElement, showElement } from './helpers-admin.js';
/**
* A function for dynamically changing fields on the UserPortfolioPermissions
* and PortfolioInvitation admin forms
*/
function handlePortfolioPermissionFields(){
const roleDropdown = document.getElementById("id_role");
const domainPermissionsField = document.querySelector(".field-domain_permissions");
const domainRequestPermissionsField = document.querySelector(".field-request_permissions");
const memberPermissionsField = document.querySelector(".field-member_permissions");
/**
* Updates the visibility of portfolio permissions fields based on the selected role.
*
* This function checks the value of the role dropdown (`roleDropdown`):
* - If the selected role is "organization_member":
* - Shows the domain permissions field (`domainPermissionsField`).
* - Shows the domain request permissions field (`domainRequestPermissionsField`).
* - Shows the member permissions field (`memberPermissionsField`).
* - Otherwise:
* - Hides all the above fields.
*
* The function ensures that the appropriate fields are dynamically displayed
* or hidden depending on the role selection in the form.
*/
function updatePortfolioPermissionsFormVisibility() {
if (roleDropdown && domainPermissionsField && domainRequestPermissionsField && memberPermissionsField) {
if (roleDropdown.value === "organization_member") {
showElement(domainPermissionsField);
showElement(domainRequestPermissionsField);
showElement(memberPermissionsField);
} else {
hideElement(domainPermissionsField);
hideElement(domainRequestPermissionsField);
hideElement(memberPermissionsField);
}
}
}
/**
* Sets event listeners for key UI elements.
*/
function setEventListeners() {
if (roleDropdown) {
roleDropdown.addEventListener("change", function() {
updatePortfolioPermissionsFormVisibility();
})
}
}
// Run initial setup functions
updatePortfolioPermissionsFormVisibility();
setEventListeners();
}
export function initDynamicPortfolioPermissionFields() {
document.addEventListener('DOMContentLoaded', function() {
let isPortfolioPermissionPage = document.getElementById("userportfoliopermission_form");
let isPortfolioInvitationPage = document.getElementById("portfolioinvitation_form")
if (isPortfolioPermissionPage || isPortfolioInvitationPage) {
handlePortfolioPermissionFields();
}
});
}

View file

@ -1,4 +1,4 @@
import { submitForm } from './helpers.js';
import { submitForm } from './form-helpers.js';
export function initDomainDNSSEC() {
document.addEventListener('DOMContentLoaded', function() {

View file

@ -1,4 +1,4 @@
import { submitForm } from './helpers.js';
import { submitForm } from './form-helpers.js';
export function initDomainDSData() {
document.addEventListener('DOMContentLoaded', function() {

View file

@ -1,4 +1,4 @@
import { submitForm } from './helpers.js';
import { submitForm } from './form-helpers.js';
export function initDomainManagersPage() {
document.addEventListener('DOMContentLoaded', function() {

View file

@ -1,4 +1,4 @@
import { submitForm } from './helpers.js';
import { submitForm } from './form-helpers.js';
export function initDomainRequestForm() {
document.addEventListener('DOMContentLoaded', function() {

View file

@ -0,0 +1,57 @@
/**
* Helper function to submit a form
* @param {} form_id - the id of the form to be submitted
*/
export function submitForm(form_id) {
let form = document.getElementById(form_id);
if (form) {
form.submit();
} else {
console.error("Form '" + form_id + "' not found.");
}
}
/**
* Removes all error-related classes and messages from the specified DOM element.
* This method cleans up validation errors by removing error highlighting from input fields,
* labels, and form groups, as well as deleting error message elements.
* @param {HTMLElement} domElement - The parent element within which errors should be cleared.
*/
export function removeErrorsFromElement(domElement) {
// Remove the 'usa-form-group--error' class from all div elements
domElement.querySelectorAll("div.usa-form-group--error").forEach(div => {
div.classList.remove("usa-form-group--error");
});
// Remove the 'usa-label--error' class from all label elements
domElement.querySelectorAll("label.usa-label--error").forEach(label => {
label.classList.remove("usa-label--error");
});
// Remove all error message divs whose ID ends with '__error-message'
domElement.querySelectorAll("div[id$='__error-message']").forEach(errorDiv => {
errorDiv.remove();
});
// Remove the 'usa-input--error' class from all input elements
domElement.querySelectorAll("input.usa-input--error").forEach(input => {
input.classList.remove("usa-input--error");
});
}
/**
* Removes all form-level error messages displayed in the UI.
* The form error messages are contained within div elements with the ID 'form-errors'.
* Since multiple elements with the same ID may exist (even though not syntactically correct),
* this function removes them iteratively.
*/
export function removeFormErrors() {
let formErrorDiv = document.getElementById("form-errors");
// Recursively remove all instances of form error divs
while (formErrorDiv) {
formErrorDiv.remove();
formErrorDiv = document.getElementById("form-errors");
}
}

View file

@ -0,0 +1,516 @@
import { showElement, hideElement, scrollToElement } from './helpers';
import { removeErrorsFromElement, removeFormErrors } from './form-helpers';
export class NameserverForm {
constructor() {
this.addNameserverButton = document.getElementById('nameserver-add-button');
this.addNameserversForm = document.querySelector('.add-nameservers-form');
this.domain = '';
this.formChanged = false;
this.callback = null;
// Bind event handlers to maintain 'this' context
this.handleAddFormClick = this.handleAddFormClick.bind(this);
this.handleEditClick = this.handleEditClick.bind(this);
this.handleDeleteClick = this.handleDeleteClick.bind(this);
this.handleDeleteKebabClick = this.handleDeleteKebabClick.bind(this);
this.handleCancelClick = this.handleCancelClick.bind(this);
this.handleCancelAddFormClick = this.handleCancelAddFormClick.bind(this);
}
/**
* Initialize the NameserverForm by setting up display and event listeners.
*/
init() {
this.initializeNameserverFormDisplay();
this.initializeEventListeners();
}
/**
* Determines the initial display state of the nameserver form,
* handling validation errors and setting visibility of elements accordingly.
*/
initializeNameserverFormDisplay() {
const domainName = document.getElementById('id_form-0-domain');
if (domainName) {
this.domain = domainName.value;
} else {
console.warn("Form expects a dom element, id_form-0-domain");
}
// Check if exactly two nameserver forms exist: id_form-1-server is present but id_form-2-server is not
const secondNameserver = document.getElementById('id_form-1-server');
const thirdNameserver = document.getElementById('id_form-2-server'); // This should not exist
// Check if there are error messages in the form (indicated by elements with class 'usa-alert--error')
const errorMessages = document.querySelectorAll('.usa-alert--error');
// This check indicates that there are exactly two forms (which is the case for the Add New Nameservers form)
// and there is at least one error in the form. In this case, show the Add New Nameservers form, and
// indicate that the form has changed
if (this.addNameserversForm && secondNameserver && !thirdNameserver && errorMessages.length > 0) {
showElement(this.addNameserversForm);
this.formChanged = true;
}
// This check indicates that there is either an Add New Nameservers form or an Add New Nameserver form
// and that form has errors in it. In this case, show the form, and indicate that the form has
// changed.
if (this.addNameserversForm && this.addNameserversForm.querySelector('.usa-input--error')) {
showElement(this.addNameserversForm);
this.formChanged = true;
}
// handle display of table view errors
// if error exists in an edit-row, make that row show, and readonly row hide
const formTable = document.getElementById('nameserver-table')
if (formTable) {
const editRows = formTable.querySelectorAll('.edit-row');
editRows.forEach(editRow => {
if (editRow.querySelector('.usa-input--error')) {
const readOnlyRow = editRow.previousElementSibling;
this.formChanged = true;
showElement(editRow);
hideElement(readOnlyRow);
}
})
}
// hide ip in forms unless nameserver ends with domain name
let formIndex = 0;
while (document.getElementById('id_form-' + formIndex + '-domain')) {
let serverInput = document.getElementById('id_form-' + formIndex + '-server');
let ipInput = document.getElementById('id_form-' + formIndex + '-ip');
if (serverInput && ipInput) {
let serverValue = serverInput.value.trim(); // Get the value and trim spaces
let ipParent = ipInput.parentElement; // Get the parent element of ipInput
if (ipParent && !serverValue.endsWith('.' + this.domain)) {
hideElement(ipParent); // Hide the parent element of ipInput
}
}
formIndex++;
}
}
/**
* Attaches event listeners to relevant UI elements for interaction handling.
*/
initializeEventListeners() {
this.addNameserverButton.addEventListener('click', this.handleAddFormClick);
const editButtons = document.querySelectorAll('.nameserver-edit');
editButtons.forEach(editButton => {
editButton.addEventListener('click', this.handleEditClick);
});
const cancelButtons = document.querySelectorAll('.nameserver-cancel');
cancelButtons.forEach(cancelButton => {
cancelButton.addEventListener('click', this.handleCancelClick);
});
const cancelAddFormButtons = document.querySelectorAll('.nameserver-cancel-add-form');
cancelAddFormButtons.forEach(cancelAddFormButton => {
cancelAddFormButton.addEventListener('click', this.handleCancelAddFormClick);
});
const deleteButtons = document.querySelectorAll('.nameserver-delete');
deleteButtons.forEach(deleteButton => {
deleteButton.addEventListener('click', this.handleDeleteClick);
});
const deleteKebabButtons = document.querySelectorAll('.nameserver-delete-kebab');
deleteKebabButtons.forEach(deleteKebabButton => {
deleteKebabButton.addEventListener('click', this.handleDeleteKebabClick);
});
const textInputs = document.querySelectorAll("input[type='text']");
textInputs.forEach(input => {
input.addEventListener("input", () => {
this.formChanged = true;
});
});
// Add a specific listener for 'id_form-{number}-server' inputs to make
// nameserver forms 'smart'. Inputs on server field will change the
// display value of the associated IP address field.
let formIndex = 0;
while (document.getElementById(`id_form-${formIndex}-server`)) {
let serverInput = document.getElementById(`id_form-${formIndex}-server`);
let ipInput = document.getElementById(`id_form-${formIndex}-ip`);
if (serverInput && ipInput) {
let ipParent = ipInput.parentElement; // Get the parent element of ipInput
let ipTd = ipParent.parentElement;
// add an event listener on the server input that adjusts visibility
// and value of the ip input (and its parent)
serverInput.addEventListener("input", () => {
let serverValue = serverInput.value.trim();
if (ipParent && ipTd) {
if (serverValue.endsWith('.' + this.domain)) {
showElement(ipParent); // Show IP field if the condition matches
ipTd.classList.add('width-40p');
} else {
hideElement(ipParent); // Hide IP field otherwise
ipTd.classList.remove('width-40p');
ipInput.value = ""; // Set the IP value to blank
}
} else {
console.warn("Expected DOM element but did not find it");
}
});
}
formIndex++; // Move to the next index
}
// Set event listeners on the submit buttons for the modals. Event listeners
// should execute the callback function, which has its logic updated prior
// to modal display
const unsaved_changes_modal = document.getElementById('unsaved-changes-modal');
if (unsaved_changes_modal) {
const submitButton = document.getElementById('unsaved-changes-click-button');
const closeButton = unsaved_changes_modal.querySelector('.usa-modal__close');
submitButton.addEventListener('click', () => {
closeButton.click();
this.executeCallback();
});
}
const delete_modal = document.getElementById('delete-modal');
if (delete_modal) {
const submitButton = document.getElementById('delete-click-button');
const closeButton = delete_modal.querySelector('.usa-modal__close');
submitButton.addEventListener('click', () => {
closeButton.click();
this.executeCallback();
});
}
}
/**
* Executes a stored callback function if defined, otherwise logs a warning.
*/
executeCallback() {
if (this.callback) {
this.callback();
this.callback = null;
} else {
console.warn("No callback function set.");
}
}
/**
* Handles clicking the 'Add Nameserver' button, showing the form if needed.
* @param {Event} event - Click event
*/
handleAddFormClick(event) {
this.callback = () => {
// Check if any other edit row is currently visible and hide it
document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => {
this.resetEditRowAndFormAndCollapseEditRow(openEditRow);
});
if (this.addNameserversForm) {
// Check if this.addNameserversForm is visible (i.e., does not have 'display-none')
if (!this.addNameserversForm.classList.contains('display-none')) {
this.resetAddNameserversForm();
}
// show nameservers form
showElement(this.addNameserversForm);
} else {
this.addAlert("error", "Youve reached the maximum amount of name server records (13). To add another record, youll need to delete one of your saved records.");
}
};
if (this.formChanged) {
//------- Show the unsaved changes confirmation modal
let modalTrigger = document.querySelector("#unsaved_changes_trigger");
if (modalTrigger) {
modalTrigger.click();
}
} else {
this.executeCallback();
}
}
/**
* Handles clicking an 'Edit' button on a readonly row, which hides the readonly row
* and displays the edit row, after performing some checks and possibly displaying modal.
* @param {Event} event - Click event
*/
handleEditClick(event) {
let editButton = event.target;
let readOnlyRow = editButton.closest('tr'); // Find the closest row
let editRow = readOnlyRow.nextElementSibling; // Get the next row
if (!editRow || !readOnlyRow) {
console.warn("Expected DOM element but did not find it");
return;
}
this.callback = () => {
// Check if any other edit row is currently visible and hide it
document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => {
this.resetEditRowAndFormAndCollapseEditRow(openEditRow);
});
// Check if this.addNameserversForm is visible (i.e., does not have 'display-none')
if (this.addNameserversForm && !this.addNameserversForm.classList.contains('display-none')) {
this.resetAddNameserversForm();
}
// hide and show rows as appropriate
hideElement(readOnlyRow);
showElement(editRow);
};
if (this.formChanged) {
//------- Show the unsaved changes confirmation modal
let modalTrigger = document.querySelector("#unsaved_changes_trigger");
if (modalTrigger) {
modalTrigger.click();
}
} else {
this.executeCallback();
}
}
/**
* Handles clicking a 'Delete' button on an edit row, which hattempts to delete the nameserver
* after displaying modal and performing check for minimum number of nameservers.
* @param {Event} event - Click event
*/
handleDeleteClick(event) {
let deleteButton = event.target;
let editRow = deleteButton.closest('tr');
if (!editRow) {
console.warn("Expected DOM element but did not find it");
return;
}
this.deleteRow(editRow);
}
/**
* Handles clicking a 'Delete' button on a readonly row in a kebab, which attempts to delete the nameserver
* after displaying modal and performing check for minimum number of nameservers.
* @param {Event} event - Click event
*/
handleDeleteKebabClick(event) {
let deleteKebabButton = event.target;
let accordionDiv = deleteKebabButton.closest('div');
// hide the accordion
accordionDiv.hidden = true;
let readOnlyRow = deleteKebabButton.closest('tr'); // Find the closest row
let editRow = readOnlyRow.nextElementSibling; // Get the next row
if (!editRow) {
console.warn("Expected DOM element but did not find it");
return;
}
this.deleteRow(editRow);
}
/**
* Deletes a nameserver row after verifying the minimum required nameservers exist.
* If there are only two nameservers left, deletion is prevented, and an alert is shown.
* If deletion proceeds, the input fields are cleared, and the form is submitted.
* @param {HTMLElement} editRow - The row corresponding to the nameserver being deleted.
*/
deleteRow(editRow) {
// Check if at least two nameserver forms exist
const fourthNameserver = document.getElementById('id_form-3-server'); // This should exist
// This checks that at least 3 nameservers exist prior to the delete of a row, and if not
// display an error alert
if (fourthNameserver) {
this.callback = () => {
hideElement(editRow);
let textInputs = editRow.querySelectorAll("input[type='text']");
textInputs.forEach(input => {
input.value = "";
});
document.querySelector("form").submit();
};
let modalTrigger = document.querySelector('#delete_trigger');
if (modalTrigger) {
modalTrigger.click();
}
} else {
this.addAlert("error", "At least two name servers are required. To proceed, add a new name server before removing this name server. If you need help, email us at help@get.gov.");
}
}
/**
* Handles the click event on the "Cancel" button in the add nameserver form.
* Resets the form fields and hides the add form section.
* @param {Event} event - Click event
*/
handleCancelAddFormClick(event) {
this.resetAddNameserversForm();
}
/**
* Handles the click event for the cancel button within the table form.
*
* This method identifies the edit row containing the cancel button and resets
* it to its initial state, restoring the corresponding read-only row.
*
* @param {Event} event - the click event triggered by the cancel button
*/
handleCancelClick(event) {
// get the cancel button that was clicked
let cancelButton = event.target;
// find the closest table row that contains the cancel button
let editRow = cancelButton.closest('tr');
if (editRow) {
this.resetEditRowAndFormAndCollapseEditRow(editRow);
} else {
console.warn("Expected DOM element but did not find it");
}
}
/**
* Resets the edit row, restores its original values, removes validation errors,
* and collapses the edit row while making the readonly row visible again.
* @param {HTMLElement} editRow - The row that is being reset and collapsed.
*/
resetEditRowAndFormAndCollapseEditRow(editRow) {
let readOnlyRow = editRow.previousElementSibling; // Get the next row
if (!editRow || !readOnlyRow) {
console.warn("Expected DOM element but did not find it");
return;
}
// reset the values set in editRow
this.resetInputValuesInElement(editRow);
// copy values from editRow to readOnlyRow
this.copyEditRowToReadonlyRow(editRow, readOnlyRow);
// remove errors from the editRow
removeErrorsFromElement(editRow);
// remove errors from the entire form
removeFormErrors();
// reset formChanged
this.resetFormChanged();
// hide and show rows as appropriate
hideElement(editRow);
showElement(readOnlyRow);
}
/**
* Resets the 'Add Nameserver' form by clearing its input fields, removing errors,
* and hiding the form to return it to its initial state.
*/
resetAddNameserversForm() {
if (this.addNameserversForm) {
// reset the values set in addNameserversForm
this.resetInputValuesInElement(this.addNameserversForm);
// remove errors from the addNameserversForm
removeErrorsFromElement(this.addNameserversForm);
// remove errors from the entire form
removeFormErrors();
// reset formChanged
this.resetFormChanged();
// hide the addNameserversForm
hideElement(this.addNameserversForm);
}
}
/**
* Resets all text input fields within the specified DOM element to their initial values.
* Triggers an 'input' event to ensure any event listeners update accordingly.
* @param {HTMLElement} domElement - The parent element containing text input fields to be reset.
*/
resetInputValuesInElement(domElement) {
const inputEvent = new Event('input');
let textInputs = domElement.querySelectorAll("input[type='text']");
textInputs.forEach(input => {
// Reset input value to its initial stored value
input.value = input.dataset.initialValue;
// Dispatch input event to update any event-driven changes
input.dispatchEvent(inputEvent);
});
}
/**
* Copies values from the editable row's text inputs into the corresponding
* readonly row cells, formatting them appropriately.
* @param {HTMLElement} editRow - The row containing editable input fields.
* @param {HTMLElement} readOnlyRow - The row where values will be displayed in a non-editable format.
*/
copyEditRowToReadonlyRow(editRow, readOnlyRow) {
let textInputs = editRow.querySelectorAll("input[type='text']");
let tds = readOnlyRow.querySelectorAll("td");
let updatedText = '';
// If a server name exists, store its value
if (textInputs[0]) {
updatedText = textInputs[0].value;
}
// If an IP address exists, append it in parentheses next to the server name
if (textInputs[1] && textInputs[1].value) {
updatedText = updatedText + " (" + textInputs[1].value + ")";
}
// Assign the formatted text to the first column of the readonly row
if (tds[0]) {
tds[0].innerText = updatedText;
}
}
/**
* Resets the form change state.
* This method marks the form as unchanged by setting `formChanged` to false.
* It is useful for tracking whether a user has modified any form fields.
*/
resetFormChanged() {
this.formChanged = false;
}
/**
* Removes all existing alert messages from the main content area.
* This ensures that only the latest alert is displayed to the user.
*/
resetAlerts() {
const mainContent = document.getElementById("main-content");
if (mainContent) {
// Remove all alert elements within the main content area
mainContent.querySelectorAll(".usa-alert:not(.usa-alert--do-not-reset)").forEach(alert => alert.remove());
} else {
console.warn("Expecting main-content DOM element");
}
}
/**
* Displays an alert message at the top of the main content area.
* It first removes any existing alerts before adding a new one to ensure only the latest alert is visible.
* @param {string} level - The alert level (e.g., 'error', 'success', 'warning', 'info').
* @param {string} message - The message to display inside the alert.
*/
addAlert(level, message) {
this.resetAlerts(); // Remove any existing alerts before adding a new one
const mainContent = document.getElementById("main-content");
if (!mainContent) return;
// Create a new alert div with appropriate classes based on alert level
const alertDiv = document.createElement("div");
alertDiv.className = `usa-alert usa-alert--${level} usa-alert--slim margin-bottom-2`;
alertDiv.setAttribute("role", "alert"); // Add the role attribute
// Create the alert body to hold the message text
const alertBody = document.createElement("div");
alertBody.className = "usa-alert__body";
alertBody.textContent = message;
// Append the alert body to the alert div and insert it at the top of the main content area
alertDiv.appendChild(alertBody);
mainContent.insertBefore(alertDiv, mainContent.firstChild);
// Scroll the page to make the alert visible to the user
scrollToElement("class", "usa-alert__body");
}
}
/**
* Initializes the NameserverForm when the DOM is fully loaded.
*/
export function initFormNameservers() {
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('nameserver-add-button')) {
const nameserverForm = new NameserverForm();
nameserverForm.init();
}
});
}

View file

@ -3,7 +3,7 @@
* We will call this on the forms init, and also every time we add a form
*
*/
function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){
function removeForm(e, formLabel, addButton, formIdentifier){
let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`);
let formToRemove = e.target.closest(".repeatable-form");
formToRemove.remove();
@ -38,48 +38,7 @@ function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){
node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`);
node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`);
}
// If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required)
// inject the USWDS required markup and make sure the INPUT is required
if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) {
// Remove the word optional
innerSpan.textContent = innerSpan.textContent.replace(/\s*\(\s*optional\s*\)\s*/, '');
// Create a new element
const newElement = document.createElement('abbr');
newElement.textContent = '*';
newElement.setAttribute("title", "required");
newElement.classList.add("usa-hint", "usa-hint--required");
// Append the new element to the label
node.appendChild(newElement);
// Find the next sibling that is an input element
let nextInputElement = node.nextElementSibling;
while (nextInputElement) {
if (nextInputElement.tagName === 'INPUT') {
// Found the next input element
nextInputElement.setAttribute("required", "")
break;
}
nextInputElement = nextInputElement.nextElementSibling;
}
nextInputElement.required = true;
}
});
// Display the add more button if we have less than 13 forms
if (isNameserversForm && forms.length <= 13) {
addButton.removeAttribute("disabled");
}
if (isNameserversForm && forms.length < 3) {
// Hide the delete buttons on the remaining nameservers
Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
deleteButton.setAttribute("disabled", "true");
});
}
});
}
@ -131,7 +90,6 @@ function markForm(e, formLabel){
*/
function prepareNewDeleteButton(btn, formLabel) {
let formIdentifier = "form"
let isNameserversForm = document.querySelector(".nameservers-form");
let isOtherContactsForm = document.querySelector(".other-contacts-form");
let addButton = document.querySelector("#add-form");
@ -144,7 +102,7 @@ function prepareNewDeleteButton(btn, formLabel) {
} else {
// We will remove the forms and re-order the formset
btn.addEventListener('click', function(e) {
removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier);
removeForm(e, formLabel, addButton, formIdentifier);
});
}
}
@ -157,7 +115,6 @@ function prepareNewDeleteButton(btn, formLabel) {
function prepareDeleteButtons(formLabel) {
let formIdentifier = "form"
let deleteButtons = document.querySelectorAll(".delete-record");
let isNameserversForm = document.querySelector(".nameservers-form");
let isOtherContactsForm = document.querySelector(".other-contacts-form");
let addButton = document.querySelector("#add-form");
if (isOtherContactsForm) {
@ -174,7 +131,7 @@ function prepareDeleteButtons(formLabel) {
} else {
// We will remove the forms and re-order the formset
deleteButton.addEventListener('click', function(e) {
removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier);
removeForm(e, formLabel, addButton, formIdentifier);
});
}
});
@ -214,16 +171,14 @@ export function initFormsetsForms() {
let addButton = document.querySelector("#add-form");
let cloneIndex = 0;
let formLabel = '';
let isNameserversForm = document.querySelector(".nameservers-form");
let isOtherContactsForm = document.querySelector(".other-contacts-form");
let isDsDataForm = document.querySelector(".ds-data-form");
let isDotgovDomain = document.querySelector(".dotgov-domain-form");
// The Nameservers formset features 2 required and 11 optionals
if (isNameserversForm) {
// cloneIndex = 2;
formLabel = "Name server";
if( !(isOtherContactsForm || isDotgovDomain || isDsDataForm) ){
return
}
// DNSSEC: DS Data
} else if (isDsDataForm) {
if (isDsDataForm) {
formLabel = "DS data record";
// The Other Contacts form
} else if (isOtherContactsForm) {
@ -235,11 +190,6 @@ export function initFormsetsForms() {
}
let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`);
// On load: Disable the add more button if we have 13 forms
if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) {
addButton.setAttribute("disabled", "true");
}
// Hide forms which have previously been deleted
hideDeletedForms()
@ -258,33 +208,6 @@ export function initFormsetsForms() {
// For the eample on Nameservers
let formExampleRegex = RegExp(`ns(\\d){1}`, 'g');
// Some Nameserver form checks since the delete can mess up the source object we're copying
// in regards to required fields and hidden delete buttons
if (isNameserversForm) {
// If the source element we're copying has required on an input,
// reset that input
let formRequiredNeedsCleanUp = newForm.innerHTML.includes('*');
if (formRequiredNeedsCleanUp) {
newForm.querySelector('label abbr').remove();
// Get all input elements within the container
const inputElements = newForm.querySelectorAll("input");
// Loop through each input element and remove the 'required' attribute
inputElements.forEach((input) => {
if (input.hasAttribute("required")) {
input.removeAttribute("required");
}
});
}
// If the source element we're copying has an disabled delete button,
// enable that button
let deleteButton= newForm.querySelector('.delete-record');
if (deleteButton.hasAttribute("disabled")) {
deleteButton.removeAttribute("disabled");
}
}
formNum++;
newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`);
@ -292,16 +215,20 @@ export function initFormsetsForms() {
// For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden,
// since the form on the backend employs Django's DELETE widget.
let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length;
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`);
let newFormCount = totalShownForms + 1;
// update the header
let header = newForm.querySelector('legend h3');
header.textContent = `${formLabel} ${newFormCount}`;
header.id = `org-contact-${newFormCount}`;
// update accessibility elements on the delete buttons
let deleteDescription = newForm.querySelector('.delete-button-description');
deleteDescription.textContent = 'Delete new contact';
deleteDescription.id = `org-contact-${newFormCount}__name`;
let deleteButton = newForm.querySelector('button');
deleteButton.setAttribute("aria-labelledby", header.id);
deleteButton.setAttribute("aria-describedby", deleteDescription.id);
} else {
// Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional
// if indices 0 or 1 have been deleted
let containsOptional = newForm.innerHTML.includes('(optional)');
if (isNameserversForm && !containsOptional) {
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum} (optional)`);
} else {
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`);
}
newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`);
}
newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`);
newForm.innerHTML = newForm.innerHTML.replace(/\n/g, ''); // Remove newline characters
@ -358,20 +285,6 @@ export function initFormsetsForms() {
let newDeleteButton = newForm.querySelector(".delete-record");
if (newDeleteButton)
prepareNewDeleteButton(newDeleteButton, formLabel);
// Disable the add more button if we have 13 forms
if (isNameserversForm && formNum == 13) {
addButton.setAttribute("disabled", "true");
}
if (isNameserversForm && forms.length >= 2) {
// Enable the delete buttons on the nameservers
forms.forEach((form, index) => {
Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
deleteButton.removeAttribute("disabled");
});
});
}
}
}
@ -397,22 +310,3 @@ export function triggerModalOnDsDataForm() {
}, 50);
}
}
/**
* Disable the delete buttons on nameserver forms on page load if < 3 forms
*
*/
export function nameserversFormListener() {
let isNameserversForm = document.querySelector(".nameservers-form");
if (isNameserversForm) {
let forms = document.querySelectorAll(".repeatable-form");
if (forms.length < 3) {
// Hide the delete buttons on the 2 nameservers
forms.forEach((form) => {
Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => {
deleteButton.setAttribute("disabled", "true");
});
});
}
}
}

View file

@ -84,19 +84,6 @@ export function getCsrfToken() {
return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
}
/**
* Helper function to submit a form
* @param {} form_id - the id of the form to be submitted
*/
export function submitForm(form_id) {
let form = document.getElementById(form_id);
if (form) {
form.submit();
} else {
console.error("Form '" + form_id + "' not found.");
}
}
/**
* Helper function to strip HTML tags
* THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS

View file

@ -1,6 +1,7 @@
import { hookupYesNoListener, hookupCallbacksToRadioToggler } from './radios.js';
import { initDomainValidators } from './domain-validators.js';
import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js';
import { initFormsetsForms, triggerModalOnDsDataForm } from './formset-forms.js';
import { initFormNameservers } from './form-nameservers'
import { initializeUrbanizationToggle } from './urbanization.js';
import { userProfileListener, finishUserSetupListener } from './user-profile.js';
import { handleRequestingEntityFieldset } from './requesting-entity.js';
@ -16,11 +17,13 @@ import { initDomainDSData } from './domain-dsdata.js';
import { initDomainDNSSEC } from './domain-dnssec.js';
import { initFormErrorHandling } from './form-errors.js';
import { domain_purpose_choice_callbacks } from './domain-purpose-form.js';
import { initButtonLinks } from '../getgov-admin/button-utils.js';
initDomainValidators();
initFormsetsForms();
triggerModalOnDsDataForm();
nameserversFormListener();
initFormNameservers();
hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees');
hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null);
@ -58,3 +61,5 @@ initFormErrorHandling();
initPortfolioMemberPageRadio();
initPortfolioNewMemberPageToggle();
initAddNewMemberPageListeners();
initButtonLinks();

View file

@ -99,9 +99,7 @@ body {
}
.section-outlined__search {
flex-grow: 4;
// Align right
max-width: 383px;
margin-left: auto;
}
}
}
@ -190,6 +188,9 @@ abbr[title] {
.visible-mobile-flex {
display: none!important;
}
.text-right--tablet {
text-align: right;
}
}
@ -286,3 +287,11 @@ Fit-content itself does not work.
width: 3%;
padding-right: 0px !important;
}
.width-40p {
width: 40%;
}
.minh-143px {
min-height: 143px;
}

View file

@ -11,6 +11,11 @@ th {
border: none;
}
td.padding-right-0,
th.padding-right-0 {
padding-right: 0;
}
tr:first-child th:first-child {
border-top: none;
}

View file

@ -89,52 +89,52 @@ urlpatterns = [
name="members",
),
path(
"member/<int:pk>",
"member/<int:member_pk>",
views.PortfolioMemberView.as_view(),
name="member",
),
path(
"member/<int:pk>/delete",
"member/<int:member_pk>/delete",
views.PortfolioMemberDeleteView.as_view(),
name="member-delete",
),
path(
"member/<int:pk>/permissions",
"member/<int:member_pk>/permissions",
views.PortfolioMemberEditView.as_view(),
name="member-permissions",
),
path(
"member/<int:pk>/domains",
"member/<int:member_pk>/domains",
views.PortfolioMemberDomainsView.as_view(),
name="member-domains",
),
path(
"member/<int:pk>/domains/edit",
"member/<int:member_pk>/domains/edit",
views.PortfolioMemberDomainsEditView.as_view(),
name="member-domains-edit",
),
path(
"invitedmember/<int:pk>",
"invitedmember/<int:invitedmember_pk>",
views.PortfolioInvitedMemberView.as_view(),
name="invitedmember",
),
path(
"invitedmember/<int:pk>/delete",
"invitedmember/<int:invitedmember_pk>/delete",
views.PortfolioInvitedMemberDeleteView.as_view(),
name="invitedmember-delete",
),
path(
"invitedmember/<int:pk>/permissions",
"invitedmember/<int:invitedmember_pk>/permissions",
views.PortfolioInvitedMemberEditView.as_view(),
name="invitedmember-permissions",
),
path(
"invitedmember/<int:pk>/domains",
"invitedmember/<int:invitedmember_pk>/domains",
views.PortfolioInvitedMemberDomainsView.as_view(),
name="invitedmember-domains",
),
path(
"invitedmember/<int:pk>/domains/edit",
"invitedmember/<int:invitedmember_pk>/domains/edit",
views.PortfolioInvitedMemberDomainsEditView.as_view(),
name="invitedmember-domains-edit",
),

View file

@ -1,7 +1,13 @@
import logging
import functools
from django.core.exceptions import PermissionDenied
from django.utils.decorators import method_decorator
from registrar.models import Domain, DomainInformation, DomainInvitation, DomainRequest, UserDomainRole
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
logger = logging.getLogger(__name__)
# Constants for clarity
ALL = "all"
@ -98,24 +104,38 @@ def _user_has_permission(user, request, rules, **kwargs):
if not user.is_authenticated or user.is_restricted():
return False
portfolio = request.session.get("portfolio")
# Define permission checks
permission_checks = [
(IS_STAFF, lambda: user.is_staff),
(IS_DOMAIN_MANAGER, lambda: _is_domain_manager(user, **kwargs)),
(
IS_DOMAIN_MANAGER,
lambda: (not user.is_org_user(request) and _is_domain_manager(user, **kwargs))
or (
user.is_org_user(request)
and _is_domain_manager(user, **kwargs)
and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk"))
),
),
(IS_STAFF_MANAGING_DOMAIN, lambda: _is_staff_managing_domain(request, **kwargs)),
(IS_PORTFOLIO_MEMBER, lambda: user.is_org_user(request)),
(
HAS_PORTFOLIO_DOMAINS_VIEW_ALL,
lambda: _has_portfolio_view_all_domains(request, kwargs.get("domain_pk")),
lambda: user.is_org_user(request)
and user.has_view_all_domains_portfolio_permission(portfolio)
and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")),
),
(
HAS_PORTFOLIO_DOMAINS_ANY_PERM,
lambda: user.is_org_user(request)
and user.has_any_domains_portfolio_permission(request.session.get("portfolio")),
and user.has_any_domains_portfolio_permission(portfolio)
and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")),
),
(
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER,
lambda: _is_domain_manager(user, **kwargs) and _is_portfolio_member(request),
lambda: _is_domain_manager(user, **kwargs)
and _is_portfolio_member(request)
and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")),
),
(
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER,
@ -129,34 +149,55 @@ def _user_has_permission(user, request, rules, **kwargs):
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM,
lambda: user.is_org_user(request)
and user.has_any_requests_portfolio_permission(request.session.get("portfolio")),
and user.has_any_requests_portfolio_permission(portfolio)
and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL,
lambda: user.is_org_user(request)
and user.has_view_all_domain_requests_portfolio_permission(request.session.get("portfolio")),
and user.has_view_all_domain_requests_portfolio_permission(portfolio)
and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT,
lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")),
lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk"))
and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")),
),
(
HAS_PORTFOLIO_MEMBERS_ANY_PERM,
lambda: user.is_org_user(request)
and (
user.has_view_members_portfolio_permission(request.session.get("portfolio"))
or user.has_edit_members_portfolio_permission(request.session.get("portfolio"))
user.has_view_members_portfolio_permission(portfolio)
or user.has_edit_members_portfolio_permission(portfolio)
)
and (
# AND rather than OR because these functions return true if the PK is not found.
# This adds support for if the view simply doesn't have said PK.
_member_exists_under_portfolio(portfolio, kwargs.get("member_pk"))
and _member_invitation_exists_under_portfolio(portfolio, kwargs.get("invitedmember_pk"))
),
),
(
HAS_PORTFOLIO_MEMBERS_EDIT,
lambda: user.is_org_user(request)
and user.has_edit_members_portfolio_permission(request.session.get("portfolio")),
and user.has_edit_members_portfolio_permission(portfolio)
and (
# AND rather than OR because these functions return true if the PK is not found.
# This adds support for if the view simply doesn't have said PK.
_member_exists_under_portfolio(portfolio, kwargs.get("member_pk"))
and _member_invitation_exists_under_portfolio(portfolio, kwargs.get("invitedmember_pk"))
),
),
(
HAS_PORTFOLIO_MEMBERS_VIEW,
lambda: user.is_org_user(request)
and user.has_view_members_portfolio_permission(request.session.get("portfolio")),
and user.has_view_members_portfolio_permission(portfolio)
and (
# AND rather than OR because these functions return true if the PK is not found.
# This adds support for if the view simply doesn't have said PK.
_member_exists_under_portfolio(portfolio, kwargs.get("member_pk"))
and _member_invitation_exists_under_portfolio(portfolio, kwargs.get("invitedmember_pk"))
),
),
]
@ -191,6 +232,70 @@ def _is_domain_manager(user, **kwargs):
return False
def _domain_exists_under_portfolio(portfolio, domain_pk):
"""Checks to see if the given domain exists under the provided portfolio.
HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function.
Returns True if the pk is falsy. Otherwise, returns a bool if said object exists.
"""
# The view expects this, and the page will throw an error without this if it needs it.
# Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check.
if not domain_pk:
logger.warning(
"_domain_exists_under_portfolio => Could not find domain_pk. "
"This is a non-issue if called from the right context."
)
return True
return Domain.objects.filter(domain_info__portfolio=portfolio, id=domain_pk).exists()
def _domain_request_exists_under_portfolio(portfolio, domain_request_pk):
"""Checks to see if the given domain request exists under the provided portfolio.
HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function.
Returns True if the pk is falsy. Otherwise, returns a bool if said object exists.
"""
# The view expects this, and the page will throw an error without this if it needs it.
# Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check.
if not domain_request_pk:
logger.warning(
"_domain_request_exists_under_portfolio => Could not find domain_request_pk. "
"This is a non-issue if called from the right context."
)
return True
return DomainRequest.objects.filter(portfolio=portfolio, id=domain_request_pk).exists()
def _member_exists_under_portfolio(portfolio, member_pk):
"""Checks to see if the given UserPortfolioPermission exists under the provided portfolio.
HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function.
Returns True if the pk is falsy. Otherwise, returns a bool if said object exists.
"""
# The view expects this, and the page will throw an error without this if it needs it.
# Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check.
if not member_pk:
logger.warning(
"_member_exists_under_portfolio => Could not find member_pk. "
"This is a non-issue if called from the right context."
)
return True
return UserPortfolioPermission.objects.filter(portfolio=portfolio, id=member_pk).exists()
def _member_invitation_exists_under_portfolio(portfolio, invitedmember_pk):
"""Checks to see if the given PortfolioInvitation exists under the provided portfolio.
HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function.
Returns True if the pk is falsy. Otherwise, returns a bool if said object exists.
"""
# The view expects this, and the page will throw an error without this if it needs it.
# Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check.
if not invitedmember_pk:
logger.warning(
"_member_invitation_exists_under_portfolio => Could not find invitedmember_pk. "
"This is a non-issue if called from the right context."
)
return True
return PortfolioInvitation.objects.filter(portfolio=portfolio, id=invitedmember_pk).exists()
def _is_domain_request_creator(user, domain_request_pk):
"""Checks to see if the user is the creator of a domain request
with domain_request_pk."""
@ -286,15 +391,3 @@ def _is_staff_managing_domain(request, **kwargs):
# the user is permissioned,
# and it is in a valid status
return True
def _has_portfolio_view_all_domains(request, domain_pk):
"""Returns whether the user in the request can access the domain
via portfolio view all domains permission."""
portfolio = request.session.get("portfolio")
if request.user.has_view_all_domains_portfolio_permission(portfolio):
if Domain.objects.filter(id=domain_pk).exists():
domain = Domain.objects.get(id=domain_pk)
if domain.domain_info.portfolio == portfolio:
return True
return False

View file

@ -319,31 +319,23 @@ class DomainRequestFixture:
"""Creates DomainRequests given a list of users."""
total_domain_requests_to_make = len(users) # 100000
# Check if the database is already populated with the desired
# number of entries.
# (Prevents re-adding more entries to an already populated database,
# which happens when restarting Docker src)
domain_requests_already_made = DomainRequest.objects.count()
domain_requests_to_create = []
if domain_requests_already_made < total_domain_requests_to_make:
for user in users:
for request_data in cls.DOMAINREQUESTS:
# Prepare DomainRequest objects
try:
domain_request = DomainRequest(
creator=user,
organization_name=request_data["organization_name"],
)
cls._set_non_foreign_key_fields(domain_request, request_data)
cls._set_foreign_key_fields(domain_request, request_data, user)
domain_requests_to_create.append(domain_request)
except Exception as e:
logger.warning(e)
num_additional_requests_to_make = (
total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create)
)
for user in users:
for request_data in cls.DOMAINREQUESTS:
# Prepare DomainRequest objects
try:
domain_request = DomainRequest(
creator=user,
organization_name=request_data["organization_name"],
)
cls._set_non_foreign_key_fields(domain_request, request_data)
cls._set_foreign_key_fields(domain_request, request_data, user)
domain_requests_to_create.append(domain_request)
except Exception as e:
logger.warning(e)
num_additional_requests_to_make = total_domain_requests_to_make - len(domain_requests_to_create)
if num_additional_requests_to_make > 0:
for _ in range(num_additional_requests_to_make):
random_user = random.choice(users) # nosec

View file

@ -65,7 +65,12 @@ class DomainNameserverForm(forms.Form):
domain = forms.CharField(widget=forms.HiddenInput, required=False)
server = forms.CharField(label="Name server", strip=True)
server = forms.CharField(
label="Name server",
strip=True,
required=True,
error_messages={"required": "At least two name servers are required."},
)
ip = forms.CharField(
label="IP address (IPv4 or IPv6)",
@ -76,13 +81,6 @@ class DomainNameserverForm(forms.Form):
def __init__(self, *args, **kwargs):
super(DomainNameserverForm, self).__init__(*args, **kwargs)
# add custom error messages
self.fields["server"].error_messages.update(
{
"required": "At least two name servers are required.",
}
)
def clean(self):
# clean is called from clean_forms, which is called from is_valid
# after clean_fields. it is used to determine form level errors.
@ -183,43 +181,83 @@ class DomainSuborganizationForm(forms.ModelForm):
class BaseNameserverFormset(forms.BaseFormSet):
def clean(self):
"""
Check for duplicate entries in the formset.
"""
"""Check for duplicate entries in the formset and ensure at least two valid nameservers."""
error_message = "At least two name servers are required."
# Check if there are at least two valid servers
valid_servers_count = sum(
1 for form in self.forms if form.cleaned_data.get("server") and form.cleaned_data.get("server").strip()
)
if valid_servers_count >= 2:
# If there are, remove the "At least two name servers are required" error from each form
# This will allow for successful submissions when the first or second entries are blanked
# but there are enough entries total
for form in self.forms:
if form.errors.get("server") == ["At least two name servers are required."]:
form.errors.pop("server")
valid_forms, invalid_forms, empty_forms = self._categorize_forms(error_message)
self._enforce_minimum_nameservers(valid_forms, invalid_forms, empty_forms, error_message)
if any(self.errors):
# Don't bother validating the formset unless each form is valid on its own
if any(self.errors): # Skip further validation if individual forms already have errors
return
data = []
self._check_for_duplicates()
def _categorize_forms(self, error_message):
"""Sort forms into valid, invalid or empty based on the 'server' field."""
valid_forms = []
invalid_forms = []
empty_forms = []
for form in self.forms:
if not self._is_server_validation_needed(form, error_message):
invalid_forms.append(form)
continue
server = form.cleaned_data.get("server", "").strip()
if server:
valid_forms.append(form)
else:
empty_forms.append(form)
return valid_forms, invalid_forms, empty_forms
def _is_server_validation_needed(self, form, error_message):
"""Determine if server validation should be performed on a given form."""
return form.is_valid() or list(form.errors.get("server", [])) == [error_message]
def _enforce_minimum_nameservers(self, valid_forms, invalid_forms, empty_forms, error_message):
"""Ensure at least two nameservers are provided, adjusting error messages as needed."""
if len(valid_forms) + len(invalid_forms) < 2:
self._add_required_error(empty_forms, error_message)
else:
self._remove_required_error_from_forms(error_message)
def _add_required_error(self, empty_forms, error_message):
"""Add 'At least two name servers' error to one form and remove duplicates."""
error_added = False
for form in empty_forms:
if list(form.errors.get("server", [])) == [error_message]:
form.errors.pop("server")
if not error_added:
form.add_error("server", error_message)
error_added = True
def _remove_required_error_from_forms(self, error_message):
"""Remove the 'At least two name servers' error from all forms if sufficient nameservers exist."""
for form in self.forms:
if form.errors.get("server") == [error_message]:
form.errors.pop("server")
def _check_for_duplicates(self):
"""Ensure no duplicate nameservers exist within the formset."""
seen_servers = set()
duplicates = []
for index, form in enumerate(self.forms):
if form.cleaned_data:
value = form.cleaned_data["server"]
# We need to make sure not to trigger the duplicate error in case the first and second nameservers
# are empty. If there are enough records in the formset, that error is an unecessary blocker.
# If there aren't, the required error will block the submit.
if value in data and not (form.cleaned_data.get("server", "").strip() == "" and index == 1):
form.add_error(
"server",
NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=value),
)
duplicates.append(value)
else:
data.append(value)
for form in self.forms:
if not form.cleaned_data:
continue
server = form.cleaned_data["server"].strip()
if server and server in seen_servers:
form.add_error(
"server",
NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=server),
)
duplicates.append(server)
else:
seen_servers.add(server)
NameserverFormset = formset_factory(

View file

@ -348,7 +348,7 @@ class OrganizationContactForm(RegistrarForm):
error_messages={
"required": ("Select the state, territory, or military post where your organization is located.")
},
widget=ComboboxWidget,
widget=ComboboxWidget(attrs={"required": True}),
)
zipcode = forms.CharField(
label="Zip code",
@ -608,37 +608,25 @@ class DotGovDomainForm(RegistrarForm):
)
class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm):
"""
Form for verifying if the domain request meets the Federal Executive Branch domain naming requirements.
If the "no" option is selected, details must be provided via the separate details form.
"""
class PurposeDetailsForm(BaseDeletableRegistrarForm):
field_name = "feb_naming_requirements"
field_name = "purpose"
@property
def form_is_checked(self):
"""
Determines the initial checked state of the form based on the domain_request's attributes.
"""
return self.domain_request.feb_naming_requirements
class ExecutiveNamingRequirementsDetailsForm(BaseDeletableRegistrarForm):
# Text area for additional details; rendered conditionally when "no" is selected.
feb_naming_requirements_details = forms.CharField(
widget=forms.Textarea(attrs={"maxlength": "2000"}),
max_length=2000,
required=True,
error_messages={"required": ("This field is required.")},
purpose = forms.CharField(
label="Purpose",
widget=forms.Textarea(
attrs={
"aria-label": "What is the purpose of your requested domain? Describe how youll use your .gov domain. \
Will it be used for a website, email, or something else?"
}
),
validators=[
MaxLengthValidator(
2000,
message="Response must be less than 2000 characters.",
)
],
label="",
help_text="Maximum 2000 characters allowed.",
error_messages={"required": "Describe how youll use the .gov domain youre requesting."},
)

View file

@ -3,6 +3,40 @@ from django.core.validators import MaxLengthValidator
from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm
from registrar.models.contact import Contact
class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm):
"""
Form for verifying if the domain request meets the Federal Executive Branch domain naming requirements.
If the "no" option is selected, details must be provided via the separate details form.
"""
field_name = "feb_naming_requirements"
@property
def form_is_checked(self):
"""
Determines the initial checked state of the form based on the domain_request's attributes.
"""
return self.domain_request.feb_naming_requirements
class ExecutiveNamingRequirementsDetailsForm(BaseDeletableRegistrarForm):
# Text area for additional details; rendered conditionally when "no" is selected.
feb_naming_requirements_details = forms.CharField(
widget=forms.Textarea(attrs={"maxlength": "2000"}),
max_length=2000,
required=True,
error_messages={"required": ("This field is required.")},
validators=[
MaxLengthValidator(
2000,
message="Response must be less than 2000 characters.",
)
],
label="",
help_text="Maximum 2000 characters allowed.",
)
class FEBPurposeOptionsForm(BaseDeletableRegistrarForm):
field_name = "feb_purpose_choice"

View file

@ -445,3 +445,28 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm):
class Meta:
model = PortfolioInvitation
fields = ["portfolio", "email", "roles", "additional_permissions"]
def _post_clean(self):
"""
Override _post_clean to customize model validation errors.
This runs after form clean is complete, but before the errors are displayed.
"""
try:
super()._post_clean()
self.instance.clean()
except forms.ValidationError as e:
override_error = False
if hasattr(e, "code"):
field = "email" if "email" in self.fields else None
if e.code == "has_existing_permissions":
self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.")
override_error = True
elif e.code == "has_existing_invitations":
self.add_error(
field, f"{self.instance.email} has already been invited to another .gov organization."
)
override_error = True
# Errors denoted as "__all__" are special error types reserved for the model level clean function
if override_error and "__all__" in self._errors:
del self._errors["__all__"]

View file

@ -1,4 +1,4 @@
""" "
"""
Converts all ready and DNS needed domains with a non-default public contact
to disclose their public contact. Created for Issue#1535 to resolve
disclose issue of domains with missing security emails.

View file

@ -1,4 +1,4 @@
""" "
"""
Data migration: Renaming deprecated Federal Agencies to
their new updated names ie (U.S. Peace Corps to Peace Corps)
within Domain Information and Domain Requests

View file

@ -0,0 +1,86 @@
# Generated by Django 4.2.17 on 2025-02-28 17:11
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0140_alter_portfolioinvitation_additional_permissions_and_more"),
]
operations = [
migrations.AlterField(
model_name="portfolioinvitation",
name="additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "Viewer"),
("view_managed_domains", "Viewer, limited (domains they manage)"),
("view_members", "Viewer"),
("edit_members", "Manager"),
("view_all_requests", "Viewer"),
("edit_requests", "Creator"),
("view_portfolio", "Viewer"),
("edit_portfolio", "Manager"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="portfolioinvitation",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[("organization_admin", "Admin"), ("organization_member", "Basic")], max_length=50
),
blank=True,
help_text="Select one or more roles.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="userportfoliopermission",
name="additional_permissions",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("view_all_domains", "Viewer"),
("view_managed_domains", "Viewer, limited (domains they manage)"),
("view_members", "Viewer"),
("edit_members", "Manager"),
("view_all_requests", "Viewer"),
("edit_requests", "Creator"),
("view_portfolio", "Viewer"),
("edit_portfolio", "Manager"),
],
max_length=50,
),
blank=True,
help_text="Select one or more additional permissions.",
null=True,
size=None,
),
),
migrations.AlterField(
model_name="userportfoliopermission",
name="roles",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[("organization_admin", "Admin"), ("organization_member", "Basic")], max_length=50
),
blank=True,
help_text="Select one or more roles.",
null=True,
size=None,
),
),
]

View file

@ -1452,6 +1452,7 @@ class DomainRequest(TimeStampedModel):
if self.portfolio:
return self.portfolio.federal_type == BranchChoices.EXECUTIVE
return False
def is_federal(self) -> Union[bool, None]:
"""Is this domain request for a federal agency?

View file

@ -15,6 +15,7 @@ from .utility.portfolio_helper import (
get_domains_display,
get_members_description_display,
get_members_display,
get_readable_roles,
get_role_display,
validate_portfolio_invitation,
) # type: ignore
@ -78,6 +79,10 @@ class PortfolioInvitation(TimeStampedModel):
def __str__(self):
return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
def get_readable_roles(self):
"""Returns a readable list of self.roles"""
return get_readable_roles(self.roles)
def get_managed_domains_count(self):
"""Return the count of domain invitations managed by the invited user for this portfolio."""
# Filter the UserDomainRole model to get domains where the user has a manager role

View file

@ -12,6 +12,7 @@ from registrar.models.utility.portfolio_helper import (
get_domains_description_display,
get_members_display,
get_members_description_display,
get_readable_roles,
get_role_display,
validate_user_portfolio_permission,
)
@ -94,12 +95,7 @@ class UserPortfolioPermission(TimeStampedModel):
def get_readable_roles(self):
"""Returns a readable list of self.roles"""
readable_roles = []
if self.roles:
readable_roles = sorted(
[UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles]
)
return readable_roles
return get_readable_roles(self.roles)
def get_managed_domains_count(self):
"""Return the count of domains managed by the user for this portfolio."""
@ -275,7 +271,12 @@ class UserPortfolioPermission(TimeStampedModel):
def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()
validate_user_portfolio_permission(self)
# Ensure user exists before running further validation
# In django admin, this clean method is called before form validation checks
# for required fields. Since validation below requires user, skip if user does
# not exist
if self.user_id:
validate_user_portfolio_permission(self)
def delete(self, *args, **kwargs):

View file

@ -16,7 +16,7 @@ class UserPortfolioRoleChoices(models.TextChoices):
"""
ORGANIZATION_ADMIN = "organization_admin", "Admin"
ORGANIZATION_MEMBER = "organization_member", "Member"
ORGANIZATION_MEMBER = "organization_member", "Basic"
@classmethod
def get_user_portfolio_role_label(cls, user_portfolio_role):
@ -30,17 +30,17 @@ class UserPortfolioRoleChoices(models.TextChoices):
class UserPortfolioPermissionChoices(models.TextChoices):
""" """
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
VIEW_ALL_DOMAINS = "view_all_domains", "Viewer"
VIEW_MANAGED_DOMAINS = "view_managed_domains", "Viewer, limited (domains they manage)"
VIEW_MEMBERS = "view_members", "View members"
EDIT_MEMBERS = "edit_members", "Create and edit members"
VIEW_MEMBERS = "view_members", "Viewer"
EDIT_MEMBERS = "edit_members", "Manager"
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
EDIT_REQUESTS = "edit_requests", "Create and edit requests"
VIEW_ALL_REQUESTS = "view_all_requests", "Viewer"
EDIT_REQUESTS = "edit_requests", "Creator"
VIEW_PORTFOLIO = "view_portfolio", "View organization"
EDIT_PORTFOLIO = "edit_portfolio", "Edit organization"
VIEW_PORTFOLIO = "view_portfolio", "Viewer"
EDIT_PORTFOLIO = "edit_portfolio", "Manager"
@classmethod
def get_user_portfolio_permission_label(cls, user_portfolio_permission):
@ -79,6 +79,13 @@ class MemberPermissionDisplay(StrEnum):
NONE = "None"
def get_readable_roles(roles):
readable_roles = []
if roles:
readable_roles = sorted([UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in roles])
return readable_roles
def get_role_display(roles):
"""
Returns a user-friendly display name for a given list of user roles.
@ -285,7 +292,8 @@ def validate_user_portfolio_permission(user_portfolio_permission):
if existing_permissions.exists():
raise ValidationError(
"This user is already assigned to a portfolio. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios.",
code="has_existing_permissions",
)
existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude(
@ -295,7 +303,8 @@ def validate_user_portfolio_permission(user_portfolio_permission):
if existing_invitations.exists():
raise ValidationError(
"This user is already assigned to a portfolio invitation. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios.",
code="has_existing_invitations",
)
@ -343,6 +352,7 @@ def validate_portfolio_invitation(portfolio_invitation):
# == Validate the multiple_porfolios flag. == #
user = User.objects.filter(email=portfolio_invitation.email).first()
# If user returns None, then we check for global assignment of multiple_portfolios.
# Otherwise we just check on the user.
if not flag_is_active_for_user(user, "multiple_portfolios"):
@ -355,13 +365,15 @@ def validate_portfolio_invitation(portfolio_invitation):
if existing_permissions.exists():
raise ValidationError(
"This user is already assigned to a portfolio. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios.",
code="has_existing_permissions",
)
if existing_invitations.exists():
raise ValidationError(
"This user is already assigned to a portfolio invitation. "
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios.",
code="has_existing_invitations",
)

View file

@ -0,0 +1,13 @@
{% comment %} Override of this file: https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/filter.html {% endcomment %}
{% load i18n %}
<details data-filter-title="{{ title }}" open>
<summary>
{% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %}
</summary>
<ul>
{% for choice in choices %}
<li {% if choice.selected %} class="selected"{% endif %}>
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" href="{{ choice.query_string|iriencode }}">{{ choice.display }}</a></li>
{% endfor %}
</ul>
</details>

View file

@ -30,6 +30,8 @@
{% include "django/admin/includes/descriptions/verified_by_staff_description.html" %}
{% elif opts.model_name == 'website' %}
{% include "django/admin/includes/descriptions/website_description.html" %}
{% elif opts.model_name == 'userportfoliopermission' %}
{% include "django/admin/includes/descriptions/user_portfolio_permission_description.html" %}
{% elif opts.model_name == 'portfolioinvitation' %}
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
{% elif opts.model_name == 'allowedemail' %}

View file

@ -6,7 +6,11 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a domain here, it will trigger emails to the invitee and all managers of the domain when you click "save." If you don't want to trigger those emails, use the <a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User domain roles permissions table</a> instead.
If you invite someone to a domain here, it will trigger email notifications. If you don't want to trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">
User Domain Roles
</a>
table instead.
</p>
</div>
</div>

View file

@ -5,10 +5,12 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
their domain management privileges if they already have that role assigned. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
if you want to remove the user from a domain.
If you cancel the domain invitation here, it won't trigger any email notifications.
It also won't remove the user's domain management privileges if they already logged in. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">
User Domain Roles
</a>
table if you want to remove their domain management privileges.
</p>
</div>
</div>

View file

@ -5,10 +5,12 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the domain invitation here, it won't trigger any emails. It also won't remove
their domain management privileges if they already have that role assigned. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User Domain Roles table</a>
if you want to remove the user from a domain.
If you cancel the domain invitation here, it won't trigger any email notifications.
It also won't remove the user's domain management privileges if they already logged in. Go to the
<a class="usa-link" href="{% url 'admin:registrar_userdomainrole_changelist' %}">
User Domain Roles
</a>
table if you want to remove their domain management privileges.
</p>
</div>
</div>

View file

@ -1,16 +1,14 @@
<p>
Domain invitations contain all individuals who have been invited to manage a .gov domain.
Invitations are sent via email, and the recipient must log in to the registrar to officially
accept and become a domain manager.
This table contains all individuals who have been invited to manage a .gov domain.
These individuals must log in to the registrar to officially accept and become a domain manager.
</p>
<p>
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent. Deleting an invitation with an "invited" status will prevent the user from signing in.
A “received” status indicates that the recipient has logged in. Deleting an invitation with a "received" status will not revoke that user's access from the domain. To remove a user who has already signed in, go to <a class="text-underline" href="{% url 'admin:registrar_userdomainrole_changelist' %}">User domain roles</a> and delete the role for the correct domain/manager combination.
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent.
A “received” status indicates that the recipient has logged in.
</p>
<p>
If an invitation is created in this table, an email will not be sent.
To have an email sent, go to the domain in <a class="text-underline" href="{% url 'admin:registrar_domain_changelist' %}">Domains</a>,
click the “Manage domain” button, and add a domain manager.
If you invite someone to a domain by using this table, theyll receive an email notification.
The existing managers of the domain will also be notified. However, canceling an invitation here wont trigger any emails.
</p>

View file

@ -1,11 +1,15 @@
<p>
Portfolio invitations contain all individuals who have been invited to become members of an organization.
Invitations are sent via email, and the recipient must log in to the registrar to officially
accept and become a member.
This table contains all individuals who have been invited to become members of a portfolio.
These individuals must log in to the registrar to officially accept and become a member.
</p>
<p>
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent
or that the recipient has logged in but is already a member of an organization.
A “received” status indicates that the recipient has logged in.
An “invited” status indicates that the recipient has not logged in to the registrar since the invitation
was sent or that the recipient has logged in but is already a member of another portfolio. A “received”
status indicates that the recipient has logged in.
</p>
<p>
If you invite someone to a portfolio by using this table, theyll receive an email notification.
If you assign them "admin" access, the existing portfolio admins will also be notified. However, canceling an invitation here wont trigger any emails.
</p>

View file

@ -1,10 +1,13 @@
<p>
This table represents the managers who are assigned to each domain in the registrar.
There are separate records for each domain/manager combination.
Managers can update information related to a domain, such as DNS data and security contact.
This table represents the managers who are assigned to each domain in the registrar. There are separate records for each domain/manager combination.
Managers can update information related to a domain, such as DNS data and security contact.
</p>
<p>
The creator of an approved domain request automatically becomes a manager for that domain.
Anyone who retrieves a domain invitation is also assigned the manager role.
The creator of an approved domain request automatically becomes a manager for that domain.
Anyone who retrieves a domain invitation will also appear in this table as a manager.
</p>
<p>
If you add or remove someone to a domain by using this table, those actions wont trigger notification emails.
</p>

View file

@ -0,0 +1,11 @@
<p>
This table represents the members of each portfolio in the registrar. There are separate records for each member/portfolio combination.
</p>
<p>
Each member is assigned one of two access levels: admin or basic. Only admins can manage member permissions and organization metadata.
</p>
<p>
If you add or remove someone to a portfolio by using this table, those actions wont trigger notification emails.
</p>

View file

@ -160,6 +160,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endblock field_readonly %}
{% block field_other %}
{% comment %}
.gov override - add Aria messages for select2 dropdowns. These messages are hooked-up to their respective DOM
elements via javascript (see andi.js)
{% endcomment %}
{% if "related_widget_wrapper" in field.field.field.widget.template_name %}
<span id="{{ field.field.id_for_label }}--aria-description" class="visually-hidden admin-select--aria-description">
{{ field.field.label }}, edit, has autocomplete. To set the value, use the arrow keys or type the text.
</span>
{% endif %}
{% if field.field.name == "action_needed_reason_email" %}
{{ field.field }}
@ -251,7 +261,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% elif field.field.name == "rejection_reason_email" %}
{{ field.field }}
<div class="margin-top-05 text-faded custom-email-placeholder">
&ndash;
</div>
@ -331,7 +340,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</button>
</div>
</div>
{% if original_object.rejection_reason_email %}
<input id="last-sent-rejection-email-content" class="display-none" value="{{original_object.rejection_reason_email}}">
{% else %}

View file

@ -9,16 +9,12 @@
{% for choice in choices %}
{% if choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}">
<a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
</li>
{% endif %}
{% endfor %}
{% for choice in choices %}
{% if not choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}">
{% else %}
<li{% if choice.selected %} class="selected"{% endif %}>
{% if choice.selected and choice.exclude_query_string %}
<a role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
@ -26,9 +22,8 @@
<use xlink:href="{%static 'img/sprite.svg'%}#check"></use>
</svg>
</a>
{% endif %}
{% if not choice.selected and choice.include_query_string %}
<a role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
{% elif not choice.selected and choice.include_query_string %}
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>

View file

@ -6,7 +6,11 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a portfolio here, it will trigger an invitation email when you click "save." If you don't want to trigger an email, use the <a class="usa-link" href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">User portfolio permissions table</a> instead.
If you invite someone to a portfolio here, it will trigger email notifications. If you don't want to trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table instead.
</p>
</div>
</div>

View file

@ -4,12 +4,12 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the portfolio invitation here, it won't trigger any emails. It also won't remove the user's
portfolio access if they already logged in. Go to the
If you cancel the portfolio invitation here, it won't trigger any email notifications.
It also won't remove the user's portfolio access if they already logged in. Go to the
<a href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table if you want to remove the user from a portfolio.
table if you want to remove their portfolio access.
</p>
</div>
</div>

View file

@ -0,0 +1,17 @@
{% extends "admin/delete_selected_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the portfolio invitation here, it won't trigger any email notifications.
It also won't remove the user's portfolio access if they already logged in. Go to the
<a href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table if you want to remove their portfolio access.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -6,7 +6,10 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a domain here, it will not trigger any emails. To trigger emails, use the <a class="usa-link" href="{% url 'admin:registrar_domaininvitation_changelist' %}">User Domain Role invitations table</a> instead.
If you add someone to a domain here, it won't trigger any email notifications. To trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_domaininvitation_changelist' %}">
Domain Invitations
</a> table instead.
</p>
</div>
</div>

View file

@ -5,7 +5,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save."
If you remove someone from a domain here, it won't trigger any email notifications.
</p>
</div>
</div>

View file

@ -5,7 +5,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a domain here, it won't trigger any emails when you click "save."
If you remove someone from a domain here, it won't trigger any email notifications.
</p>
</div>
</div>

View file

@ -6,7 +6,11 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a portfolio here, it will not trigger an invitation email. To trigger an email, use the <a class="usa-link" href="{% url 'admin:registrar_portfolioinvitation_changelist' %}">Portfolio invitations table</a> instead.
If you add someone to a portfolio here, it won't trigger any email notifications. To trigger emails, use the
<a class="usa-link" href="{% url 'admin:registrar_portfolioinvitation_changelist' %}">
Portfolio Invitations
</a>
table instead.
</p>
</div>
</div>

View file

@ -4,7 +4,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a portfolio here, it will not send any emails when you click "Save".
If you remove someone from a portfolio here, it won't trigger any email notifications.
</p>
</div>
</div>

View file

@ -0,0 +1,12 @@
{% extends "admin/delete_selected_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a portfolio here, it won't trigger any email notifications.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -46,7 +46,7 @@
{# messages block is under the back breadcrumb link #}
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2">
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body">
{{ message }}
</div>

View file

@ -5,11 +5,22 @@
{% block domain_content %}
{# this is right after the messages block in the parent template #}
{# this is right after the messages block in the parent template. #}
{% for form in formset %}
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
{% if formset.initial|length >= formset.max_num %}
<div class="usa-alert usa-alert--do-not-reset usa-alert--info usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body">
<p class="usa-alert__text">
Youve reached the maximum amount of allowed name server records (13).
</p>
</div>
</div>
{% endif %}
{% block breadcrumb %}
{% if portfolio %}
<!-- Navigation breadcrumbs -->
@ -32,82 +43,286 @@
{% endif %}
{% endblock breadcrumb %}
<h1>DNS name servers</h1>
<div class="grid-row grid-gap">
<div class="tablet:grid-col-6">
<h1 class="tablet:margin-bottom-1">Name servers</h1>
</div>
<p>Before your domain can be used well need information about your domain name servers. Name server records indicate which DNS server is authoritative for your domain.</p>
<p>Add a name server record by entering the address (e.g., ns1.nameserver.com) in the name server fields below. You must add at least two name servers (13 max).</p>
<div class="usa-alert usa-alert--info">
<div class="usa-alert__body">
<p class="margin-top-0">Add an IP address only when your name server's address includes your domain name (e.g., if your domain name is “example.gov” and your name server is “ns1.example.gov,” then an IP address is required). Multiple IP addresses must be separated with commas.</p>
<p class="margin-bottom-0">This step is uncommon unless you self-host your DNS or use custom addresses for your nameserver.</p>
<div class="tablet:grid-col-6 text-right--tablet">
<button type="button" class="usa-button margin-bottom-1 tablet:float-right" id="nameserver-add-button">
Add name servers
</button>
</div>
</div>
<p>Before your domain can be used well need information about your domain name servers. Name server records indicate which DNS server is authoritative for your domain.</p>
{% include "includes/required_fields.html" %}
<p>Add a name server record by clicking “Add name servers.” You must add at least two name servers (13 max).</p>
<form class="usa-form usa-form--extra-large nameservers-form" method="post" novalidate id="form-container">
{% csrf_token %}
{{ formset.management_form }}
{% comment %}
This template supports the rendering of three different types of nameserver forms, conditionally displayed:
1 - Add New Namervers form (rendered when there are no existing nameservers defined for the domain)
2 - Nameserver table (rendered when the domain has existing nameservers, which can be viewed and edited)
3 - Add New Nameserver (rendered above the Nameserver table to add a single additional nameserver)
{% endcomment %}
{% for form in formset %}
<div class="repeatable-form">
<div class="grid-row grid-gap-2 flex-end">
<div class="tablet:grid-col-5">
{{ form.domain }}
{% with sublabel_text="Example: ns"|concat:forloop.counter|concat:".example.com" %}
{% if forloop.counter <= 2 %}
{# span_for_text will wrap the copy in s <span>, which we'll use in the JS for this component #}
{% with attr_required=True add_group_class="usa-form-group--unstyled-error" span_for_text=True %}
{% input_with_errors form.server %}
{% endwith %}
{% else %}
{% with span_for_text=True %}
{% input_with_errors form.server %}
{% endwith %}
{% if formset.initial and formset.forms.0.initial %}
{% comment %}This section renders both the Nameserver table and the Add New Nameserver {% endcomment %}
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--extra-large" method="post" novalidate>
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
{% if forloop.last and not form.initial %}
{% comment %}
This section renders the Add New Nameserver form.
This section does not render if the last form has initial data (this occurs if 13 nameservers already exist)
{% endcomment %}
<section class="add-nameservers-form display-none section-outlined">
{{ form.domain }}
<h2>Add a name server</h2>
<div class="repeatable-form">
<div class="grid-row grid-gap-2 flex-end minh-143px">
<div class="tablet:grid-col-6">
{% with sublabel_text="Example: ns"|concat:forloop.counter|concat:".example.com" %}
{% with attr_required=True span_for_text=True add_initial_value_attr=True add_group_class="usa-form-group--unstyled-error margin-top-2" %}
{% input_with_errors form.server %}
{% endwith %}
{% endwith %}
</div>
<div class="tablet:grid-col-6">
{% with attr_required=True add_initial_value_attr=True label_text=form.ip.label sublabel_text="Example: 86.124.49.54 or 2001:db8::1234:5678" add_aria_label="Name server "|concat:forloop.counter|concat:" "|concat:form.ip.label add_group_class="usa-form-group--unstyled-error margin-top-2" %}
{% input_with_errors form.ip %}
{% endwith %}
</div>
</div>
</div>
<div class="margin-top-2">
<button
type="button"
class="usa-button usa-button--outline nameserver-cancel-add-form"
name="btn-cancel-click"
aria-label="Reset the data in the name server form to the registry state (undo changes)"
>Cancel
</button>
<button
type="submit"
class="usa-button"
>Save
</button>
</div>
</section>
{% endif %}
{% endfor %}
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked" id="nameserver-table">
<caption class="sr-only">Your registered domains</caption>
<thead>
<tr>
<th scope="col" role="columnheader">Name servers</th>
<th scope="col" role="columnheader"><span class="sr-only">IP address</span></th>
<th scope="col" role="columnheader" class="width-0 padding-right-0">Action</th>
</tr>
</thead>
<tbody>
{% for form in formset %}
{% if not forloop.last or form.initial %}
{% comment %}
This section renders table rows for each existing nameserver. Two rows are rendered, a readonly row
and an edit row. Only one of which is displayed at a time.
{% endcomment %}
{{ form.domain }}
<!-- Readonly row -->
<tr>
<td colspan="2" aria-colspan="2">{{ form.server.value }} {% if form.ip.value %}({{ form.ip.value }}){% endif %}</td>
<td class="padding-right-0">
<div class="tablet:display-flex tablet:flex-row">
<button type="button" class='usa-button usa-button--unstyled margin-right-2 margin-top-0 nameserver-edit'>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#edit"></use>
</svg>
Edit <span class="usa-sr-only">{{ form.server.value }}</span>
</button>
<a
role="button"
id="button-trigger-delete-{{ form.server.value }}"
class="usa-button usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary visible-mobile-flex nameserver-delete-kebab"
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#delete"></use>
</svg>
Delete
</a>
<div class="usa-accordion usa-accordion--more-actions margin-right-2 hidden-mobile-flex">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions margin-top-0"
aria-expanded="false"
aria-controls="more-actions-{{ form.server.value }}"
aria-label="More Actions for ({{ form.server.value }})"
>
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
</svg>
</button>
</div>
<div id="more-actions-{{ form.server.value }}" class="usa-accordion__content usa-prose shadow-1 left-auto right-neg-1" hidden>
<h2>More options</h2>
<button
type="button"
class="usa-button usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary nameserver-delete-kebab"
name="btn-delete-kebab-click"
aria-label="Delete the name server from the registry"
>
Delete
</button>
</div>
</div>
</div>
</td>
</tr>
<!-- Edit row -->
<tr class="edit-row display-none">
<td class="text-bottom">
{% with sublabel_text="Example: ns"|concat:forloop.counter|concat:".example.com" %}
{% with attr_required=True add_initial_value_attr=True span_for_text=True add_group_class="usa-form-group--unstyled-error margin-top-0" %}
{% input_with_errors form.server %}
{% endwith %}
{% endwith %}
</td>
<td class="text-bottom">
{% with attr_required=True add_initial_value_attr=True label_text=form.ip.label sublabel_text="Example: 86.124.49.54 or 2001:db8::1234:5678" add_aria_label="Name server "|concat:forloop.counter|concat:" "|concat:form.ip.label add_group_class="usa-form-group--unstyled-error margin-top-0" %}
{% input_with_errors form.ip %}
{% endwith %}
</td>
<td class="padding-right-0 text-bottom">
<button class="usa-button usa-button--unstyled display-block margin-top-1" type="submit">Save</button>
<button
type="button"
class="usa-button usa-button--unstyled display-block nameserver-cancel"
name="btn-cancel-click"
aria-label="Reset the data in the name server form to the registry state (undo changes)"
>Cancel
</button>
<button
type="button"
class="usa-button usa-button--unstyled display-block text-secondary nameserver-delete"
name="btn-delete-click"
aria-label="Delete the name server from the registry"
>Delete
</button>
</div>
</td>
</tr>
{% endif %}
{% endwith %}
</div>
<div class="tablet:grid-col-5">
{% with label_text=form.ip.label sublabel_text="Example: 86.124.49.54 or 2001:db8::1234:5678" add_group_class="usa-form-group--unstyled-error" add_aria_label="Name server "|concat:forloop.counter|concat:" "|concat:form.ip.label %}
{% input_with_errors form.ip %}
{% endwith %}
</div>
<div class="tablet:grid-col-2">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon delete-record margin-bottom-075 text-secondary line-height-sans-5">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>Delete
<span class="sr-only">Name server {{forloop.counter}}</span>
</button>
</div>
</div>
</div>
{% endfor %}
{% endfor %}
</tbody>
</table>
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg>Add another name server
</button>
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'
This solution still works when we remove the 'add more' at 13 forms {% endcomment %}
<div class="margin-top-2">
<button
type="submit"
class="usa-button"
>Save
</button>
</form>
<button
type="submit"
class="usa-button usa-button--outline"
{% else %}
{% comment %}
This section renders Add New Nameservers form which renders when there are no existing
nameservers defined on the domain.
{% endcomment %}
<section class="add-nameservers-form display-none section-outlined">
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--extra-large" method="post" novalidate>
<h2>Add name servers</h2>
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
{{ form.domain }}
<div class="repeatable-form">
<div class="grid-row grid-gap-2 flex-end minh-143px">
<div class="tablet:grid-col-6">
{% with sublabel_text="Example: ns"|concat:forloop.counter|concat:".example.com" add_group_class="usa-form-group--unstyled-error margin-top-2" %}
{% if forloop.counter <= 2 %}
{# span_for_text will wrap the copy in s <span>, which we'll use in the JS for this component #}
{% with attr_required=True add_initial_value_attr=True span_for_text=True %}
{% input_with_errors form.server %}
{% endwith %}
{% else %}
{% with span_for_text=True add_initial_value_attr=True %}
{% input_with_errors form.server %}
{% endwith %}
{% endif %}
{% endwith %}
</div>
<div class="tablet:grid-col-6">
{% with attr_required=True add_initial_value_attr=True label_text=form.ip.label sublabel_text="Example: 86.124.49.54 or 2001:db8::1234:5678" add_aria_label="Name server "|concat:forloop.counter|concat:" "|concat:form.ip.label add_group_class="usa-form-group--unstyled-error margin-top-2" %}
{% input_with_errors form.ip %}
{% endwith %}
</div>
</div>
</div>
{% endfor %}
<div class="margin-top-2">
<button
type="button"
class="usa-button usa-button--outline nameserver-cancel-add-form"
name="btn-cancel-click"
aria-label="Reset the data in the name server form to the registry state (undo changes)"
>Cancel
</button>
</div>
</form>
>Cancel
</button>
<button
type="submit"
class="usa-button"
>Save
</button>
</div>
</form>
</section>
{% endif %}
<a
id="unsaved_changes_trigger"
href="#unsaved-changes-modal"
class="usa-button usa-button--outline margin-top-1 display-none"
aria-controls="unsaved-changes-modal"
data-open-modal
>Trigger unsaved changes modal</a>
<div
class="usa-modal"
id="unsaved-changes-modal"
aria-labelledby="Are you sure you want to continue?"
aria-describedby="You have unsaved changes that will be lost."
>
{% 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_id="unsaved-changes-click-button" modal_button_text="Continue without saving" cancel_button_text="Go back" %}
</div>
<a
id="delete_trigger"
href="#delete-modal"
class="usa-button usa-button--outline margin-top-1 display-none"
aria-controls="delete-modal"
data-open-modal
>Trigger delete modal</a>
<div
class="usa-modal"
id="delete-modal"
aria-labelledby="Are you sure you want to delete this name server?"
aria-describedby="This will delete the name server from your DNS records. This action cannot be undone."
>
{% include 'includes/modal.html' with modal_heading="Are you sure you want to delete this name server?" modal_description="This will delete the name server from your DNS records. This action cannot be undone." modal_button_id="delete-click-button" modal_button_text="Yes, delete name server" modal_button_class="usa-button--secondary" %}
</div>
{% endblock %} {# domain_content #}

View file

@ -7,7 +7,7 @@
<!-- Banner for if_policy_acknowledged -->
{% if form.is_policy_acknowledged.errors %}
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2">
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body">
{% for error in form.is_policy_acknowledged.errors %}
<p class="usa-alert__text">{{ error }}</p>

View file

@ -62,7 +62,7 @@
<fieldset class="usa-fieldset margin-top-1 dotgov-domain-form" id="form-container">
<legend>
<h2>Alternative domains (optional)</h2>
<h2 id="alternative-domains-title">Alternative domains (optional)</h2>
</legend>
<p id="alt_domain_instructions" class="margin-top-05">
Are there other domains you'd like if we can't give you your first choice?
@ -79,18 +79,23 @@
{% endfor %}
{% endwith %}
{% endwith %}
<button type="button" value="save" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
<div class="usa-sr-only" id="alternative-domains__add-another-alternative">Add another alternative domain</div>
<button aria-labelledby="alternative-domains-title" aria-describedby="alternative-domains__add-another-alternative" type="button" value="save" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{% static 'img/sprite.svg' %}#add_circle"></use>
</svg>
<span class="margin-left-05">Add another alternative</span>
</button>
<div class="margin-bottom-3">
<div class="usa-sr-only" id="alternative-domains__check-availability">Check domain availability</div>
<button
id="validate-alt-domains-availability"
type="button"
class="usa-button usa-button--outline"
validate-for="{{ forms.1.requested_domain.auto_id }}"
aria-labelledby="alternative-domains-title"
aria-describedby="alternative-domains__check-availability"
>Check availability</button>
</div>
<p class="margin-top-05">

View file

@ -31,10 +31,14 @@
<fieldset class="usa-fieldset repeatable-form padding-y-1">
<legend class="float-left-tablet">
<h3 class="margin-top-05">Organization contact {{ forloop.counter }}</h2>
<h3 class="margin-top-05" id="org-contact-{{ forloop.counter }}">Organization contact {{ forloop.counter }}</h2>
</legend>
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-top-1 text-secondary line-height-sans-5 usa-button--with-icon">
{% if form.first_name or form.last_name %}
<span class="usa-sr-only delete-button-description" id="org-contact-{{ forloop.counter }}__name">Delete {{form.first_name.value }} {{ form.last_name.value }}</span>
{% else %}
<span class="usa-sr-only" id="org-contact-{{ forloop.counter }}__name">Delete new contact</span>
{% endif %}
<button aria-labelledby="org-contact-{{ forloop.counter }}" aria-describedby="org-contact-{{ forloop.counter }}__name" type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-top-1 text-secondary line-height-sans-5 usa-button--with-icon">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg>Delete

View file

@ -4,7 +4,6 @@
{% block form_instructions %}
<p>.Gov domains are intended for public use. Domains will not be given to organizations that only want to reserve a domain name (defensive registration) or that only intend to use the domain internally (as for an intranet).</p>
<p>Read about <a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'domains/requirements/' %}">activities that are prohibited on .gov domains.</a></p>
<h2>What is the purpose of your requested domain?</h2>
{% endblock %}
{% block form_required_fields_help_text %}
@ -20,6 +19,7 @@
{{forms.3.management_form}}
{{forms.4.management_form}}
{{forms.5.management_form}}
<h2>What is the purpose of your requested domain?</h2>
<p class="margin-bottom-0 margin-top-1">
<em>Select one. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
</p>
@ -74,6 +74,7 @@
</div>
</fieldset>
{% else %}
<h2>What is the purpose of your requested domain?</h2>
<p>Describe how youll use your .gov domain. Will it be used for a website, email, or something else?</p>
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %}

View file

@ -15,14 +15,14 @@
{% include "includes/form_messages.html" %}
{% endblock %}
<h1>Manage your domains</h1>
<p class="margin-top-4">
<a href="{% url 'domain-request:start' %}" class="usa-button"
>
Start a new domain request
</a>
</p>
<div class="grid-row margin-bottom-3">
<h1 class="flex-fill">Manage your domains</h1>
<div>
<button data-href="{% url 'domain-request:start' %}" class="usa-button use-button-as-link">
Start a new domain request
</button>
</div>
</div>
{% include "includes/domains_table.html" with user_domain_count=user_domain_count %}
{% include "includes/domain_requests_table.html" %}

View file

@ -4,54 +4,20 @@
{% url 'get_domain_requests_json' as url %}
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
<section class="section-outlined domain-requests {% if portfolio %}section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %}section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
<section class="section-outlined domain-requests {% if portfolio %}margin-top-0 section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="section-outlined__header margin-bottom-3 grid-row">
{% if not portfolio %}
<h2 id="domain-requests-header" class="display-inline-block">Domain requests</h2>
<div class="grid-row grid-col-12">
<h2 id="domain-requests-header" class="display-inline-block flex-fill">Domain requests</h2>
</div>
{% else %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %}
<div class="section-outlined__search section-outlined__search--widescreen {% if portfolio %}mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domain requests search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domain-requests__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>
<label id="domain-requests__search-label" class="usa-sr-only" for="domain-requests__search-field">
{% if portfolio %}
Search by domain name or creator
{% else %}
Search by domain name
{% endif %}
</label>
<input
class="usa-input"
id="domain-requests__search-field"
type="search"
name="domain-requests-search"
{% if portfolio %}
placeholder="Search by domain name or creator"
{% else %}
placeholder="Search by domain name"
{% endif %}
/>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit" aria-labelledby="domain-requests__search-label">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
<!-- ---------- SEARCH ---------- -->
{% with label_text=portfolio|yesno:"Search by domain name or creator,Search by domain name" item_name="domain-requests" aria_label_text="Domain requests search component"%}
{% include "includes/search.html" %}
{% endwith %}
</div>
{% if portfolio %}

View file

@ -26,54 +26,32 @@
{% endif %}
<section class="section-outlined domains margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domains">
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
<div class="section-outlined__header margin-bottom-3 grid-row">
{% if not portfolio %}
<h2 id="domains-header" class="display-inline-block">Domains</h2>
<div class="grid-row grid-col-12">
<h2 id="domains-header" class="display-inline-block flex-fill">Domains</h2>
<!-- ---------- EXPORT (non-org placement) ---------- -->
{% if user_domain_count and user_domain_count > 0 %}
{% with export_aria="Domains report component" export_url='export_data_type_user' %}
{% include "includes/export.html" %}
{% endwith %}
{% endif %}
</div>
{% else %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %}
<div class="section-outlined__search section-outlined__search--widescreen {% if portfolio %}mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="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>
<label id="domains__search-label" class="usa-sr-only" for="domains__search-field">Search by domain name</label>
<input
class="usa-input"
id="domains__search-field"
type="search"
name="domains-search"
placeholder="Search by domain name"
/>
<button class="usa-button" type="submit" id="domains__search-field-submit" aria-labelledby="domains__search-label">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
{% if user_domain_count and user_domain_count > 0 %}
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right">
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV
</a>
</section>
</div>
<!-- ---------- SEARCH ---------- -->
{% with label_text="Search by domain name" item_name="domains" aria_label_text="Domains search component"%}
{% include "includes/search.html" %}
{% endwith %}
<!-- ---------- EXPORT (org placement) ---------- -->
{% if user_domain_count and user_domain_count > 0 and portfolio%}
{% with export_aria="Domains report component" export_url='export_data_type_user' %}
{% include "includes/export.html" %}
{% endwith %}
{% endif %}
</div>
<!-- Non org model banner -->
{% if num_expiring_domains > 0 and not portfolio %}
<section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">

View file

@ -0,0 +1,12 @@
{% load static %}
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} flex-auto desktop:padding-left-3{% endif %} margin-top-0">
<section aria-label="{{export_aria}}" class="margin-top-205">
<button data-href="{% url export_url %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right use-button-as-link">
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV
</button>
</section>
</div>

View file

@ -3,14 +3,16 @@
{% for error in form.non_field_errors %}
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body">
{{ error|escape }}
<span class="usa-sr-only">Error:</span>
{{ error|escape }}
</div>
</div>
{% endfor %}
{% for field in form %}
{% for error in field.errors %}
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2">
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body">
<span class="usa-sr-only">Error:</span>
{{ error|escape }}
</div>
</div>

View file

@ -1,6 +1,6 @@
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2">
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body {% if no_max_width %} maxw-none {% endif %}">
{{ message }}
</div>

View file

@ -91,9 +91,9 @@
aria-describedby="You have unsaved changes that will be lost."
>
{% if portfolio_permission %}
{% url 'member-domains' pk=portfolio_permission.id as url %}
{% url 'member-domains' member_pk=portfolio_permission.id as url %}
{% else %}
{% url 'invitedmember-domains' pk=portfolio_invitation.id as url %}
{% url 'invitedmember-domains' invitedmember_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" %}

View file

@ -7,45 +7,14 @@
<span id="get_members_json_url" class="display-none">{{url}}</span>
<section class="section-outlined members margin-top-0 section-outlined--border-base-light" id="members">
<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-outlined__search--widescreen">
<section aria-label="Members search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="members__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>
<label class="usa-sr-only" for="members__search-field">Search by member name</label>
<input
class="usa-input"
id="members__search-field"
type="search"
name="members-search"
placeholder="Search by member name"
/>
<button class="usa-button" type="submit" id="members__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_members_portfolio' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right">
<svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV
</a>
</section>
</div>
</div>
<!-- ---------- SEARCH ---------- -->
{% with label_text="Search by member name" item_name="members" aria_label_text="Members search component"%}
{% include "includes/search.html" %}
{% endwith %}
{% with export_aria="Members report component" export_url='export_members_portfolio' %}
{% include "includes/export.html" %}
{% endwith %}
</div>
<!-- ---------- MAIN TABLE ---------- -->
<div class="display-none margin-top-0" id="members__table-wrapper">

View file

@ -75,7 +75,7 @@
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
>
Cancel
{% if cancel_button_text %}{{ cancel_button_text }}{% else %}Cancel{% endif %}
</button>
{% endif %}
</li>

View file

@ -1,26 +1,28 @@
{% load static %}
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
<div class="section-outlined__search tablet:grid-col">
<section aria-label="{{aria_label_text}}">
<form class="usa-search usa-search--show-label" method="POST" role="search">
<form class="usa-search {% if use_search_icon %} usa-search--small {% else %} usa-search--default {% endif %}usa-search--show-label" method="POST" role="search">
{% csrf_token %}
<label class="usa-label display-block margin-bottom-05 maxw-none" for="{{item_name}}__search-field">
<label id="{{item_name}}__search-label" class="usa-label display-block maxw-none margin-top-2 margin-bottom-1" for="{{item_name}}__search-field">
{{ label_text }}
</label>
<div class="usa-search--show-label__input-wrapper flex-align-self-end">
<div class="usa-search--show-label__input-wrapper">
<input
class="usa-input minw-15"
id="{{item_name}}__search-field"
type="search"
name="{{item_name}}-search"
/>
<button class="usa-button" type="submit" id="{{item_name}}__search-field-submit">
<span class="usa-search__submit-text">Search </span>
<button class="usa-button" type="submit" id="{{item_name}}__search-field-submit" aria-labelledby="{{item_name}}__search-label">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
{% if not use_search_icon %}
<span class="usa-search__submit-text">Search </span>
{% endif %}
</button>
<button class="usa-button usa-button--unstyled margin-left-3 display-none flex-1" id="{{item_name}}__reset-search" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">

View file

@ -15,11 +15,11 @@
{% 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 %}
{% url 'member' member_pk=portfolio_permission.id as url2 %}
{% url 'member-domains-edit' member_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 %}
{% url 'invitedmember' invitedmember_pk=portfolio_invitation.id as url2 %}
{% url 'invitedmember-domains-edit' invitedmember_pk=portfolio_invitation.id as url3 %}
{% endif %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list">

View file

@ -15,11 +15,11 @@
{% url 'members' as url %}
{% if portfolio_permission %}
{% url 'member' pk=portfolio_permission.id as url2 %}
{% url 'member-domains' pk=portfolio_permission.id as url3 %}
{% url 'member' member_pk=portfolio_permission.id as url2 %}
{% url 'member-domains' member_pk=portfolio_permission.id as url3 %}
{% else %}
{% url 'invitedmember' pk=portfolio_invitation.id as url2 %}
{% url 'invitedmember-domains' pk=portfolio_invitation.id as url3 %}
{% url 'invitedmember' invitedmember_pk=portfolio_invitation.id as url2 %}
{% url 'invitedmember-domains' invitedmember_pk=portfolio_invitation.id as url3 %}
{% endif %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list">

View file

@ -20,9 +20,9 @@
<!-- Navigation breadcrumbs -->
{% url 'members' as url %}
{% if portfolio_permission %}
{% url 'member' pk=portfolio_permission.id as url2 %}
{% url 'member' member_pk=portfolio_permission.id as url2 %}
{% else %}
{% url 'invitedmember' pk=invitation.id as url2 %}
{% url 'invitedmember' invitedmember_pk=invitation.id as url2 %}
{% endif %}
<nav class="usa-breadcrumb padding-top-0 bg-gray-1" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list">

View file

@ -16,28 +16,26 @@
{% endblock messages%}
<div id="main-content">
<h1 id="domain-requests-header" class="margin-bottom-1">Domain requests</h1>
<div class="grid-row grid-gap">
<div class="grid-row grid-gap margin-bottom-3">
<div class="mobile:grid-col-12 tablet:grid-col-6">
<h1 id="domain-requests-header" class="margin-bottom-1">Domain requests</h1>
<p class="margin-y-0">Domain requests can only be modified by the person who created the request.</p>
</div>
{% if has_edit_request_portfolio_permission %}
<div class="mobile:grid-col-12 tablet:grid-col-6">
<p class="margin-y-0">Domain requests can only be modified by the person who created the request.</p>
</div>
<div class="mobile:grid-col-12 tablet:grid-col-6">
<p class="float-right-tablet tablet:margin-y-0">
<a href="{% url 'domain-request:start' %}" class="usa-button"
<button data-href="{% url 'domain-request:start' %}" class="usa-button use-button-as-link"
>
Start a new domain request
</a>
</button>
</p>
</div>
{% else %}
<p class="margin-y-0">Domain requests can only be modified by the person who created the request.</p>
{% endif %}
</div>
{% include "includes/domain_requests_table.html" with portfolio=portfolio %}
</div>
{% endblock %}

View file

@ -16,7 +16,7 @@ Edit your User Profile |
<div class="desktop:grid-col-8 desktop:grid-offset-2">
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-3">
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-3" role="alert">
<div class="usa-alert__body">
{{ message }}
</div>

View file

@ -173,6 +173,10 @@ def input_with_errors(context, field=None): # noqa: C901
if aria_labels:
context["aria_label"] = " ".join(aria_labels)
# Conditionally add the data-initial-value attribute
if context.get("add_initial_value_attr", False):
attrs["data-initial-value"] = field.initial or ""
# ask Django to give us the widget dict
# see Widget.get_context() on
# https://docs.djangoproject.com/en/4.1/ref/forms/widgets

View file

@ -1449,7 +1449,7 @@ class MockEppLib(TestCase):
)
infoDomainThreeHosts = fakedEppObject(
"my-nameserver.gov",
"threenameserversdomain.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=[
@ -1460,7 +1460,7 @@ class MockEppLib(TestCase):
)
infoDomainFourHosts = fakedEppObject(
"fournameserversDomain.gov",
"fournameserversdomain.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=[
@ -1471,6 +1471,47 @@ class MockEppLib(TestCase):
],
)
infoDomainTwelveHosts = fakedEppObject(
"twelvenameserversdomain.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=[
"ns1.my-nameserver-1.com",
"ns1.my-nameserver-2.com",
"ns1.cats-are-superior3.com",
"ns1.explosive-chicken-nuggets.com",
"ns5.example.com",
"ns6.example.com",
"ns7.example.com",
"ns8.example.com",
"ns9.example.com",
"ns10.example.com",
"ns11.example.com",
"ns12.example.com",
],
)
infoDomainThirteenHosts = fakedEppObject(
"thirteennameserversdomain.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[],
hosts=[
"ns1.my-nameserver-1.com",
"ns1.my-nameserver-2.com",
"ns1.cats-are-superior3.com",
"ns1.explosive-chicken-nuggets.com",
"ns5.example.com",
"ns6.example.com",
"ns7.example.com",
"ns8.example.com",
"ns9.example.com",
"ns10.example.com",
"ns11.example.com",
"ns12.example.com",
"ns13.example.com",
],
)
infoDomainNoHost = fakedEppObject(
"my-nameserver.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
@ -1587,6 +1628,26 @@ class MockEppLib(TestCase):
],
)
noNameserver = fakedEppObject(
"nonameserver.com",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
contacts=[
common.DomainContact(
contact="securityContact",
type=PublicContact.ContactTypeChoices.SECURITY,
),
common.DomainContact(
contact="technicalContact",
type=PublicContact.ContactTypeChoices.TECHNICAL,
),
common.DomainContact(
contact="adminContact",
type=PublicContact.ContactTypeChoices.ADMINISTRATIVE,
),
],
hosts=[],
)
infoDomainCheckHostIPCombo = fakedEppObject(
"nameserversubdomain.gov",
cr_date=make_aware(datetime(2023, 5, 25, 19, 45, 35)),
@ -1801,10 +1862,13 @@ class MockEppLib(TestCase):
"freeman.gov": (self.InfoDomainWithContacts, None),
"threenameserversdomain.gov": (self.infoDomainThreeHosts, None),
"fournameserversdomain.gov": (self.infoDomainFourHosts, None),
"twelvenameserversdomain.gov": (self.infoDomainTwelveHosts, None),
"thirteennameserversdomain.gov": (self.infoDomainThirteenHosts, None),
"defaultsecurity.gov": (self.InfoDomainWithDefaultSecurityContact, None),
"adomain2.gov": (self.InfoDomainWithVerisignSecurityContact, None),
"defaulttechnical.gov": (self.InfoDomainWithDefaultTechnicalContact, None),
"justnameserver.com": (self.justNameserver, None),
"nonameserver.com": (self.noNameserver, None),
"meoward.gov": (self.mockDataInfoDomainSubdomain, None),
"meow.gov": (self.mockDataInfoDomainSubdomainAndIPAddress, None),
"fakemeow.gov": (self.mockDataInfoDomainNotSubdomainNoIP, None),

View file

@ -2,6 +2,7 @@ from datetime import datetime
from django.utils import timezone
from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite
from registrar import models
from registrar.utility.email import EmailSendingError
from registrar.utility.errors import MissingEmailError
from waffle.testutils import override_flag
@ -19,6 +20,7 @@ from registrar.admin import (
MyHostAdmin,
PortfolioInvitationAdmin,
UserDomainRoleAdmin,
UserPortfolioPermissionsForm,
VerifiedByStaffAdmin,
FsmModelResource,
WebsiteAdmin,
@ -175,7 +177,7 @@ class TestDomainInvitationAdmin(WebTest):
# Test for a description snippet
self.assertContains(
response, "Domain invitations contain all individuals who have been invited to manage a .gov domain."
response, "This table contains all individuals who have been invited to manage a .gov domain."
)
self.assertContains(response, "Show more")
@ -199,7 +201,7 @@ class TestDomainInvitationAdmin(WebTest):
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a domain here, it will trigger emails to the invitee and all managers of the domain",
"If you invite someone to a domain here, it will trigger email notifications.",
)
@less_console_noise_decorator
@ -217,12 +219,12 @@ class TestDomainInvitationAdmin(WebTest):
# Assert that the filters are added
self.assertContains(response, "invited", count=5)
self.assertContains(response, "Invited", count=2)
self.assertContains(response, "retrieved", count=2)
self.assertContains(response, "retrieved", count=3)
self.assertContains(response, "Retrieved", count=2)
# Check for the HTML context specificially
invited_html = '<a href="?status__exact=invited">Invited</a>'
retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>'
invited_html = '<a id="status-filter-invited" href="?status__exact=invited">Invited</a>'
retrieved_html = '<a id="status-filter-retrieved" href="?status__exact=retrieved">Retrieved</a>'
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
@ -1166,7 +1168,7 @@ class TestUserPortfolioPermissionAdmin(TestCase):
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a portfolio here, it will not trigger an invitation email.",
"If you add someone to a portfolio here, it won't trigger any email notifications.",
)
@less_console_noise_decorator
@ -1181,7 +1183,7 @@ class TestUserPortfolioPermissionAdmin(TestCase):
response = self.client.get(delete_url)
# Check if the response contains the expected static message
expected_message = "If you remove someone from a portfolio here, it will not send any emails"
expected_message = "If you remove someone from a portfolio here, it won't trigger any email notifications."
self.assertIn(expected_message, response.content.decode("utf-8"))
@ -1230,7 +1232,7 @@ class TestPortfolioInvitationAdmin(TestCase):
# Test for a description snippet
self.assertContains(
response,
"Portfolio invitations contain all individuals who have been invited to become members of an organization.",
"This table contains all individuals who have been invited to become members of a portfolio.",
)
self.assertContains(response, "Show more")
@ -1254,7 +1256,7 @@ class TestPortfolioInvitationAdmin(TestCase):
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a portfolio here, it will trigger an invitation email when you click",
"If you invite someone to a portfolio here, it will trigger email notifications.",
)
@less_console_noise_decorator
@ -1269,14 +1271,14 @@ class TestPortfolioInvitationAdmin(TestCase):
)
# Assert that the filters are added
self.assertContains(response, "invited", count=4)
self.assertContains(response, "invited", count=5)
self.assertContains(response, "Invited", count=2)
self.assertContains(response, "retrieved", count=2)
self.assertContains(response, "retrieved", count=3)
self.assertContains(response, "Retrieved", count=2)
# Check for the HTML context specificially
invited_html = '<a href="?status__exact=invited">Invited</a>'
retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>'
invited_html = '<a id="status-filter-invited" href="?status__exact=invited">Invited</a>'
retrieved_html = '<a id="status-filter-retrieved" href="?status__exact=retrieved">Retrieved</a>'
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
@ -1638,6 +1640,143 @@ class TestPortfolioInvitationAdmin(TestCase):
self.assertIn(expected_message, response.content.decode("utf-8"))
class PortfolioPermissionsFormTest(TestCase):
def setUp(self):
# Create a mock portfolio for testing
self.user = create_test_user()
self.portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test Portfolio", creator=self.user)
def tearDown(self):
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
def test_form_valid_with_required_fields(self):
"""Test that the form is valid when required fields are filled correctly."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"request_permissions": "view_all_requests",
"domain_permissions": "view_all_domains",
"member_permissions": "view_members",
"user": self.user.id,
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
self.assertTrue(form.is_valid())
def test_form_invalid_without_role(self):
"""Test that the form is invalid if role is missing."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"portfolio": self.portfolio.id,
"role": "", # Missing role
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
self.assertFalse(form.is_valid())
self.assertIn("role", form.errors)
def test_member_role_preserves_permissions(self):
"""Ensure that selecting 'organization_member' keeps the additional permissions."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"request_permissions": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
"member_permissions": UserPortfolioPermissionChoices.VIEW_MEMBERS,
"portfolio": self.portfolio.id,
"user": self.user.id,
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
# Check if form is valid
self.assertTrue(form.is_valid())
# Test if permissions are correctly preserved
cleaned_data = form.cleaned_data
self.assertIn(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, cleaned_data["request_permissions"])
self.assertIn(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, cleaned_data["domain_permissions"])
def test_admin_role_clears_permissions(self):
"""Ensure that selecting 'organization_admin' clears additional permissions."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"request_permissions": "view_all_requests",
"domain_permissions": "view_all_domains",
"member_permissions": "view_members",
"user": self.user.id,
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
self.assertTrue(form.is_valid())
# Simulate form save to check cleaned data behavior
cleaned_data = form.clean()
self.assertEqual(cleaned_data["role"], UserPortfolioRoleChoices.ORGANIZATION_ADMIN)
self.assertNotIn("request_permissions", cleaned_data["additional_permissions"]) # Permissions should be removed
self.assertNotIn("domain_permissions", cleaned_data["additional_permissions"])
self.assertNotIn("member_permissions", cleaned_data["additional_permissions"])
def test_invalid_permission_choice(self):
"""Ensure invalid permissions are not accepted."""
# Mock the instance or use a test instance
test_instance = models.UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
],
)
form_data = {
"portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"request_permissions": "invalid_permission", # Invalid choice
}
form = UserPortfolioPermissionsForm(data=form_data, instance=test_instance)
self.assertFalse(form.is_valid())
self.assertIn("request_permissions", form.errors)
class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user
@ -2186,7 +2325,7 @@ class TestUserDomainRoleAdmin(WebTest):
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a domain here, it will not trigger any emails.",
"If you add someone to a domain here, it won't trigger any email notifications.",
)
def test_domain_sortable(self):
@ -3560,10 +3699,10 @@ class TestPortfolioAdmin(TestCase):
display_admins = self.admin.display_admins(self.portfolio)
url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={self.portfolio.id}"
self.assertIn(f'<a href="{url}">2 administrators</a>', display_admins)
self.assertIn(f'<a href="{url}">2 admins</a>', display_admins)
display_members = self.admin.display_members(self.portfolio)
self.assertIn(f'<a href="{url}">2 members</a>', display_members)
self.assertIn(f'<a href="{url}">2 basic members</a>', display_members)
@less_console_noise_decorator
def test_senior_official_readonly_for_federal_org(self):

View file

@ -888,8 +888,8 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
csv_content = csv_file.read()
expected_content = (
# Header
"Email,Organization admin,Invited by,Joined date,Last active,Domain requests,"
"Member management,Domain management,Number of domains,Domains\n"
"Email,Member access,Invited by,Joined date,Last active,Domain requests,"
"Members,Domains,Number domains assigned,Domain assignments\n"
# Content
"big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None,"
"Viewer,True,1,cdomain1.gov\n"

View file

@ -0,0 +1,355 @@
from django.test import Client
from django.urls import reverse
from waffle.testutils import override_flag
from registrar.tests.common import (
MockDbForIndividualTests,
less_console_noise_decorator,
completed_domain_request,
)
from registrar.models import (
DomainRequest,
Portfolio,
UserPortfolioPermission,
PortfolioInvitation,
)
from registrar.models.utility.portfolio_helper import (
UserPortfolioRoleChoices,
UserPortfolioPermissionChoices,
)
from registrar.decorators import (
_domain_exists_under_portfolio,
_domain_request_exists_under_portfolio,
_member_exists_under_portfolio,
_member_invitation_exists_under_portfolio,
)
class TestPortfolioResourceAccess(MockDbForIndividualTests):
"""Test functions that verify resources belong to a portfolio.
More specifically, this function tests our helper utilities in decorators.py"""
def setUp(self):
super().setUp()
# Create portfolios
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
self.other_portfolio = Portfolio.objects.create(
creator=self.custom_staffuser, organization_name="Other Portfolio"
)
# Create domain requests
self.domain_request = completed_domain_request(name="eggnog.gov", user=self.user, portfolio=self.portfolio)
self.other_domain_request = completed_domain_request(
name="christmas.gov", user=self.tired_user, portfolio=self.other_portfolio
)
# Create domains
self.approved_domain_request_1 = completed_domain_request(
name="done_1.gov",
user=self.tired_user,
portfolio=self.portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
)
self.approved_domain_request_2 = completed_domain_request(
name="done_2.gov",
user=self.tired_user,
portfolio=self.other_portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
)
self.approved_domain_request_1.approve()
self.approved_domain_request_2.approve()
self.domain = self.approved_domain_request_1.approved_domain
self.other_domain = self.approved_domain_request_2.approved_domain
# Create portfolio permissions
self.user_permission = UserPortfolioPermission.objects.create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
self.other_user_permission = UserPortfolioPermission.objects.create(
user=self.tired_user, portfolio=self.other_portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Create portfolio invitations
self.portfolio_invitation = PortfolioInvitation.objects.create(
email="invited@example.com",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
)
self.other_portfolio_invitation = PortfolioInvitation.objects.create(
email="other-invited@example.com",
portfolio=self.other_portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
status=PortfolioInvitation.PortfolioInvitationStatus.INVITED,
)
# Domain request tests
@less_console_noise_decorator
def test_domain_request_exists_under_portfolio_when_pk_is_none(self):
"""Check behavior when the PK is None."""
self.assertTrue(_domain_request_exists_under_portfolio(self.portfolio, None))
@less_console_noise_decorator
def test_domain_request_exists_under_portfolio_when_exists(self):
"""Verify returns True when the domain request exists under the portfolio."""
self.assertTrue(_domain_request_exists_under_portfolio(self.portfolio, self.domain_request.id))
@less_console_noise_decorator
def test_domain_request_exists_under_portfolio_when_not_exists(self):
"""Verify returns False when the domain request does not exist under the portfolio."""
self.assertFalse(_domain_request_exists_under_portfolio(self.portfolio, self.other_domain_request.id))
# Domain tests
@less_console_noise_decorator
def test_domain_exists_under_portfolio_when_pk_is_none(self):
"""Check behavior when the PK is None."""
self.assertTrue(_domain_exists_under_portfolio(self.portfolio, None))
@less_console_noise_decorator
def test_domain_exists_under_portfolio_when_exists(self):
"""Verify returns True when the domain exists under the portfolio."""
self.assertTrue(_domain_exists_under_portfolio(self.portfolio, self.domain.id))
@less_console_noise_decorator
def test_domain_exists_under_portfolio_when_not_exists(self):
"""Verify returns False when the domain does not exist under the portfolio."""
self.assertFalse(_domain_exists_under_portfolio(self.portfolio, self.other_domain.id))
# Member tests
@less_console_noise_decorator
def test_member_exists_under_portfolio_when_pk_is_none(self):
"""Check behavior when the PK is None."""
self.assertTrue(_member_exists_under_portfolio(self.portfolio, None))
@less_console_noise_decorator
def test_member_exists_under_portfolio_when_exists(self):
"""Verify returns True when the member exists under the portfolio."""
self.assertTrue(_member_exists_under_portfolio(self.portfolio, self.user_permission.id))
@less_console_noise_decorator
def test_member_exists_under_portfolio_when_not_exists(self):
"""Verify returns False when the member does not exist under the portfolio."""
self.assertFalse(_member_exists_under_portfolio(self.portfolio, self.other_user_permission.id))
# Member invitation tests
@less_console_noise_decorator
def test_member_invitation_exists_under_portfolio_when_pk_is_none(self):
"""Check behavior when the PK is None."""
self.assertTrue(_member_invitation_exists_under_portfolio(self.portfolio, None))
@less_console_noise_decorator
def test_member_invitation_exists_under_portfolio_when_exists(self):
"""Verify returns True when the member invitation exists under the portfolio."""
self.assertTrue(_member_invitation_exists_under_portfolio(self.portfolio, self.portfolio_invitation.id))
@less_console_noise_decorator
def test_member_invitation_exists_under_portfolio_when_not_exists(self):
"""Verify returns False when the member invitation does not exist under the portfolio."""
self.assertFalse(_member_invitation_exists_under_portfolio(self.portfolio, self.other_portfolio_invitation.id))
class TestPortfolioDomainRequestViewAccess(MockDbForIndividualTests):
"""Tests for domain request views to ensure users can only access domain requests in their portfolio."""
def setUp(self):
super().setUp()
self.client = Client()
self.client.force_login(self.user)
# Create portfolios
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
self.other_portfolio = Portfolio.objects.create(creator=self.tired_user, organization_name="Other Portfolio")
# Create domain requests
self.domain_request = completed_domain_request(
name="test-domain.gov",
portfolio=self.portfolio,
status=DomainRequest.DomainRequestStatus.STARTED,
user=self.user,
)
self.other_domain_request = completed_domain_request(
name="other-domain.gov",
portfolio=self.other_portfolio,
status=DomainRequest.DomainRequestStatus.STARTED,
user=self.tired_user,
)
# Give user permission to view all requests
self.user_permission = UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
# Setup session for portfolio views
session = self.client.session
session["portfolio"] = self.portfolio
session.save()
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_domain_request_view_same_portfolio(self):
"""Test that user can access domain requests in their portfolio."""
# With just the view all permission, access should be denied
response = self.client.get(reverse("edit-domain-request", kwargs={"domain_request_pk": self.domain_request.pk}))
self.assertEqual(response.status_code, 403)
# But with the edit permission, the user should be able to access this domain request
self.user_permission.additional_permissions = [
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
]
self.user_permission.save()
self.user_permission.refresh_from_db()
response = self.client.get(
reverse("edit-domain-request", kwargs={"domain_request_pk": self.domain_request.pk}), follow=True
)
self.assertEqual(response.status_code, 200)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_domain_request_view_different_portfolio(self):
"""Test that user cannot access domain request not in their portfolio."""
response = self.client.get(
reverse("edit-domain-request", kwargs={"domain_request_pk": self.other_domain_request.pk})
)
self.assertEqual(response.status_code, 403)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_domain_request_viewonly_same_portfolio(self):
"""Test that user can access view-only domain request in their portfolio."""
response = self.client.get(
reverse("domain-request-status-viewonly", kwargs={"domain_request_pk": self.domain_request.pk})
)
self.assertEqual(response.status_code, 200)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
def test_domain_request_viewonly_different_portfolio(self):
"""Test that user cannot access view-only domain request not in their portfolio."""
response = self.client.get(
reverse("domain-request-status-viewonly", kwargs={"domain_request_pk": self.other_domain_request.pk})
)
self.assertEqual(response.status_code, 403)
class TestPortfolioDomainViewAccess(MockDbForIndividualTests):
"""Tests for domain views to ensure users can only access domains in their portfolio."""
def setUp(self):
super().setUp()
self.client = Client()
self.client.force_login(self.user)
# Create portfolios
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
self.other_portfolio = Portfolio.objects.create(creator=self.tired_user, organization_name="Other Portfolio")
# Create domains through domain requests
self.domain_request = completed_domain_request(
name="test-domain.gov",
portfolio=self.portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
user=self.user,
)
self.domain_request.approve()
self.domain = self.domain_request.approved_domain
self.other_domain_request = completed_domain_request(
name="other-domain.gov",
portfolio=self.other_portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
user=self.user,
)
self.other_domain_request.approve()
self.other_domain = self.other_domain_request.approved_domain
# Give user permission to view all domains
self.user_permission = UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS],
)
# Setup session for portfolio views
session = self.client.session
session["portfolio"] = self.portfolio
session.save()
@override_flag("organization_feature", active=True)
@less_console_noise_decorator
def test_domain_view_same_portfolio(self):
"""Test that user can access domain in their portfolio."""
response = self.client.get(reverse("domain", kwargs={"domain_pk": self.domain.pk}))
self.assertEqual(response.status_code, 200)
@override_flag("organization_feature", active=True)
@less_console_noise_decorator
def test_domain_view_different_portfolio(self):
"""Test that user cannot access domain not in their portfolio."""
response = self.client.get(reverse("domain", kwargs={"domain_pk": self.other_domain.pk}))
self.assertEqual(response.status_code, 403)
class TestPortfolioMemberViewAccess(MockDbForIndividualTests):
"""Tests for member views to ensure users can only access members in their portfolio."""
def setUp(self):
super().setUp()
self.client = Client()
self.client.force_login(self.user)
# Create portfolios
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
self.other_portfolio = Portfolio.objects.create(creator=self.tired_user, organization_name="Other Portfolio")
# Create portfolio permissions
self.member_permission = UserPortfolioPermission.objects.create(
user=self.meoward_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
self.other_member_permission = UserPortfolioPermission.objects.create(
user=self.lebowski_user,
portfolio=self.other_portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Give user permission to view/edit members
self.user_permission = UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
# Setup session for portfolio views
session = self.client.session
session["portfolio"] = self.portfolio
session.save()
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@less_console_noise_decorator
def test_member_view_same_portfolio(self):
"""Test that user can access member in their portfolio."""
response = self.client.get(reverse("member", kwargs={"member_pk": self.member_permission.pk}))
self.assertEqual(response.status_code, 200)
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@less_console_noise_decorator
def test_member_view_different_portfolio(self):
"""Test that user cannot access member not in their portfolio."""
response = self.client.get(reverse("member", kwargs={"member_pk": self.other_member_permission.pk}))
self.assertEqual(response.status_code, 403)

View file

@ -29,6 +29,8 @@ SAMPLE_KWARGS = {
"user_pk": "1",
"portfolio_id": "1",
"user_id": "1",
"member_pk": "1",
"invitedmember_pk": "1",
}
# Our test suite will ignore some namespaces.

View file

@ -59,6 +59,7 @@ class TestWithDomainPermissions(TestWithUser):
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.domain_with_ip, _ = Domain.objects.get_or_create(name="nameserverwithip.gov")
self.domain_just_nameserver, _ = Domain.objects.get_or_create(name="justnameserver.com")
self.domain_no_nameserver, _ = Domain.objects.get_or_create(name="nonameserver.com")
self.domain_no_information, _ = Domain.objects.get_or_create(name="noinformation.gov")
self.domain_on_hold, _ = Domain.objects.get_or_create(
name="on-hold.gov",
@ -84,17 +85,23 @@ class TestWithDomainPermissions(TestWithUser):
# We could simply use domain (igorville) but this will be more readable in tests
# that inherit this setUp
self.domain_dnssec_none, _ = Domain.objects.get_or_create(name="dnssec-none.gov")
self.domain_with_four_nameservers, _ = Domain.objects.get_or_create(name="fournameserversDomain.gov")
self.domain_with_three_nameservers, _ = Domain.objects.get_or_create(name="threenameserversdomain.gov")
self.domain_with_four_nameservers, _ = Domain.objects.get_or_create(name="fournameserversdomain.gov")
self.domain_with_twelve_nameservers, _ = Domain.objects.get_or_create(name="twelvenameserversdomain.gov")
self.domain_with_thirteen_nameservers, _ = Domain.objects.get_or_create(name="thirteennameserversdomain.gov")
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dsdata)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_multdsdata)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dnssec_none)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_with_thirteen_nameservers)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_with_twelve_nameservers)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_with_four_nameservers)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_with_three_nameservers)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_with_ip)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_just_nameserver)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_no_nameserver)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_on_hold)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_deleted)
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dns_needed)
@ -119,11 +126,26 @@ class TestWithDomainPermissions(TestWithUser):
domain=self.domain_dnssec_none,
role=UserDomainRole.Roles.MANAGER,
)
UserDomainRole.objects.get_or_create(
user=self.user,
domain=self.domain_with_three_nameservers,
role=UserDomainRole.Roles.MANAGER,
)
UserDomainRole.objects.get_or_create(
user=self.user,
domain=self.domain_with_four_nameservers,
role=UserDomainRole.Roles.MANAGER,
)
UserDomainRole.objects.get_or_create(
user=self.user,
domain=self.domain_with_twelve_nameservers,
role=UserDomainRole.Roles.MANAGER,
)
UserDomainRole.objects.get_or_create(
user=self.user,
domain=self.domain_with_thirteen_nameservers,
role=UserDomainRole.Roles.MANAGER,
)
UserDomainRole.objects.get_or_create(
user=self.user,
domain=self.domain_with_ip,
@ -134,6 +156,11 @@ class TestWithDomainPermissions(TestWithUser):
domain=self.domain_just_nameserver,
role=UserDomainRole.Roles.MANAGER,
)
UserDomainRole.objects.get_or_create(
user=self.user,
domain=self.domain_no_nameserver,
role=UserDomainRole.Roles.MANAGER,
)
UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain_on_hold, role=UserDomainRole.Roles.MANAGER
)
@ -1479,11 +1506,17 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
Uses self.app WebTest because we need to interact with forms.
"""
# initial nameservers page has one server with two ips
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
nameservers_page = self.app.get(
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_no_nameserver.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# attempt to submit the form with only one nameserver, should error
# regarding required fields
nameservers_page.form["form-0-server"] = "ns1.nonameserver.com"
nameservers_page.form["form-0-ip"] = "127.0.0.1"
nameservers_page.form["form-1-server"] = ""
nameservers_page.form["form-1-ip"] = ""
result = nameservers_page.form.submit()
# form submission was a post with an error, response should be a 200
# error text appears twice, once at the top of the page, once around
@ -1722,53 +1755,91 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
"""
nameserver1 = ""
nameserver2 = "ns2.igorville.gov"
nameserver3 = "ns3.igorville.gov"
nameserver2 = "ns2.threenameserversdomain.gov"
nameserver3 = "ns3.threenameserversdomain.gov"
valid_ip = ""
valid_ip_2 = "128.0.0.2"
valid_ip_3 = "128.0.0.3"
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
valid_ip_2 = "128.8.8.1"
valid_ip_3 = "128.8.8.2"
nameservers_page = self.app.get(
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_three_nameservers.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
nameservers_page.form["form-0-server"] = nameserver1
nameservers_page.form["form-0-ip"] = valid_ip
nameservers_page.form["form-1-server"] = nameserver2
nameservers_page.form["form-1-ip"] = valid_ip_2
nameservers_page.form["form-2-server"] = nameserver3
nameservers_page.form["form-2-ip"] = valid_ip_3
result = nameservers_page.form.submit()
# webtest is not able to properly parse the form from nameservers_page, so manually
# inputting form data
form_data = {
"csrfmiddlewaretoken": nameservers_page.form["csrfmiddlewaretoken"].value,
"form-TOTAL_FORMS": "4",
"form-INITIAL_FORMS": "3",
"form-0-domain": "threenameserversdomain.gov",
"form-0-server": nameserver1,
"form-0-ip": valid_ip,
"form-1-domain": "threenameserversdomain.gov",
"form-1-server": nameserver2,
"form-1-ip": valid_ip_2,
"form-2-domain": "threenameserversdomain.gov",
"form-2-server": nameserver3,
"form-2-ip": valid_ip_3,
"form-3-domain": "threenameserversdomain.gov",
"form-3-server": "",
"form-3-ip": "",
}
result = self.app.post(
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_three_nameservers.id}), form_data
)
# form submission was a successful post, response should be a 302
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}),
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_three_nameservers.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
nameservers_page = result.follow()
self.assertContains(nameservers_page, "The name servers for this domain have been updated")
nameserver1 = "ns1.igorville.gov"
nameserver1 = "ns1.threenameserversdomain.gov"
nameserver2 = ""
nameserver3 = "ns3.igorville.gov"
nameserver3 = "ns3.threenameserversdomain.gov"
valid_ip = "128.0.0.1"
valid_ip_2 = ""
valid_ip_3 = "128.0.0.3"
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
nameservers_page.form["form-0-server"] = nameserver1
nameservers_page.form["form-0-ip"] = valid_ip
nameservers_page.form["form-1-server"] = nameserver2
nameservers_page.form["form-1-ip"] = valid_ip_2
nameservers_page.form["form-2-server"] = nameserver3
nameservers_page.form["form-2-ip"] = valid_ip_3
result = nameservers_page.form.submit()
# webtest is not able to properly parse the form from nameservers_page, so manually
# inputting form data
form_data = {
"csrfmiddlewaretoken": nameservers_page.form["csrfmiddlewaretoken"].value,
"form-TOTAL_FORMS": "4",
"form-INITIAL_FORMS": "3",
"form-0-domain": "threenameserversdomain.gov",
"form-0-server": nameserver1,
"form-0-ip": valid_ip,
"form-1-domain": "threenameserversdomain.gov",
"form-1-server": nameserver2,
"form-1-ip": valid_ip_2,
"form-2-domain": "threenameserversdomain.gov",
"form-2-server": nameserver3,
"form-2-ip": valid_ip_3,
"form-3-domain": "threenameserversdomain.gov",
"form-3-server": "",
"form-3-ip": "",
}
result = self.app.post(
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_three_nameservers.id}), form_data
)
# form submission was a successful post, response should be a 302
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}),
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_three_nameservers.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
nameservers_page = result.follow()
@ -1799,19 +1870,29 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Minimal check to ensure the form is loaded correctly
self.assertEqual(nameservers_page.form["form-0-server"].value, "ns1.my-nameserver-1.com")
self.assertEqual(nameservers_page.form["form-3-server"].value, "ns1.explosive-chicken-nuggets.com")
# webtest is not able to properly parse the form from nameservers_page, so manually
# inputting form data
form_data = {
"csrfmiddlewaretoken": nameservers_page.form["csrfmiddlewaretoken"].value,
"form-TOTAL_FORMS": "4",
"form-INITIAL_FORMS": "4",
"form-0-domain": "fournameserversdomain.gov",
"form-0-server": nameserver1,
"form-0-ip": valid_ip,
"form-1-domain": "fournameserversdomain.gov",
"form-1-server": nameserver2,
"form-1-ip": valid_ip_2,
"form-2-domain": "fournameserversdomain.gov",
"form-2-server": nameserver3,
"form-2-ip": valid_ip_3,
"form-3-domain": "fournameserversdomain.gov",
"form-3-server": nameserver4,
"form-3-ip": valid_ip_4,
}
nameservers_page.form["form-0-server"] = nameserver1
nameservers_page.form["form-0-ip"] = valid_ip
nameservers_page.form["form-1-server"] = nameserver2
nameservers_page.form["form-1-ip"] = valid_ip_2
nameservers_page.form["form-2-server"] = nameserver3
nameservers_page.form["form-2-ip"] = valid_ip_3
nameservers_page.form["form-3-server"] = nameserver4
nameservers_page.form["form-3-ip"] = valid_ip_4
result = nameservers_page.form.submit()
result = self.app.post(
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_four_nameservers.id}), form_data
)
# form submission was a successful post, response should be a 302
self.assertEqual(result.status_code, 302)
@ -1823,6 +1904,34 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
nameservers_page = result.follow()
self.assertContains(nameservers_page, "The name servers for this domain have been updated")
@less_console_noise_decorator
def test_domain_nameservers_12_entries(self):
"""Nameserver form does not present info alert when 12 enrties."""
nameservers_page = self.app.get(
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_twelve_nameservers.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
self.assertNotContains(
nameservers_page, "Youve reached the maximum amount of allowed name server records (13)."
)
@less_console_noise_decorator
def test_domain_nameservers_13_entries(self):
"""Nameserver form present3 info alert when 13 enrties."""
nameservers_page = self.app.get(
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_thirteen_nameservers.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
self.assertContains(nameservers_page, "Youve reached the maximum amount of allowed name server records (13).")
@less_console_noise_decorator
def test_domain_nameservers_form_invalid(self):
"""Nameserver form does not submit with invalid data.
@ -1837,12 +1946,12 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
nameservers_page.form["form-0-server"] = ""
result = nameservers_page.form.submit()
# form submission was a post with an error, response should be a 200
# error text appears four times, twice at the top of the page,
# once around each required field.
# error text appears twice, once at the top of the page,
# once around the required field.
self.assertContains(
result,
"At least two name servers are required.",
count=4,
count=2,
status_code=200,
)

View file

@ -867,7 +867,7 @@ class TestPortfolio(WebTest):
# Verify that the user cannot access the member page
self.client.force_login(self.user)
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
response = self.client.get(reverse("member", kwargs={"member_pk": 1}), follow=True)
# Make sure the page is denied
self.assertEqual(response.status_code, 403)
@ -886,7 +886,7 @@ class TestPortfolio(WebTest):
# Verify that the user cannot access the member page
self.client.force_login(self.user)
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
response = self.client.get(reverse("member", kwargs={"member_pk": 1}), follow=True)
# Make sure the page is denied
self.assertEqual(response.status_code, 403)
@ -909,7 +909,7 @@ class TestPortfolio(WebTest):
# Verify the page can be accessed
self.client.force_login(self.user)
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
response = self.client.get(reverse("member", kwargs={"member_pk": permission_obj.pk}), follow=True)
self.assertEqual(response.status_code, 200)
# Assert text within the page is correct
@ -942,7 +942,7 @@ class TestPortfolio(WebTest):
# Verify the page can be accessed
self.client.force_login(self.user)
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
response = self.client.get(reverse("member", kwargs={"member_pk": permission_obj.pk}), follow=True)
self.assertEqual(response.status_code, 200)
# Assert text within the page is correct
@ -966,7 +966,7 @@ class TestPortfolio(WebTest):
# Verify that the user cannot access the member page
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
response = self.client.get(reverse("invitedmember", kwargs={"invitedmember_pk": 1}), follow=True)
# Make sure the page is denied
self.assertEqual(response.status_code, 403)
@ -985,7 +985,7 @@ class TestPortfolio(WebTest):
# Verify that the user cannot access the member page
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
response = self.client.get(reverse("invitedmember", kwargs={"invitedmember_pk": 1}), follow=True)
# Make sure the page is denied
self.assertEqual(response.status_code, 403)
@ -1016,7 +1016,9 @@ class TestPortfolio(WebTest):
# Verify the page can be accessed
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
response = self.client.get(
reverse("invitedmember", kwargs={"invitedmember_pk": portfolio_invitation.pk}), follow=True
)
self.assertEqual(response.status_code, 200)
# Assert text within the page is correct
@ -1054,7 +1056,9 @@ class TestPortfolio(WebTest):
# Verify the page can be accessed
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
response = self.client.get(
reverse("invitedmember", kwargs={"invitedmember_pk": portfolio_invitation.pk}), follow=True
)
self.assertEqual(response.status_code, 200)
# Assert text within the page is correct
@ -1697,7 +1701,7 @@ class TestPortfolioMemberDeleteView(WebTest):
self.client.force_login(self.user)
# We check X_REQUESTED_WITH bc those return JSON responses
response = self.client.post(
reverse("member-delete", kwargs={"pk": upp.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
reverse("member-delete", kwargs={"member_pk": upp.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
)
self.assertEqual(response.status_code, 400) # Bad request due to active requests
@ -1738,7 +1742,8 @@ class TestPortfolioMemberDeleteView(WebTest):
self.client.force_login(self.user)
# We check X_REQUESTED_WITH bc those return JSON responses
response = self.client.post(
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
reverse("member-delete", kwargs={"member_pk": admin_perm_user.pk}),
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(response.status_code, 400)
@ -1795,7 +1800,7 @@ class TestPortfolioMemberDeleteView(WebTest):
self.client.force_login(self.user)
response = self.client.post(
# We check X_REQUESTED_WITH bc those return JSON responses
reverse("member-delete", kwargs={"pk": upp.pk}),
reverse("member-delete", kwargs={"member_pk": upp.pk}),
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
@ -1862,7 +1867,7 @@ class TestPortfolioMemberDeleteView(WebTest):
self.client.force_login(self.user)
response = self.client.post(
# We check X_REQUESTED_WITH bc those return JSON responses
reverse("member-delete", kwargs={"pk": upp.pk}),
reverse("member-delete", kwargs={"member_pk": upp.pk}),
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
@ -1939,7 +1944,7 @@ class TestPortfolioMemberDeleteView(WebTest):
self.client.force_login(self.user)
response = self.client.post(
# We check X_REQUESTED_WITH bc those return JSON responses
reverse("member-delete", kwargs={"pk": upp.pk}),
reverse("member-delete", kwargs={"member_pk": upp.pk}),
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
@ -2000,7 +2005,7 @@ class TestPortfolioMemberDeleteView(WebTest):
with patch("django.contrib.messages.error") as mock_error:
self.client.force_login(self.user)
response = self.client.post(
reverse("member-delete", kwargs={"pk": upp.pk}),
reverse("member-delete", kwargs={"member_pk": upp.pk}),
)
# We don't want to do follow=True in response bc that does automatic redirection
@ -2023,7 +2028,7 @@ class TestPortfolioMemberDeleteView(WebTest):
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
# and then confirm that we're still on the Manage Members page
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": upp.pk}))
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"member_pk": upp.pk}))
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@ -2047,7 +2052,7 @@ class TestPortfolioMemberDeleteView(WebTest):
with patch("django.contrib.messages.error") as mock_error:
self.client.force_login(self.user)
response = self.client.post(
reverse("member-delete", kwargs={"pk": admin_perm_user.pk}),
reverse("member-delete", kwargs={"member_pk": admin_perm_user.pk}),
)
self.assertEqual(response.status_code, 302)
@ -2066,7 +2071,9 @@ class TestPortfolioMemberDeleteView(WebTest):
# Location is used for a 3xx HTTP status code to indicate that the URL was redirected
# and then confirm that we're still on the Manage Members page
self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": admin_perm_user.pk}))
self.assertEqual(
response.headers["Location"], reverse("member", kwargs={"member_pk": admin_perm_user.pk})
)
class TestPortfolioInvitedMemberDeleteView(WebTest):
@ -2125,7 +2132,7 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
with patch("django.contrib.messages.success") as mock_success:
self.client.force_login(self.user)
response = self.client.post(
reverse("invitedmember-delete", kwargs={"pk": invitation.pk}),
reverse("invitedmember-delete", kwargs={"invitedmember_pk": invitation.pk}),
)
self.assertEqual(response.status_code, 302)
@ -2190,7 +2197,7 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
with patch("django.contrib.messages.success") as mock_success:
self.client.force_login(self.user)
response = self.client.post(
reverse("invitedmember-delete", kwargs={"pk": invitation.pk}),
reverse("invitedmember-delete", kwargs={"invitedmember_pk": invitation.pk}),
)
self.assertEqual(response.status_code, 302)
@ -2263,7 +2270,7 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
with patch("django.contrib.messages.success") as mock_success:
self.client.force_login(self.user)
response = self.client.post(
reverse("invitedmember-delete", kwargs={"pk": invitation.pk}),
reverse("invitedmember-delete", kwargs={"invitedmember_pk": invitation.pk}),
)
self.assertEqual(response.status_code, 302)
@ -2365,7 +2372,7 @@ class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
"""Tests that the portfolio member domains view is accessible."""
self.client.force_login(self.user)
response = self.client.get(reverse("member-domains", kwargs={"pk": self.permission.id}))
response = self.client.get(reverse("member-domains", kwargs={"member_pk": self.permission.id}))
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
@ -2378,7 +2385,7 @@ class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
"""Tests that the portfolio member domains view is not accessible to user with no perms."""
self.client.force_login(self.user_no_perms)
response = self.client.get(reverse("member-domains", kwargs={"pk": self.permission.id}))
response = self.client.get(reverse("member-domains", kwargs={"member_pk": self.permission.id}))
# Make sure the request returns forbidden
self.assertEqual(response.status_code, 403)
@ -2390,7 +2397,7 @@ class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
"""Tests that the portfolio member domains view is not accessible when no authenticated user."""
self.client.logout()
response = self.client.get(reverse("member-domains", kwargs={"pk": self.permission.id}))
response = self.client.get(reverse("member-domains", kwargs={"member_pk": self.permission.id}))
# Make sure the request returns redirect to openid login
self.assertEqual(response.status_code, 302) # Redirect to openid login
@ -2403,7 +2410,7 @@ class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
"""Tests that the portfolio member domains view returns not found if user portfolio permission not found."""
self.client.force_login(self.user)
response = self.client.get(reverse("member-domains", kwargs={"pk": "0"}))
response = self.client.get(reverse("member-domains", kwargs={"member_pk": "0"}))
# Make sure the response is not found
self.assertEqual(response.status_code, 404)
@ -2463,7 +2470,7 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
"""Tests that the portfolio invited member domains view is accessible."""
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember-domains", kwargs={"pk": self.invitation.id}))
response = self.client.get(reverse("invitedmember-domains", kwargs={"invitedmember_pk": self.invitation.id}))
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
@ -2476,7 +2483,7 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
"""Tests that the portfolio invited member domains view is not accessible to user with no perms."""
self.client.force_login(self.user_no_perms)
response = self.client.get(reverse("invitedmember-domains", kwargs={"pk": self.invitation.id}))
response = self.client.get(reverse("invitedmember-domains", kwargs={"invitedmember_pk": self.invitation.id}))
# Make sure the request returns forbidden
self.assertEqual(response.status_code, 403)
@ -2488,7 +2495,7 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
"""Tests that the portfolio invited member domains view is not accessible when no authenticated user."""
self.client.logout()
response = self.client.get(reverse("invitedmember-domains", kwargs={"pk": self.invitation.id}))
response = self.client.get(reverse("invitedmember-domains", kwargs={"invitedmember_pk": self.invitation.id}))
# Make sure the request returns redirect to openid login
self.assertEqual(response.status_code, 302) # Redirect to openid login
@ -2501,7 +2508,7 @@ class TestPortfolioInvitedMemberDomainsView(TestWithUser, WebTest):
"""Tests that the portfolio invited member domains view returns not found if user is not a member."""
self.client.force_login(self.user)
response = self.client.get(reverse("invitedmember-domains", kwargs={"pk": "0"}))
response = self.client.get(reverse("invitedmember-domains", kwargs={"invitedmember_pk": "0"}))
# Make sure the response is not found
self.assertEqual(response.status_code, 404)
@ -2566,7 +2573,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
],
)
# Create url to be used in all tests
self.url = reverse("member-domains-edit", kwargs={"pk": self.portfolio_permission.pk})
self.url = reverse("member-domains-edit", kwargs={"member_pk": self.portfolio_permission.pk})
def tearDown(self):
super().tearDown()
@ -2584,7 +2591,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
"""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}))
response = self.client.get(reverse("member-domains-edit", kwargs={"member_pk": self.permission.id}))
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
@ -2597,7 +2604,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
"""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}))
response = self.client.get(reverse("member-domains-edit", kwargs={"member_pk": self.permission.id}))
# Make sure the request returns forbidden
self.assertEqual(response.status_code, 403)
@ -2609,7 +2616,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
"""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}))
response = self.client.get(reverse("member-domains-edit", kwargs={"member_pk": self.permission.id}))
# Make sure the request returns redirect to openid login
self.assertEqual(response.status_code, 302) # Redirect to openid login
@ -2623,7 +2630,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
portfolio permission not found."""
self.client.force_login(self.user)
response = self.client.get(reverse("member-domains-edit", kwargs={"pk": "0"}))
response = self.client.get(reverse("member-domains-edit", kwargs={"member_pk": "0"}))
# Make sure the response is not found
self.assertEqual(response.status_code, 404)
@ -2645,7 +2652,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
self.assertEqual(UserDomainRole.objects.filter(user=self.user, role=UserDomainRole.Roles.MANAGER).count(), 3)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
self.assertRedirects(response, reverse("member-domains", kwargs={"member_pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
@ -2681,7 +2688,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
self.assertEqual(UserDomainRole.objects.filter(domain=self.domain3, user=self.user).count(), 1)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
self.assertRedirects(response, reverse("member-domains", kwargs={"member_pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
@ -2706,7 +2713,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0)
# Check for an error message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
self.assertRedirects(response, reverse("member-domains", kwargs={"member_pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
@ -2729,7 +2736,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0)
# Check for an error message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
self.assertRedirects(response, reverse("member-domains", kwargs={"member_pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
@ -2749,7 +2756,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0)
# Check for an info message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
self.assertRedirects(response, reverse("member-domains", kwargs={"member_pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
@ -2772,7 +2779,9 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
self.assertEqual(UserDomainRole.objects.filter(user=self.user, role=UserDomainRole.Roles.MANAGER).count(), 0)
# Check for an error message and a redirect to edit form
self.assertRedirects(response, reverse("member-domains-edit", kwargs={"pk": self.portfolio_permission.pk}))
self.assertRedirects(
response, reverse("member-domains-edit", kwargs={"member_pk": self.portfolio_permission.pk})
)
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
@ -2831,7 +2840,7 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
self.url = reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.pk})
self.url = reverse("invitedmember-domains-edit", kwargs={"invitedmember_pk": self.invitation.pk})
def tearDown(self):
super().tearDown()
@ -2849,7 +2858,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
"""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}))
response = self.client.get(
reverse("invitedmember-domains-edit", kwargs={"invitedmember_pk": self.invitation.id})
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
@ -2862,7 +2873,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
"""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}))
response = self.client.get(
reverse("invitedmember-domains-edit", kwargs={"invitedmember_pk": self.invitation.id})
)
# Make sure the request returns forbidden
self.assertEqual(response.status_code, 403)
@ -2874,7 +2887,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
"""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}))
response = self.client.get(
reverse("invitedmember-domains-edit", kwargs={"invitedmember_pk": self.invitation.id})
)
# Make sure the request returns redirect to openid login
self.assertEqual(response.status_code, 302) # Redirect to openid login
@ -2887,7 +2902,7 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
"""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"}))
response = self.client.get(reverse("invitedmember-domains-edit", kwargs={"invitedmember_pk": "0"}))
# Make sure the response is not found
self.assertEqual(response.status_code, 404)
@ -2914,7 +2929,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
self.assertRedirects(
response, reverse("invitedmember-domains", kwargs={"invitedmember_pk": self.invitation.pk})
)
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
@ -2971,7 +2988,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
self.assertRedirects(
response, reverse("invitedmember-domains", kwargs={"invitedmember_pk": self.invitation.pk})
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@ -3015,7 +3034,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
self.assertRedirects(
response, reverse("invitedmember-domains", kwargs={"invitedmember_pk": self.invitation.pk})
)
# assert that send_domain_invitation_email is not called
mock_send_domain_email.assert_not_called()
@ -3035,7 +3056,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
self.assertEqual(DomainInvitation.objects.count(), 0)
# Check for an error message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
self.assertRedirects(
response, reverse("invitedmember-domains", kwargs={"invitedmember_pk": self.invitation.pk})
)
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
@ -3058,7 +3081,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
self.assertEqual(DomainInvitation.objects.count(), 0)
# Check for an error message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
self.assertRedirects(
response, reverse("invitedmember-domains", kwargs={"invitedmember_pk": self.invitation.pk})
)
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
@ -3078,7 +3103,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
self.assertEqual(DomainInvitation.objects.count(), 0)
# Check for an info message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
self.assertRedirects(
response, reverse("invitedmember-domains", kwargs={"invitedmember_pk": self.invitation.pk})
)
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
@ -3106,7 +3133,9 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
)
# Check for an error message and a redirect to edit form
self.assertRedirects(response, reverse("invitedmember-domains-edit", kwargs={"pk": self.invitation.pk}))
self.assertRedirects(
response, reverse("invitedmember-domains-edit", kwargs={"invitedmember_pk": self.invitation.pk})
)
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
@ -3873,11 +3902,7 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
# verify messages
self.assertContains(
response,
(
"This user is already assigned to a portfolio invitation. "
"Based on current waffle flag settings, users cannot be assigned "
"to multiple portfolios."
),
f"{self.invited_member_email} has already been invited to another .gov organization.",
)
# Validate Database has not changed
@ -3915,11 +3940,7 @@ class TestPortfolioInviteNewMemberView(MockEppLib, WebTest):
# Verify messages
self.assertContains(
response,
(
"This user is already assigned to a portfolio. "
"Based on current waffle flag settings, users cannot be "
"assigned to multiple portfolios."
),
f"{self.user.email} is already a member of another .gov organization.",
)
# Validate Database has not changed
@ -4089,7 +4110,7 @@ class TestPortfolioMemberEditView(WebTest):
mock_send_update_email.return_value = True
response = self.client.post(
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
reverse("member-permissions", kwargs={"member_pk": basic_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
},
@ -4152,7 +4173,7 @@ class TestPortfolioMemberEditView(WebTest):
mock_send_update_email.return_value = False
response = self.client.post(
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
reverse("member-permissions", kwargs={"member_pk": basic_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
},
@ -4219,7 +4240,7 @@ class TestPortfolioMemberEditView(WebTest):
)
response = self.client.post(
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
reverse("member-permissions", kwargs={"member_pk": admin_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
},
@ -4257,7 +4278,7 @@ class TestPortfolioMemberEditView(WebTest):
mock_send_update_email.return_value = True
response = self.client.post(
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
reverse("member-permissions", kwargs={"member_pk": basic_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
@ -4306,7 +4327,7 @@ class TestPortfolioMemberEditView(WebTest):
mock_send_update_email.return_value = True
response = self.client.post(
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
reverse("member-permissions", kwargs={"member_pk": admin_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
@ -4369,7 +4390,7 @@ class TestPortfolioMemberEditView(WebTest):
mock_send_update_email.return_value = False
response = self.client.post(
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
reverse("member-permissions", kwargs={"member_pk": admin_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
@ -4429,7 +4450,7 @@ class TestPortfolioMemberEditView(WebTest):
# Test missing required admin permissions
response = self.client.post(
reverse("member-permissions", kwargs={"pk": permission.id}),
reverse("member-permissions", kwargs={"member_pk": permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
# Missing required admin fields
@ -4461,7 +4482,7 @@ class TestPortfolioMemberEditView(WebTest):
admin_permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio)
response = self.client.post(
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
reverse("member-permissions", kwargs={"member_pk": admin_permission.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
@ -4534,7 +4555,7 @@ class TestPortfolioInvitedMemberEditView(WebTest):
# Test updating invitation permissions
response = self.client.post(
reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}),
reverse("invitedmember-permissions", kwargs={"invitedmember_pk": self.invitation.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
},
@ -4577,7 +4598,7 @@ class TestPortfolioInvitedMemberEditView(WebTest):
# Test updating invitation permissions
response = self.client.post(
reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}),
reverse("invitedmember-permissions", kwargs={"invitedmember_pk": self.invitation.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
},
@ -4623,7 +4644,7 @@ class TestPortfolioInvitedMemberEditView(WebTest):
# Test updating invitation permissions
response = self.client.post(
reverse("invitedmember-permissions", kwargs={"pk": self.admin_invitation.id}),
reverse("invitedmember-permissions", kwargs={"invitedmember_pk": self.admin_invitation.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
@ -4669,7 +4690,7 @@ class TestPortfolioInvitedMemberEditView(WebTest):
# Test updating invitation permissions
response = self.client.post(
reverse("invitedmember-permissions", kwargs={"pk": self.admin_invitation.id}),
reverse("invitedmember-permissions", kwargs={"invitedmember_pk": self.admin_invitation.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
@ -4715,7 +4736,7 @@ class TestPortfolioInvitedMemberEditView(WebTest):
# Test updating invitation permissions
response = self.client.post(
reverse("invitedmember-permissions", kwargs={"pk": self.invitation.id}),
reverse("invitedmember-permissions", kwargs={"invitedmember_pk": self.invitation.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_permissions": UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS,
@ -4742,7 +4763,7 @@ class TestPortfolioInvitedMemberEditView(WebTest):
# Test updating invitation permissions
response = self.client.post(
reverse("invitedmember-permissions", kwargs={"pk": self.admin_invitation.id}),
reverse("invitedmember-permissions", kwargs={"invitedmember_pk": self.admin_invitation.id}),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
},

View file

@ -2975,7 +2975,7 @@ class DomainRequestTests(TestWithUser, WebTest):
self.assertEqual(intro_page.status_code, 200)
# This user should also be allowed to edit existing ones
domain_request = completed_domain_request(user=self.user)
domain_request = completed_domain_request(user=self.user, portfolio=portfolio)
edit_page = self.app.get(
reverse("edit-domain-request", kwargs={"domain_request_pk": domain_request.pk})
).follow()
@ -3083,7 +3083,9 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_REQUESTS],
)
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, user=self.user)
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.SUBMITTED, user=self.user, portfolio=portfolio
)
domain_request.save()
detail_page = self.app.get(f"/domain-request/{domain_request.id}")
@ -3223,13 +3225,17 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_REQUESTS],
)
domain_request.portfolio = portfolio
domain_request.save()
domain_request.refresh_from_db()
# Check portfolio-specific breadcrumb
portfolio_page = self.app.get(f"/domain-request/{domain_request.id}/edit/").follow()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
self.assertContains(portfolio_page, "Domain requests")
domain_request.portfolio = None
domain_request.save()
# Clean up portfolio
permission.delete()
portfolio.delete()
@ -3356,15 +3362,6 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
- The user does not see the Domain and Domain requests buttons
"""
# This should unlock 4 steps by default.
# Purpose, .gov domain, current websites, and requirements for operating
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED,
user=self.user,
)
domain_request.anything_else = None
domain_request.save()
federal_agency = FederalAgency.objects.get(agency="Non-Federal Agency")
# Add a portfolio
portfolio = Portfolio.objects.create(
@ -3382,6 +3379,14 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
],
)
# This should unlock 4 steps by default.
# Purpose, .gov domain, current websites, and requirements for operating
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED, user=self.user, portfolio=portfolio
)
domain_request.anything_else = None
domain_request.save()
response = self.app.get(f"/domain-request/{domain_request.id}/edit/")
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
@ -3426,6 +3431,8 @@ class TestDomainRequestWizard(TestWithUser, WebTest):
self.fail(f"Expected a redirect, but got a different response: {response}")
# Data cleanup
domain_request.portfolio = None
domain_request.save()
user_portfolio_permission.delete()
portfolio.delete()
federal_agency.delete()
@ -3490,7 +3497,9 @@ class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
dummy_user, _ = User.objects.get_or_create(username="testusername123456")
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.SUBMITTED, user=dummy_user)
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.SUBMITTED, user=dummy_user, portfolio=portfolio
)
domain_request.save()
detail_page = self.app.get(f"/domain-request/viewonly/{domain_request.id}")

View file

@ -38,10 +38,15 @@ from django.contrib.admin.models import LogEntry, ADDITION
from django.contrib.contenttypes.models import ContentType
from registrar.models.utility.generic_helper import convert_queryset_to_dict
from registrar.models.utility.orm_helper import ArrayRemoveNull
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.templatetags.custom_filters import get_region
from registrar.utility.constants import BranchChoices
from registrar.utility.enums import DefaultEmail, DefaultUserValues
from registrar.models.utility.portfolio_helper import (
get_role_display,
get_domain_requests_display,
get_domains_display,
get_members_display,
)
logger = logging.getLogger(__name__)
@ -479,15 +484,15 @@ class MemberExport(BaseExport):
"""
return [
"Email",
"Organization admin",
"Member access",
"Invited by",
"Joined date",
"Last active",
"Domain requests",
"Member management",
"Domain management",
"Number of domains",
"Members",
"Domains",
"Number domains assigned",
"Domain assignments",
]
@classmethod
@ -503,15 +508,15 @@ class MemberExport(BaseExport):
length_user_managed_domains = len(user_managed_domains)
FIELDS = {
"Email": model.get("email_display"),
"Organization admin": bool(UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles),
"Member access": get_role_display(roles),
"Invited by": model.get("invited_by"),
"Joined date": model.get("joined_date"),
"Last active": model.get("last_active"),
"Domain requests": UserPortfolioPermission.get_domain_request_permission_display(roles, permissions),
"Member management": UserPortfolioPermission.get_member_permission_display(roles, permissions),
"Domain management": bool(length_user_managed_domains > 0),
"Number of domains": length_user_managed_domains,
"Domains": ",".join(user_managed_domains),
"Domain requests": f"{get_domain_requests_display(roles, permissions)}",
"Members": f"{get_members_display(roles, permissions)}",
"Domains": f"{get_domains_display(roles, permissions)}",
"Number domains assigned": length_user_managed_domains,
"Domain assignments": ", ".join(user_managed_domains),
}
return [FIELDS.get(column, "") for column in columns]

View file

@ -889,13 +889,12 @@ class DomainNameserversView(DomainFormBaseView):
"""The initial value for the form (which is a formset here)."""
nameservers = self.object.nameservers
initial_data = []
if nameservers is not None:
# Add existing nameservers as initial data
initial_data.extend({"server": name, "ip": ",".join(ip)} for name, ip in nameservers)
# Ensure at least 3 fields, filled or empty
while len(initial_data) < 2:
# Ensure 2 fields in the case we have no data
if len(initial_data) == 0:
initial_data.append({})
return initial_data
@ -917,11 +916,6 @@ class DomainNameserversView(DomainFormBaseView):
for i, form in enumerate(formset):
form.fields["server"].label += f" {i+1}"
if i < 2:
form.fields["server"].required = True
else:
form.fields["server"].required = False
form.fields["server"].label += " (optional)"
form.fields["domain"].initial = self.object.name
return formset
@ -933,8 +927,6 @@ class DomainNameserversView(DomainFormBaseView):
self._get_domain(request)
formset = self.get_form()
logger.debug("got formet")
if "btn-cancel-click" in request.POST:
url = self.get_success_url()
return HttpResponseRedirect(url)

View file

@ -702,8 +702,8 @@ class DotgovDomain(DomainRequestWizard):
forms = [
forms.DotGovDomainForm,
forms.AlternativeDomainFormSet,
forms.ExecutiveNamingRequirementsYesNoForm,
forms.ExecutiveNamingRequirementsDetailsForm,
feb.ExecutiveNamingRequirementsYesNoForm,
feb.ExecutiveNamingRequirementsDetailsForm,
]
def get_context_data(self):

View file

@ -213,9 +213,12 @@ class PortfolioMembersJson(View):
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
action_url = reverse(item["type"], kwargs={"pk": item["id"]})
item_type = item.get("type", "")
if item_type == "invitedmember":
action_url = reverse(item["type"], kwargs={"invitedmember_pk": item["id"]})
else:
action_url = reverse(item["type"], kwargs={"member_pk": item["id"]})
# Ensure domain_info is properly processed for invites -
# we need to un-concatenate the subquery

View file

@ -76,9 +76,10 @@ class PortfolioMemberView(DetailView, View):
model = Portfolio
context_object_name = "portfolio"
template_name = "portfolio_member.html"
pk_url_kwarg = "member_pk"
def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
def get(self, request, member_pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=member_pk)
member = portfolio_permission.user
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
@ -102,8 +103,8 @@ class PortfolioMemberView(DetailView, View):
request,
self.template_name,
{
"edit_url": reverse("member-permissions", args=[pk]),
"domains_url": reverse("member-domains", args=[pk]),
"edit_url": reverse("member-permissions", args=[member_pk]),
"domains_url": reverse("member-domains", args=[member_pk]),
"portfolio_permission": portfolio_permission,
"member": member,
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
@ -115,22 +116,23 @@ class PortfolioMemberView(DetailView, View):
)
@grant_access(HAS_PORTFOLIO_MEMBERS_ANY_PERM)
@grant_access(HAS_PORTFOLIO_MEMBERS_EDIT)
class PortfolioMemberDeleteView(View):
pk_url_kwarg = "member_pk"
def post(self, request, pk):
def post(self, request, member_pk):
"""
Find and delete the portfolio member using the provided primary key (pk).
Redirect to a success page after deletion (or any other appropriate page).
"""
portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=member_pk)
member = portfolio_member_permission.user
portfolio = portfolio_member_permission.portfolio
# Validate if the member can be removed
error_message = self._validate_member_removal(request, member, portfolio)
if error_message:
return self._handle_error_response(request, error_message, pk)
return self._handle_error_response(request, error_message, member_pk)
# Attempt to send notification emails
self._send_removal_notifications(request, portfolio_member_permission)
@ -161,14 +163,14 @@ class PortfolioMemberDeleteView(View):
)
return None
def _handle_error_response(self, request, error_message, pk):
def _handle_error_response(self, request, error_message, member_pk):
"""
Return an error response (JSON or redirect with messages).
"""
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({"error": error_message}, status=400)
messages.error(request, error_message)
return redirect(reverse("member", kwargs={"pk": pk}))
return redirect(reverse("member", kwargs={"member_pk": member_pk}))
def _send_removal_notifications(self, request, portfolio_member_permission):
"""
@ -223,9 +225,10 @@ class PortfolioMemberEditView(DetailView, View):
context_object_name = "portfolio"
template_name = "portfolio_member_permissions.html"
form_class = portfolioForms.PortfolioMemberForm
pk_url_kwarg = "member_pk"
def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
def get(self, request, member_pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=member_pk)
user = portfolio_permission.user
form = self.form_class(instance=portfolio_permission)
@ -240,8 +243,8 @@ class PortfolioMemberEditView(DetailView, View):
},
)
def post(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
def post(self, request, member_pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=member_pk)
user = portfolio_permission.user
form = self.form_class(request.POST, instance=portfolio_permission)
removing_admin_role_on_self = False
@ -276,7 +279,7 @@ class PortfolioMemberEditView(DetailView, View):
self._handle_exceptions(e)
form.save()
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 redirect("member", member_pk=member_pk) if not removing_admin_role_on_self else redirect("home")
return render(
request,
@ -304,9 +307,10 @@ class PortfolioMemberEditView(DetailView, View):
class PortfolioMemberDomainsView(View):
template_name = "portfolio_member_domains.html"
pk_url_kwarg = "member_pk"
def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
def get(self, request, member_pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=member_pk)
member = portfolio_permission.user
return render(
@ -324,9 +328,10 @@ class PortfolioMemberDomainsEditView(DetailView, View):
model = Portfolio
context_object_name = "portfolio"
template_name = "portfolio_member_domains_edit.html"
pk_url_kwarg = "member_pk"
def get(self, request, pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
def get(self, request, member_pk):
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=member_pk)
member = portfolio_permission.user
return render(
@ -338,33 +343,33 @@ class PortfolioMemberDomainsEditView(DetailView, View):
},
)
def post(self, request, pk):
def post(self, request, member_pk):
"""
Handles adding and removing domains for a portfolio member.
"""
added_domains = request.POST.get("added_domains")
removed_domains = request.POST.get("removed_domains")
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=member_pk)
member = portfolio_permission.user
portfolio = portfolio_permission.portfolio
added_domain_ids = self._parse_domain_ids(added_domains, "added domains")
if added_domain_ids is None:
return redirect(reverse("member-domains", kwargs={"pk": pk}))
return redirect(reverse("member-domains", kwargs={"member_pk": member_pk}))
removed_domain_ids = self._parse_domain_ids(removed_domains, "removed domains")
if removed_domain_ids is None:
return redirect(reverse("member-domains", kwargs={"pk": pk}))
return redirect(reverse("member-domains", kwargs={"member_pk": member_pk}))
if not (added_domain_ids or removed_domain_ids):
messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("member-domains", kwargs={"pk": pk}))
return redirect(reverse("member-domains", kwargs={"member_pk": member_pk}))
try:
self._process_added_domains(added_domain_ids, member, request.user, portfolio)
self._process_removed_domains(removed_domain_ids, member)
messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("member-domains", kwargs={"pk": pk}))
return redirect(reverse("member-domains", kwargs={"member_pk": member_pk}))
except IntegrityError:
messages.error(
request,
@ -372,7 +377,7 @@ class PortfolioMemberDomainsEditView(DetailView, View):
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error("A database error occurred while saving changes.", exc_info=True)
return redirect(reverse("member-domains-edit", kwargs={"pk": pk}))
return redirect(reverse("member-domains-edit", kwargs={"member_pk": member_pk}))
except Exception as e:
messages.error(
request,
@ -380,7 +385,7 @@ class PortfolioMemberDomainsEditView(DetailView, View):
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error(f"An unexpected error occurred: {str(e)}", exc_info=True)
return redirect(reverse("member-domains-edit", kwargs={"pk": pk}))
return redirect(reverse("member-domains-edit", kwargs={"member_pk": member_pk}))
def _parse_domain_ids(self, domain_data, domain_type):
"""
@ -437,9 +442,10 @@ class PortfolioInvitedMemberView(DetailView, View):
context_object_name = "portfolio"
template_name = "portfolio_member.html"
# form_class = PortfolioInvitedMemberForm
pk_url_kwarg = "invitedmember_pk"
def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
def get(self, request, invitedmember_pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=invitedmember_pk)
# form = self.form_class(instance=portfolio_invitation)
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
@ -463,8 +469,8 @@ class PortfolioInvitedMemberView(DetailView, View):
request,
self.template_name,
{
"edit_url": reverse("invitedmember-permissions", args=[pk]),
"domains_url": reverse("invitedmember-domains", args=[pk]),
"edit_url": reverse("invitedmember-permissions", args=[invitedmember_pk]),
"domains_url": reverse("invitedmember-domains", args=[invitedmember_pk]),
"portfolio_invitation": portfolio_invitation,
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
@ -475,15 +481,16 @@ class PortfolioInvitedMemberView(DetailView, View):
)
@grant_access(HAS_PORTFOLIO_MEMBERS_ANY_PERM)
@grant_access(HAS_PORTFOLIO_MEMBERS_EDIT)
class PortfolioInvitedMemberDeleteView(View):
pk_url_kwarg = "invitedmember_pk"
def post(self, request, pk):
def post(self, request, invitedmember_pk):
"""
Find and delete the portfolio invited member using the provided primary key (pk).
Redirect to a success page after deletion (or any other appropriate page).
"""
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=invitedmember_pk)
try:
# if invitation being removed is an admin
@ -527,9 +534,10 @@ class PortfolioInvitedMemberEditView(DetailView, View):
context_object_name = "portfolio"
template_name = "portfolio_member_permissions.html"
form_class = portfolioForms.PortfolioInvitedMemberForm
pk_url_kwarg = "invitedmember_pk"
def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
def get(self, request, invitedmember_pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=invitedmember_pk)
form = self.form_class(instance=portfolio_invitation)
return render(
@ -541,8 +549,8 @@ class PortfolioInvitedMemberEditView(DetailView, View):
},
)
def post(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
def post(self, request, invitedmember_pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=invitedmember_pk)
form = self.form_class(request.POST, instance=portfolio_invitation)
if form.is_valid():
try:
@ -568,7 +576,7 @@ class PortfolioInvitedMemberEditView(DetailView, View):
self._handle_exceptions(e)
form.save()
messages.success(self.request, "The member access and permission changes have been saved.")
return redirect("invitedmember", pk=pk)
return redirect("invitedmember", invitedmember_pk=invitedmember_pk)
return render(
request,
@ -596,9 +604,10 @@ class PortfolioInvitedMemberEditView(DetailView, View):
class PortfolioInvitedMemberDomainsView(View):
template_name = "portfolio_member_domains.html"
pk_url_kwarg = "invitedmember_pk"
def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
def get(self, request, invitedmember_pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=invitedmember_pk)
return render(
request,
@ -615,9 +624,10 @@ class PortfolioInvitedMemberDomainsEditView(DetailView, View):
model = Portfolio
context_object_name = "portfolio"
template_name = "portfolio_member_domains_edit.html"
pk_url_kwarg = "invitedmember_pk"
def get(self, request, pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
def get(self, request, invitedmember_pk):
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=invitedmember_pk)
return render(
request,
@ -627,33 +637,33 @@ class PortfolioInvitedMemberDomainsEditView(DetailView, View):
},
)
def post(self, request, pk):
def post(self, request, invitedmember_pk):
"""
Handles adding and removing domains for a portfolio invitee.
"""
added_domains = request.POST.get("added_domains")
removed_domains = request.POST.get("removed_domains")
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=invitedmember_pk)
email = portfolio_invitation.email
portfolio = portfolio_invitation.portfolio
added_domain_ids = self._parse_domain_ids(added_domains, "added domains")
if added_domain_ids is None:
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
return redirect(reverse("invitedmember-domains", kwargs={"invitedmember_pk": invitedmember_pk}))
removed_domain_ids = self._parse_domain_ids(removed_domains, "removed domains")
if removed_domain_ids is None:
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
return redirect(reverse("invitedmember-domains", kwargs={"invitedmember_pk": invitedmember_pk}))
if not (added_domain_ids or removed_domain_ids):
messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
return redirect(reverse("invitedmember-domains", kwargs={"invitedmember_pk": invitedmember_pk}))
try:
self._process_added_domains(added_domain_ids, email, request.user, portfolio)
self._process_removed_domains(removed_domain_ids, email)
messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
return redirect(reverse("invitedmember-domains", kwargs={"invitedmember_pk": invitedmember_pk}))
except IntegrityError:
messages.error(
request,
@ -661,7 +671,7 @@ class PortfolioInvitedMemberDomainsEditView(DetailView, View):
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error("A database error occurred while saving changes.", exc_info=True)
return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk}))
return redirect(reverse("invitedmember-domains-edit", kwargs={"invitedmember_pk": invitedmember_pk}))
except Exception as e:
messages.error(
request,
@ -669,7 +679,7 @@ class PortfolioInvitedMemberDomainsEditView(DetailView, View):
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error(f"An unexpected error occurred: {str(e)}.", exc_info=True)
return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk}))
return redirect(reverse("invitedmember-domains-edit", kwargs={"invitedmember_pk": invitedmember_pk}))
def _parse_domain_ids(self, domain_data, domain_type):
"""
@ -903,7 +913,7 @@ class PortfolioMembersView(View):
return render(request, "portfolio_members.html")
@grant_access(HAS_PORTFOLIO_MEMBERS_ANY_PERM)
@grant_access(HAS_PORTFOLIO_MEMBERS_EDIT)
class PortfolioAddMemberView(DetailView, FormMixin):
template_name = "portfolio_members_add_new.html"