Merge branch 'main' into ms/remove-email-thanks

This commit is contained in:
Jaxon Silva 2025-02-27 10:16:32 -08:00 committed by GitHub
commit 814c2dd26d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 1448 additions and 606 deletions

View file

@ -1327,6 +1327,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
search_help_text = "Search by first name, last name, email, or portfolio."
change_form_template = "django/admin/user_portfolio_permission_change_form.html"
delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.html"
def get_roles(self, obj):
readable_roles = obj.get_readable_roles()
@ -1670,6 +1671,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
autocomplete_fields = ["portfolio"]
change_form_template = "django/admin/portfolio_invitation_change_form.html"
delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html"
# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
@ -2287,11 +2289,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
@admin.display(description=_("Requested Domain"))
def custom_requested_domain(self, obj):
# Example: Show different icons based on `status`
url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}"
text = obj.requested_domain
if obj.portfolio:
return format_html('<a href="{}"><img src="/public/admin/img/icon-yes.svg"> {}</a>', url, text)
return format_html('<a href="{}">{}</a>', url, text)
return format_html(
f'<img class="padding-right-05" src="/public/admin/img/icon-yes.svg" aria-hidden="true">{escape(text)}'
)
return text
custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore
@ -3738,11 +3741,13 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Using variables to get past the linter
message1 = f"Cannot delete Domain when in state {obj.state}"
message2 = f"This subdomain is being used as a hostname on another domain: {err.note}"
message3 = f"Command failed with note: {err.note}"
# Human-readable mappings of ErrorCodes. Can be expanded.
error_messages = {
# noqa on these items as black wants to reformat to an invalid length
ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION: message1,
ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: message2,
ErrorCode.COMMAND_FAILED: message3,
}
message = "Cannot connect to the registry"

View file

@ -0,0 +1,15 @@
/**
* Initializes buttons to behave like links by navigating to their data-url attribute
* Example usage: <button class="use-button-as-link" data-url="/some/path">Click me</button>
*/
export function initButtonLinks() {
document.querySelectorAll('button.use-button-as-link').forEach(button => {
button.addEventListener('click', function() {
// Equivalent to button.getAttribute("data-href")
const href = this.dataset.href;
if (href) {
window.location.href = href;
}
});
});
}

View file

@ -16,6 +16,7 @@ import { initDynamicPortfolioFields } from './portfolio-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';
// General
initModals();
@ -23,6 +24,7 @@ initCopyToClipboard();
initFilterHorizontalWidget();
initDescriptions();
initSubmitBar();
initButtonLinks();
// Domain request
initIneligibleModal();

View file

@ -96,3 +96,14 @@ export function submitForm(form_id) {
console.error("Form '" + form_id + "' not found.");
}
}
/**
* Helper function to strip HTML tags
* THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS
*/
export function unsafeStripHtmlTags(input) {
const tempDiv = document.createElement("div");
// NOTE: THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS
tempDiv.innerHTML = input;
return tempDiv.textContent || tempDiv.innerText || "";
}

View file

@ -18,7 +18,7 @@ export function initPortfolioNewMemberPageToggle() {
const unique_id = `${member_type}-${member_id}`;
let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member";
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`);
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`, "usa-icon--large");
// This easter egg is only for fixtures that dont have names as we are displaying their emails
// All prod users will have emails linked to their account
@ -100,8 +100,8 @@ export function initAddNewMemberPageListeners() {
const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`);
permissionSections.forEach(section => {
// Find the <h3> element text
const sectionTitle = section.textContent;
// Find the <h3> element text, strip out the '*'
const sectionTitle = section.textContent.trim().replace(/\*$/, "") + ": ";
// Find the associated radio buttons container (next fieldset)
const fieldset = section.nextElementSibling;
@ -128,25 +128,29 @@ export function initAddNewMemberPageListeners() {
});
} else {
// for admin users, the permissions are always the same
appendPermissionInContainer('Domains', 'Viewer', permissionDetailsContainer);
appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer);
appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer);
appendPermissionInContainer('Domains: ', 'Viewer', permissionDetailsContainer);
appendPermissionInContainer('Domain requests: ', 'Creator', permissionDetailsContainer);
appendPermissionInContainer('Members: ', 'Manager', permissionDetailsContainer);
}
}
function appendPermissionInContainer(sectionTitle, permissionDisplay, permissionContainer) {
// Create new elements for the content
const titleElement = document.createElement("h4");
titleElement.textContent = sectionTitle;
titleElement.classList.add("text-primary", "margin-bottom-0");
const elementContainer = document.createElement("p");
elementContainer.classList.add("margin-top-0", "margin-bottom-1");
const permissionElement = document.createElement("p");
const titleElement = document.createElement("strong");
titleElement.textContent = sectionTitle;
titleElement.classList.add("text-primary-darker");
const permissionElement = document.createElement("span");
permissionElement.textContent = permissionDisplay;
permissionElement.classList.add("margin-top-0");
// Append to the content container
permissionContainer.appendChild(titleElement);
permissionContainer.appendChild(permissionElement);
elementContainer.appendChild(titleElement);
elementContainer.appendChild(permissionElement);
permissionContainer.appendChild(elementContainer);
}
/*

View file

@ -79,13 +79,13 @@ export function addModal(id, ariaLabelledby, ariaDescribedby, modalHeading, moda
* @param {string} modal_button_text - The action button's text
* @param {string} screen_reader_text - A screen reader helper
*/
export function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text) {
export function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text, icon_class) {
const generateModalButton = (mobileOnly = false) => `
<a
role="button"
id="button-trigger-${action}-${unique_id}"
href="#toggle-${action}-${unique_id}"
class="usa-button usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary ${mobileOnly ? 'visible-mobile-flex' : ''}"
class="usa-button usa-button--unstyled text-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary ${mobileOnly ? 'visible-mobile-flex' : ''}"
aria-controls="toggle-${action}-${unique_id}"
data-open-modal
>
@ -99,7 +99,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
// Main kebab structure
const kebab = `
${generateModalButton(true)} <!-- Mobile button -->
<div class="usa-accordion usa-accordion--more-actions margin-right-2 hidden-mobile-flex">
<div class="usa-accordion usa-accordion--more-actions margin-right-2 margin-top-3px hidden-mobile-flex">
<div class="usa-accordion__heading">
<button
type="button"
@ -108,12 +108,12 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
aria-controls="more-actions-${unique_id}"
aria-label="${screen_reader_text}"
>
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<svg class="usa-icon${icon_class ? " " + icon_class : ""}" 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-${unique_id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-neg-1" hidden>
<div id="more-actions-${unique_id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-0${icon_class === 'usa-icon--large' ? ' top-28px' : ''}" hidden>
<h2>More options</h2>
${generateModalButton()} <!-- Desktop button -->
</div>

View file

@ -1,4 +1,4 @@
import { hideElement, showElement, getCsrfToken } from './helpers.js';
import { hideElement, showElement, getCsrfToken, unsafeStripHtmlTags } from './helpers.js';
import { uswdsInitializeModals, uswdsUnloadModals } from './helpers-uswds.js';
import { BaseTable, addModal, generateKebabHTML } from './table-base.js';
@ -98,9 +98,10 @@ export class DomainRequestsTable extends BaseTable {
// Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly
if (this.portfolioValue) {
// NOTE: THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS
const sanitizedDomainName = unsafeStripHtmlTags(domainName);
// 2nd path (org model): Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users
modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', domainName);
modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', sanitizedDomainName);
}
}
@ -117,7 +118,7 @@ export class DomainRequestsTable extends BaseTable {
${request.status}
</td>
<td class="width--action-column">
<div class="tablet:display-flex tablet:flex-row flex-wrap">
<div class="tablet:display-flex tablet:flex-row">
<a href="${actionUrl}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${request.svg_icon}"></use>

View file

@ -23,10 +23,13 @@ export class EditMemberDomainsTable extends BaseTable {
this.readonlyModeContainer = document.getElementById('domain-assignments-readonly-view');
this.reviewButton = document.getElementById('review-domain-assignments');
this.backButton = document.getElementById('back-to-edit-domain-assignments');
this.saveButton = document.getElementById('save-domain-assignments');
this.initializeDomainAssignments();
this.saveButton = document.getElementById('save-domain-assignments');
}
async init() {
await this.initializeDomainAssignments();
this.initCancelEditDomainAssignmentButton();
this.initEventListeners();
return this;
}
getBaseUrl() {
return document.getElementById("get_member_domains_json_url");
@ -134,27 +137,33 @@ export class EditMemberDomainsTable extends BaseTable {
* member. It populates both initialDomainAssignments and initialDomainAssignmentsOnlyMember.
* It is called once per page load, but not called with subsequent table changes.
*/
initializeDomainAssignments() {
async initializeDomainAssignments() {
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
if (!baseUrlValue) return;
let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue);
let url = baseUrlValue + "?" + searchParams.toString();
fetch(url)
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Error in AJAX call: ' + data.error);
if (!baseUrlValue) {
console.error("Base URL not found");
return;
}
try {
let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue);
let url = baseUrlValue + "?" + searchParams.toString();
let response = await fetch(url);
let data = await response.json();
if (data.error) {
console.error("Error in AJAX call:", data.error);
return;
}
let dataObjects = this.getDataObjects(data);
// Map the id attributes of dataObjects to this.initialDomainAssignments
this.initialDomainAssignments = dataObjects.map(obj => obj.id);
this.initialDomainAssignmentsOnlyMember = dataObjects
.filter(obj => obj.member_is_only_manager)
.map(obj => obj.id);
})
.catch(error => console.error('Error fetching domain assignments:', error));
.filter(obj => obj.member_is_only_manager)
.map(obj => obj.id);
} catch (error) {
console.error("Error fetching domain assignments:", error);
}
}
/**
* Initializes listeners on checkboxes in the table. Checkbox listeners are used
@ -232,8 +241,6 @@ export class EditMemberDomainsTable extends BaseTable {
}
updateReadonlyDisplay() {
let totalAssignedDomains = this.getCheckedDomains().length;
// Create unassigned domains list
const unassignedDomainsList = document.createElement('ul');
unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled');
@ -260,35 +267,30 @@ export class EditMemberDomainsTable extends BaseTable {
// Clear existing content
domainAssignmentSummary.innerHTML = '';
// Append unassigned domains section
if (this.removedDomains.length) {
const unassignedHeader = document.createElement('h3');
unassignedHeader.classList.add('margin-bottom-05', 'h4');
unassignedHeader.textContent = 'Unassigned domains';
domainAssignmentSummary.appendChild(unassignedHeader);
domainAssignmentSummary.appendChild(unassignedDomainsList);
}
// Append assigned domains section
if (this.addedDomains.length) {
const assignedHeader = document.createElement('h3');
// Make this h3 look like a h4
assignedHeader.classList.add('margin-bottom-05', 'h4');
assignedHeader.textContent = 'Assigned domains';
assignedHeader.textContent = `New assignments (${this.addedDomains.length})`;
domainAssignmentSummary.appendChild(assignedHeader);
domainAssignmentSummary.appendChild(assignedDomainsList);
}
// Append total assigned domains section
const totalHeader = document.createElement('h3');
// Make this h3 look like a h4
totalHeader.classList.add('margin-bottom-05', 'h4');
totalHeader.textContent = 'Total assigned domains';
domainAssignmentSummary.appendChild(totalHeader);
const totalCount = document.createElement('p');
totalCount.classList.add('margin-y-0');
totalCount.textContent = totalAssignedDomains;
domainAssignmentSummary.appendChild(totalCount);
// Append unassigned domains section
if (this.removedDomains.length) {
const unassignedHeader = document.createElement('h3');
unassignedHeader.classList.add('margin-bottom-05', 'h4');
unassignedHeader.textContent =`Removed assignments (${this.removedDomains.length})`;
domainAssignmentSummary.appendChild(unassignedHeader);
domainAssignmentSummary.appendChild(unassignedDomainsList);
}
if (!this.addedDomains.length && !this.removedDomains.length) {
const noChangesParagraph = document.createElement('p');
noChangesParagraph.textContent = "No changes were detected. Click the “Back” button to edit this members domain assignments.";
domainAssignmentSummary.appendChild(noChangesParagraph);
}
}
showReadonlyMode() {
@ -355,14 +357,14 @@ export class EditMemberDomainsTable extends BaseTable {
}
export function initEditMemberDomainsTable() {
document.addEventListener('DOMContentLoaded', function() {
const isEditMemberDomainsPage = document.getElementById("edit-member-domains");
if (isEditMemberDomainsPage) {
const editMemberDomainsTable = new EditMemberDomainsTable();
if (editMemberDomainsTable.tableWrapper) {
// Initial load
editMemberDomainsTable.loadTable(1);
}
}
});
document.addEventListener('DOMContentLoaded', async function() {
const isEditMemberDomainsPage = document.getElementById("edit-member-domains");
if (!isEditMemberDomainsPage) return; // Exit if not on the right page
const editMemberDomainsTable = await new EditMemberDomainsTable().init();
if (editMemberDomainsTable.tableWrapper) {
editMemberDomainsTable.loadTable(1); // Initial load
}
});
}

View file

@ -69,13 +69,14 @@ export class MembersTable extends BaseTable {
const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `Expand for more options for ${member.name}`): '';
const row = document.createElement('tr');
row.classList.add('hide-td-borders');
let admin_tagHTML = ``;
if (member.is_admin)
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary-dark text-semibold">Admin</span>`
// generate html blocks for domains and permissions for the member
let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url);
let permissionsHTML = this.generatePermissionsHTML(member.permissions, customTableOptions.UserPortfolioPermissionChoices);
let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url, unique_id);
let permissionsHTML = this.generatePermissionsHTML(member.is_admin, member.permissions, customTableOptions.UserPortfolioPermissionChoices, unique_id);
// domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand
let showMoreButton = '';
@ -96,28 +97,34 @@ export class MembersTable extends BaseTable {
</button>
`;
showMoreRow.innerHTML = `<td colspan='3' headers="header-member row-header-${unique_id}" class="padding-top-0"><div class='grid-row'>${domainsHTML} ${permissionsHTML}</div></td>`;
showMoreRow.classList.add('show-more-content');
showMoreRow.classList.add('display-none');
showMoreRow.innerHTML = `
<td colspan='4' headers="header-member row-header-${unique_id}" class="padding-top-0">
${showMoreButton}
<div class='grid-row grid-gap-2 show-more-content display-none'>
${domainsHTML}
${permissionsHTML}
</div>
</td>
`;
showMoreRow.id = unique_id;
}
row.innerHTML = `
<th role="rowheader" headers="header-member" data-label="member email" id='row-header-${unique_id}'>
${member.member_display} ${admin_tagHTML} ${showMoreButton}
<th class="padding-bottom-0" role="rowheader" headers="header-member" data-label="Member" id='row-header-${unique_id}'>
${member.member_display} ${admin_tagHTML}
</th>
<td headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="last_active">
<td class="padding-bottom-0" headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="Last active">
${last_active.display_value}
</td>
<td headers="header-action row-header-${unique_id}" class="width--action-column">
<td class="padding-bottom-0" headers="header-action row-header-${unique_id}" class="width--action-column">
<div class="tablet:display-flex tablet:flex-row flex-align-center">
<a href="${member.action_url}">
<a href="${member.action_url}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
</svg>
${member.action_label} <span class="usa-sr-only">${member.name}</span>
</a>
<span class="padding-left-1">${customTableOptions.hasAdditionalActions ? kebabHTML : ''}</span>
${customTableOptions.hasAdditionalActions ? kebabHTML : ''}
</div>
</td>
`;
@ -146,16 +153,15 @@ export class MembersTable extends BaseTable {
*
* @param {HTMLElement} toggleButton - The button that toggles the content visibility.
* @param {HTMLElement} contentDiv - The content div whose visibility is toggled.
* @param {HTMLElement} buttonParentRow - The parent row element containing the button.
*/
function toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow) {
function toggleShowMoreButton(toggleButton, contentDiv) {
const spanElement = toggleButton.querySelector('span');
const useElement = toggleButton.querySelector('use');
if (contentDiv.classList.contains('display-none')) {
showElement(contentDiv);
spanElement.textContent = 'Close';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
buttonParentRow.classList.add('hide-td-borders');
toggleButton.classList.add('margin-bottom-2');
let ariaLabelText = "Close additional information";
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
@ -169,7 +175,7 @@ export class MembersTable extends BaseTable {
hideElement(contentDiv);
spanElement.textContent = 'Expand';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
buttonParentRow.classList.remove('hide-td-borders');
toggleButton.classList.remove('margin-bottom-2');
let ariaLabelText = "Expand for additional information";
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
@ -182,14 +188,11 @@ export class MembersTable extends BaseTable {
let toggleButtons = document.querySelectorAll('.usa-button--show-more-button');
toggleButtons.forEach((toggleButton) => {
// get contentDiv for element specified in data-for attribute of toggleButton
let dataFor = toggleButton.dataset.for;
let contentDiv = document.getElementById(dataFor);
let buttonParentRow = toggleButton.parentElement.parentElement;
if (contentDiv && contentDiv.tagName.toLowerCase() === 'tr' && contentDiv.classList.contains('show-more-content') && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') {
let contentDiv = buttonParentRow.querySelector(".show-more-content");
if (contentDiv && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') {
toggleButton.addEventListener('click', function() {
toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow);
toggleShowMoreButton(toggleButton, contentDiv);
});
} else {
console.warn('Found a toggle button with no associated toggleable content or parent row');
@ -240,32 +243,42 @@ export class MembersTable extends BaseTable {
* @param {number} num_domains - The number of domains the member is assigned to.
* @param {Array} domain_names - An array of domain names.
* @param {Array} domain_urls - An array of corresponding domain URLs.
* @param {Array} unique_id - A unique row id.
* @returns {string} - A string of HTML displaying the domains assigned to the member.
*/
generateDomainsHTML(num_domains, domain_names, domain_urls, action_url) {
generateDomainsHTML(num_domains, domain_names, domain_urls, action_url, unique_id) {
// Initialize an empty string for the HTML
let domainsHTML = '';
// Only generate HTML if the member has one or more assigned domains
domainsHTML += "<div class='desktop:grid-col-4 margin-bottom-2 desktop:margin-bottom-0'>";
domainsHTML += `<h4 id='domains-assigned--heading-${unique_id}' class='font-body-xs margin-y-0'>Domains assigned</h4>`;
domainsHTML += `<section aria-labelledby='domains-assigned--heading-${unique_id}' tabindex='0'>`
if (num_domains > 0) {
domainsHTML += "<div class='desktop:grid-col-5 margin-bottom-2 desktop:margin-bottom-0'>";
domainsHTML += "<h4 class='font-body-xs margin-y-0'>Domains assigned</h4>";
domainsHTML += `<p class='font-body-xs text-base-dark margin-y-0'>This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:</p>`;
domainsHTML += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
domainsHTML += `<p class='font-body-xs text-base-darker margin-y-0'>This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:</p>`;
if (num_domains > 1) {
domainsHTML += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
// Display up to 6 domains with their URLs
for (let i = 0; i < num_domains && i < 6; i++) {
domainsHTML += `<li><a class="font-body-xs" href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
// Display up to 6 domains with their URLs
for (let i = 0; i < num_domains && i < 6; i++) {
domainsHTML += `<li><a class="font-body-xs" href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
}
domainsHTML += "</ul>";
} else {
// We don't display this in a list for better screenreader support, when only one item exists.
domainsHTML += `<a class="font-body-xs" href="${domain_urls[0]}">${domain_names[0]}</a>`;
}
domainsHTML += "</ul>";
// If there are more than 6 domains, display a "View assigned domains" link
domainsHTML += `<p class="font-body-xs"><a href="${action_url}/domains">View assigned domains</a></p>`;
domainsHTML += "</div>";
} else {
domainsHTML += `<p class='font-body-xs text-base-darker margin-y-0'>This member is assigned to 0 domains.</p>`;
}
// If there are more than 6 domains, display a "View assigned domains" link
domainsHTML += `<p class="font-body-xs"><a href="${action_url}/domains">View domain assignments</a></p>`;
domainsHTML += "</section>"
domainsHTML += "</div>";
return domainsHTML;
}
@ -362,7 +375,7 @@ export class MembersTable extends BaseTable {
* - VIEW_ALL_REQUESTS
* - EDIT_MEMBERS
* - VIEW_MEMBERS
*
* @param {String} unique_id
* @returns {string} - A string of HTML representing the user's additional permissions.
* If the user has no specific permissions, it returns a default message
* indicating no additional permissions.
@ -377,40 +390,51 @@ export class MembersTable extends BaseTable {
* - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions.
* - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions.
*/
generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) {
let permissionsHTML = '';
// Define shared classes across elements for easier refactoring
let sharedParagraphClasses = "font-body-xs text-base-dark margin-top-1 p--blockquote";
// Check domain-related permissions
generatePermissionsHTML(is_admin, member_permissions, UserPortfolioPermissionChoices, unique_id) {
// 1. Role
const memberAccessValue = is_admin ? "Admin" : "Basic";
// 2. Domain access
let domainValue = "No access";
if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domains:</strong> Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>`;
domainValue = "Viewer";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domains:</strong> Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>`;
domainValue = "Viewer, limited";
}
// Check request-related permissions
// 3. Request access
let requestValue = "No access";
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domain requests:</strong> Can view all organization domain requests. Can create domain requests and modify their own requests.</p>`;
requestValue = "Creator";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domain requests (view-only):</strong> Can view all organization domain requests. Can't create or modify any domain requests.</p>`;
requestValue = "Viewer";
}
// Check member-related permissions
// 4. Member access
let memberValue = "No access";
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Members:</strong> Can manage members including inviting new members, removing current members, and assigning domains to members.</p>`;
memberValue = "Manager";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>`;
memberValue = "Viewer";
}
// If no specific permissions are assigned, display a message indicating no additional permissions
if (!permissionsHTML) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><b>No additional permissions:</b> There are no additional permissions for this member.</p>`;
}
// Add a permissions header and wrap the entire output in a container
permissionsHTML = `<div class='desktop:grid-col-7'><h4 class='font-body-xs margin-y-0'>Additional permissions for this member</h4>${permissionsHTML}</div>`;
// Helper function for faster element refactoring
const createPermissionItem = (label, value) => {
return `<p class="font-body-xs text-base-darker margin-top-1 p--blockquote">${label}: <strong>${value}</strong></p>`;
};
const permissionsHTML = `
<div class="desktop:grid-col-8">
<h4 id="member-access--heading-${unique_id}" class="font-body-xs margin-y-0">
Member access and permissions
</h4>
<section aria-labelledby="member-access--heading-${unique_id}" tabindex="0">
${createPermissionItem("Member access", memberAccessValue)}
${createPermissionItem("Domains", domainValue)}
${createPermissionItem("Domain requests", requestValue)}
${createPermissionItem("Members", memberValue)}
</section>
</div>
`;
return permissionsHTML;
}

View file

@ -5,12 +5,22 @@
display: inline-block;
width: auto;
position: relative;
.usa-accordion__button {
border-radius: units(.5);
}
.usa-accordion__button:focus {
outline-offset: 0;
outline-width: 3px;
}
.usa-accordion__button[aria-expanded=false],
.usa-accordion__button[aria-expanded=false]:hover,
.usa-accordion__button[aria-expanded=true],
.usa-accordion__button[aria-expanded=true]:hover {
background-image: none;
}
.usa-accordion__button[aria-expanded=true] {
background-color: color('primary-lighter');
}
.usa-accordion__content {
// Note, width is determined by a custom width class on one of the children
position: absolute;
@ -37,7 +47,12 @@
}
.usa-accordion--more-actions .usa-accordion__content {
top: 30px;
// We need to match the height of the trigger button
// to align the 'popup' underneath
top: 20px;
&.top-28px {
top: 28px;
}
}
// Special positioning for the kabob menu popup in the last row on a given page

View file

@ -498,7 +498,7 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too
font-size: 13px;
}
.object-tools li button {
.object-tools li button, button.addlink {
font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif;
text-transform: none !important;
font-size: 14px !important;
@ -520,13 +520,12 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too
}
}
.module--custom {
a {
font-size: 13px;
font-weight: 600;
border: solid 1px var(--darkened-bg);
background: var(--darkened-bg);
}
// Mimic the style for <a>
.object-tools > p > button.addlink {
background-image: url(../admin/img/tooltag-add.svg) !important;
background-repeat: no-repeat !important;
background-position: right 7px center !important;
padding-right: 25px;
}
.usa-modal--django-admin .usa-prose ul > li {
@ -839,6 +838,17 @@ div.dja__model-description{
text-transform: capitalize;
}
.module caption {
// Match the old <h2> size for django admin
font-size: 0.8125rem;
}
// text-bold doesn't work here due to style overrides, unfortunately.
// This is a workaround.
caption.text-bold {
font-weight: font-weight('bold');
}
.wrapped-button-group {
// This button group has too many items
flex-wrap: wrap;
@ -982,3 +992,7 @@ ul.add-list-reset {
}
}
#result_list > tbody tr > th > a {
text-decoration: underline;
}

View file

@ -275,6 +275,14 @@ abbr[title] {
width: 25%;
}
.margin-top-3px {
margin-top: 3px;
}
.top-28px {
top: 28px;
}
/*
NOTE: width: 3% basically forces a fit-content effect in the table.
Fit-content itself does not work.

View file

@ -143,7 +143,7 @@ th {
}
.usa-table--bg-transparent {
td, thead th {
td, th, thead th {
background-color: transparent;
}
}

View file

@ -70,6 +70,7 @@ in the form $setting: value,
----------------------------*/
$theme-font-weight-medium: 400,
$theme-font-weight-semibold: 600,
$theme-font-weight-bold: 700,
/*---------------------------
## Font roles

View file

@ -13,7 +13,16 @@ from registrar.models import (
Portfolio,
SeniorOfficial,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.utility.portfolio_helper import (
UserPortfolioPermissionChoices,
UserPortfolioRoleChoices,
get_domain_requests_description_display,
get_domain_requests_display,
get_domains_description_display,
get_domains_display,
get_members_description_display,
get_members_display,
)
logger = logging.getLogger(__name__)
@ -126,8 +135,16 @@ class BasePortfolioMemberForm(forms.ModelForm):
domain_permissions = forms.ChoiceField(
choices=[
(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"),
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer"),
(
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
get_domains_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
),
(
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value,
get_domains_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
),
),
],
widget=forms.RadioSelect,
required=False,
@ -139,9 +156,19 @@ class BasePortfolioMemberForm(forms.ModelForm):
domain_request_permissions = forms.ChoiceField(
choices=[
("no_access", "No access"),
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "Viewer"),
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Creator"),
("no_access", get_domain_requests_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)),
(
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
get_domain_requests_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS]
),
),
(
UserPortfolioPermissionChoices.EDIT_REQUESTS.value,
get_domain_requests_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS]
),
),
],
widget=forms.RadioSelect,
required=False,
@ -153,8 +180,13 @@ class BasePortfolioMemberForm(forms.ModelForm):
member_permissions = forms.ChoiceField(
choices=[
("no_access", "No access"),
(UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "Viewer"),
("no_access", get_members_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)),
(
UserPortfolioPermissionChoices.VIEW_MEMBERS.value,
get_members_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS]
),
),
],
widget=forms.RadioSelect,
required=False,
@ -191,19 +223,31 @@ class BasePortfolioMemberForm(forms.ModelForm):
# Adds a <p> description beneath each option
self.fields["domain_permissions"].descriptions = {
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: "Can view only the domains they manage",
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: "Can view all domains for the organization",
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: get_domains_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None
),
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: get_domains_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
),
}
self.fields["domain_request_permissions"].descriptions = {
UserPortfolioPermissionChoices.EDIT_REQUESTS.value: (
"Can view all domain requests for the organization and create requests"
get_domain_requests_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS]
)
),
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization",
"no_access": "Cannot view or create domain requests",
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: (
get_domain_requests_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS]
)
),
"no_access": get_domain_requests_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
}
self.fields["member_permissions"].descriptions = {
UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions",
"no_access": "Cannot view member permissions",
UserPortfolioPermissionChoices.VIEW_MEMBERS.value: get_members_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS]
),
"no_access": get_members_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
}
# Map model instance values to custom form fields
@ -218,6 +262,9 @@ class BasePortfolioMemberForm(forms.ModelForm):
cleaned_data = super().clean()
role = cleaned_data.get("role")
# handle role
cleaned_data["roles"] = [role] if role else []
# Get required fields for the selected role. Then validate all required fields for the role.
required_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
for field_name in required_fields:
@ -236,9 +283,6 @@ class BasePortfolioMemberForm(forms.ModelForm):
if cleaned_data.get("member_permissions") == "no_access":
cleaned_data["member_permissions"] = None
# Handle roles
cleaned_data["roles"] = [role]
# Handle additional_permissions
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}

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,8 +1,8 @@
"""Data migration:
1 - generates a report of data integrity across all
transition domain related tables
2 - allows users to run all migration scripts for
transition domain data
1 - generates a report of data integrity across all
transition domain related tables
2 - allows users to run all migration scripts for
transition domain data
"""
import logging

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

@ -2,6 +2,7 @@ from itertools import zip_longest
import logging
import ipaddress
import re
import time
from datetime import date, timedelta
from typing import Optional
from django.db import transaction
@ -750,11 +751,7 @@ class Domain(TimeStampedModel, DomainHelper):
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
try:
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
except Exception as e:
# we don't need this part to succeed in order to continue.
logger.error("Failed to delete nameserver hosts: %s", e)
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
if successTotalNameservers < 2:
try:
@ -1038,13 +1035,13 @@ class Domain(TimeStampedModel, DomainHelper):
logger.error(f"registry error removing client hold: {err}")
raise (err)
def _delete_domain(self):
def _delete_domain(self): # noqa
"""This domain should be deleted from the registry
may raises RegistryError, should be caught or handled correctly by caller"""
logger.info("Deleting subdomains for %s", self.name)
# check if any subdomains are in use by another domain
hosts = Host.objects.filter(name__regex=r".+{}".format(self.name))
hosts = Host.objects.filter(name__regex=r".+\.{}".format(self.name))
for host in hosts:
if host.domain != self:
logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain)
@ -1052,38 +1049,119 @@ class Domain(TimeStampedModel, DomainHelper):
code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION,
note=f"Host {host.name} is in use by {host.domain}",
)
try:
# set hosts to empty list so nameservers are deleted
(
deleted_values,
updated_values,
new_values,
oldNameservers,
) = self.getNameserverChanges(hosts=[])
(
deleted_values,
updated_values,
new_values,
oldNameservers,
) = self.getNameserverChanges(hosts=[])
_ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors
addToDomainList, _ = self.createNewHostList(new_values)
deleteHostList, _ = self.createDeleteHostList(deleted_values)
responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
# update the hosts
_ = self._update_host_values(
updated_values, oldNameservers
) # returns nothing, just need to be run and errors
addToDomainList, _ = self.createNewHostList(new_values)
deleteHostList, _ = self.createDeleteHostList(deleted_values)
responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
except RegistryError as e:
logger.error(f"Error trying to delete hosts from domain {self}: {e}")
raise e
# if unable to update domain raise error and stop
if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
raise NameserverError(code=nsErrorCodes.BAD_DATA)
logger.info("Finished removing nameservers from domain")
# addAndRemoveHostsFromDomain removes the hosts from the domain object,
# but we still need to delete the object themselves
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
logger.info("Finished _delete_hosts_if_not_used inside _delete_domain()")
# delete the non-registrant contacts
logger.debug("Deleting non-registrant contacts for %s", self.name)
contacts = PublicContact.objects.filter(domain=self)
for contact in contacts:
if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
self._update_domain_with_contact(contact, rem=True)
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
logger.info(f"retrieved contacts for domain: {contacts}")
logger.info("Deleting domain %s", self.name)
for contact in contacts:
try:
if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
logger.info(f"Deleting contact: {contact}")
try:
self._update_domain_with_contact(contact, rem=True)
except Exception as e:
logger.error(f"Error while updating domain with contact: {contact}, e: {e}", exc_info=True)
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
logger.info(f"sent DeleteContact for {contact}")
except RegistryError as e:
logger.error(f"Error deleting contact: {contact}, {e}", exc_info=True)
logger.info(f"Finished deleting contacts for {self.name}")
# delete ds data if it exists
if self.dnssecdata:
logger.debug("Deleting ds data for %s", self.name)
try:
# set and unset client hold to be able to change ds data
logger.info("removing client hold")
self._remove_client_hold()
self.dnssecdata = None
logger.info("placing client hold")
self._place_client_hold()
except RegistryError as e:
logger.error("Error deleting ds data for %s: %s", self.name, e)
e.note = "Error deleting ds data for %s" % self.name
raise e
# check if the domain can be deleted
if not self._domain_can_be_deleted():
note = "Domain has associated objects that prevent deletion."
raise RegistryError(code=ErrorCode.COMMAND_FAILED, note=note)
# delete the domain
request = commands.DeleteDomain(name=self.name)
registry.send(request, cleaned=True)
try:
registry.send(request, cleaned=True)
logger.info("Domain %s deleted successfully.", self.name)
except RegistryError as e:
logger.error("Error deleting domain %s: %s", self.name, e)
raise e
def _domain_can_be_deleted(self, max_attempts=5, wait_interval=2) -> bool:
"""
Polls the registry using InfoDomain calls to confirm that the domain can be deleted.
Returns True if the domain can be deleted, False otherwise. Includes a retry mechanism
using wait_interval and max_attempts, which may be necessary if subdomains and other
associated objects were only recently deleted as the registry may not be immediately updated.
"""
logger.info("Polling registry to confirm deletion pre-conditions for %s", self.name)
last_info_error = None
for attempt in range(max_attempts):
try:
info_response = registry.send(commands.InfoDomain(name=self.name), cleaned=True)
domain_info = info_response.res_data[0]
hosts_associated = getattr(domain_info, "hosts", None)
if hosts_associated is None or len(hosts_associated) == 0:
logger.info("InfoDomain reports no associated hosts for %s. Proceeding with deletion.", self.name)
return True
else:
logger.info("Attempt %d: Domain %s still has hosts: %s", attempt + 1, self.name, hosts_associated)
except RegistryError as info_e:
# If the domain is already gone, we can assume deletion already occurred.
if info_e.code == ErrorCode.OBJECT_DOES_NOT_EXIST:
logger.info("InfoDomain check indicates domain %s no longer exists.", self.name)
raise info_e
logger.warning("Attempt %d: Error during InfoDomain check: %s", attempt + 1, info_e)
time.sleep(wait_interval)
else:
logger.error(
"Exceeded max attempts waiting for domain %s to clear associated objects; last error: %s",
self.name,
last_info_error,
)
return False
def __str__(self) -> str:
return self.name
@ -1840,8 +1918,6 @@ class Domain(TimeStampedModel, DomainHelper):
else:
logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e))
raise e
def _fix_unknown_state(self, cleaned):
"""
_fix_unknown_state: Calls _add_missing_contacts_if_unknown

View file

@ -9,8 +9,11 @@ from .utility.portfolio_helper import (
UserPortfolioPermissionChoices,
UserPortfolioRoleChoices,
cleanup_after_portfolio_member_deletion,
get_domain_requests_description_display,
get_domain_requests_display,
get_domains_description_display,
get_domains_display,
get_members_description_display,
get_members_display,
get_role_display,
validate_portfolio_invitation,
@ -115,6 +118,16 @@ class PortfolioInvitation(TimeStampedModel):
"""
return get_domains_display(self.roles, self.additional_permissions)
@property
def domains_description_display(self):
"""
Returns a string description of the user's domain access level.
Returns:
str: The display name of the user's domain permissions description.
"""
return get_domains_description_display(self.roles, self.additional_permissions)
@property
def domain_requests_display(self):
"""
@ -129,6 +142,16 @@ class PortfolioInvitation(TimeStampedModel):
"""
return get_domain_requests_display(self.roles, self.additional_permissions)
@property
def domain_requests_description_display(self):
"""
Returns a string description of the user's access to domain requests.
Returns:
str: The display name of the user's domain request permissions description.
"""
return get_domain_requests_description_display(self.roles, self.additional_permissions)
@property
def members_display(self):
"""
@ -143,6 +166,16 @@ class PortfolioInvitation(TimeStampedModel):
"""
return get_members_display(self.roles, self.additional_permissions)
@property
def members_description_display(self):
"""
Returns a string description of the user's access to managing members.
Returns:
str: The display name of the user's member management permissions description.
"""
return get_members_description_display(self.roles, self.additional_permissions)
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.

View file

@ -7,8 +7,11 @@ from registrar.models.utility.portfolio_helper import (
MemberPermissionDisplay,
cleanup_after_portfolio_member_deletion,
get_domain_requests_display,
get_domain_requests_description_display,
get_domains_display,
get_domains_description_display,
get_members_display,
get_members_description_display,
get_role_display,
validate_user_portfolio_permission,
)
@ -211,6 +214,16 @@ class UserPortfolioPermission(TimeStampedModel):
"""
return get_domains_display(self.roles, self.additional_permissions)
@property
def domains_description_display(self):
"""
Returns a string description of the user's domain access level.
Returns:
str: The display name of the user's domain permissions description.
"""
return get_domains_description_display(self.roles, self.additional_permissions)
@property
def domain_requests_display(self):
"""
@ -225,6 +238,16 @@ class UserPortfolioPermission(TimeStampedModel):
"""
return get_domain_requests_display(self.roles, self.additional_permissions)
@property
def domain_requests_description_display(self):
"""
Returns a string description of the user's access to domain requests.
Returns:
str: The display name of the user's domain request permissions description.
"""
return get_domain_requests_description_display(self.roles, self.additional_permissions)
@property
def members_display(self):
"""
@ -239,6 +262,16 @@ class UserPortfolioPermission(TimeStampedModel):
"""
return get_members_display(self.roles, self.additional_permissions)
@property
def members_description_display(self):
"""
Returns a string description of the user's access to managing members.
Returns:
str: The display name of the user's member management permissions description.
"""
return get_members_description_display(self.roles, self.additional_permissions)
def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()

View file

@ -123,6 +123,25 @@ def get_domains_display(roles, permissions):
return "Viewer, limited"
def get_domains_description_display(roles, permissions):
"""
Determines the display description for a user's domain viewing permissions.
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain viewing access description.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
return "Can view all domains for the organization"
else:
return "Can view only the domains they manage"
def get_domain_requests_display(roles, permissions):
"""
Determines the display name for a user's domain request permissions.
@ -148,6 +167,27 @@ def get_domain_requests_display(roles, permissions):
return "No access"
def get_domain_requests_description_display(roles, permissions):
"""
Determines the display description for a user's domain request permissions.
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain request access level description.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
return "Can view all domain requests for the organization and create requests"
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
return "Can view all domain requests for the organization"
else:
return "Cannot view or create domain requests"
def get_members_display(roles, permissions):
"""
Determines the display name for a user's member management permissions.
@ -173,6 +213,27 @@ def get_members_display(roles, permissions):
return "No access"
def get_members_description_display(roles, permissions):
"""
Determines the display description for a user's member management permissions.
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's member management access level description.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
return "Can view and manage all member permissions"
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
return "Can view all member permissions"
else:
return "Cannot view member permissions"
def validate_user_portfolio_permission(user_portfolio_permission):
"""
Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports

View file

@ -4,24 +4,22 @@
{% for app in app_list %}
<div class="app-{{ app.app_label }} module{% if app.app_url in request.path|urlencode %} current-app{% endif %}">
<table>
{# .gov override: add headers #}
{% if show_changelinks %}
<colgroup span="3"></colgroup>
{% else %}
<colgroup span="2"></colgroup>
{% endif %}
{# .gov override: display the app name as a caption rather than a table header #}
<caption class="text-bold">{{ app.name }}</caption>
<thead>
<tr>
{% if show_changelinks %}
<th colspan="3" class="primary-th" scope="colgroup">
{{ app.name }}
</th>
{% else %}
<th colspan="2" class="primary-th" scope="colgroup">
{{ app.name }}
</th>
{% endif %}
{# .gov override: hide headers #}
{% comment %}
{% if show_changelinks %}
<th colspan="3" class="primary-th" scope="colgroup">
{{ app.name }}
</th>
{% else %}
<th colspan="2" class="primary-th" scope="colgroup">
{{ app.name }}
</th>
{% endif %}
{% endcomment %}
</tr>
<tr>
<th scope="col">Model</th>
@ -45,16 +43,17 @@
{% endif %}
{% if model.add_url %}
<td><a href="{{ model.add_url }}" class="addlink">{% translate 'Add' %}</a></td>
{% comment %} Remove the 's' from the end of the string to avoid text like "Add domain requests" {% endcomment %}
<td><a href="{{ model.add_url }}" class="addlink" aria-label="Add {{ model.name|slice:":-1" }}">{% translate 'Add' %}</a></td>
{% else %}
<td></td>
{% endif %}
{% if model.admin_url and show_changelinks %}
{% if model.view_only %}
<td><a href="{{ model.admin_url }}" class="viewlink">{% translate 'View' %}</a></td>
<td><a href="{{ model.admin_url }}" class="viewlink" aria-label="View {{ model.name }}">{% translate 'View' %}</a></td>
{% else %}
<td><a href="{{ model.admin_url }}" class="changelink">{% translate 'Change' %}</a></td>
<td><a href="{{ model.admin_url }}" class="changelink" aria-label="Change {{ model.name }}">{% translate 'Change' %}</a></td>
{% endif %}
{% elif show_changelinks %}
<td></td>
@ -64,9 +63,20 @@
</table>
</div>
{% endfor %}
<div class="module module--custom">
<h2>Analytics</h2>
<a class="display-block padding-y-1 padding-x-1" href="{% url 'analytics' %}">Dashboard</a>
<div class="module">
<table class="width-full">
<caption class="text-bold">Analytics</caption>
<thead>
<tr>
<th scope="col">Reports</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><a href="{% url 'analytics' %}">Dashboard</a></th>
</tr>
</tbody>
</table>
</div>
{% else %}
<p>{% translate 'You dont have permission to view or edit anything.' %}</p>

View file

@ -7,10 +7,10 @@
{% if has_absolute_url %}
<ul>
<li>
<a href="{% add_preserved_filters history_url %}" class="historylink">{% translate "History" %}</a>
<button data-href="{% add_preserved_filters history_url %}" class="historylink use-button-as-link">{% translate "History" %}</button>
</li>
<li>
<a href="{{ absolute_url }}" class="viewsitelink">{% translate "View on site" %}</a>
<button data-href="{{ absolute_url }}" class="viewsitelink use-button-as-link">{% translate "View on site" %}</button>
</li>
</ul>
{% else %}
@ -30,18 +30,18 @@
{% endif %}
<li>
<a href="{% add_preserved_filters history_url %}">{% translate "History" %}</a>
<button data-href="{% add_preserved_filters history_url %}" class="historylink use-button-as-link">{% translate "History" %}</button>
</li>
{% if opts.model_name == 'domainrequest' %}
<li>
<a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#">
<button id="id-copy-to-clipboard-summary" class="usa-button--dja">
<svg class="usa-icon">
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<!-- the span is targeted in JS, do not remove -->
<span>{% translate "Copy request summary" %}</span>
</a>
</button>
</li>
{% endif %}
</ul>

View file

@ -5,9 +5,9 @@
{% if has_add_permission %}
<p class="margin-0 padding-0">
{% url cl.opts|admin_urlname:'add' as add_url %}
<a href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink">
<button data-href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink use-button-as-link">
{% blocktranslate with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktranslate %}
</a>
</button>
</p>
{% endif %}
{% endblock %}

View file

@ -19,11 +19,11 @@ Load our custom filters to extract info from the django generated markup.
{% if results.0|contains_checkbox %}
{# .gov - hardcode the select all checkbox #}
<th scope="col" class="action-checkbox-column" title="Toggle all">
<th scope="col" class="action-checkbox-column" title="Toggle">
<div class="text">
<span>
<input type="checkbox" id="action-toggle">
<label for="action-toggle" class="usa-sr-only">Toggle all</label>
<input type="checkbox" id="action-toggle">
</span>
</div>
<div class="clear"></div>
@ -34,9 +34,9 @@ Load our custom filters to extract info from the django generated markup.
{% if header.sortable %}
{% if header.sort_priority > 0 %}
<div class="sortoptions">
<a class="sortremove" href="{{ header.url_remove }}" title="{% translate "Remove from sorting" %}"></a>
<a class="sortremove" href="{{ header.url_remove }}" aria-label="{{ header.text }}" title="{% translate "Remove from sorting" %}"></a>
{% if num_sorted_fields > 1 %}<span class="sortpriority" title="{% blocktranslate with priority_number=header.sort_priority %}Sorting priority: {{ priority_number }}{% endblocktranslate %}">{{ header.sort_priority }}</span>{% endif %}
<a href="{{ header.url_toggle }}" class="toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}"></a>
<a href="{{ header.url_toggle }}" aria-label="{{ header.text }} sorting {% if header.ascending %}ascending{% else %}descending{% endif %}" class="toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}"></a>
</div>
{% endif %}
{% endif %}
@ -61,10 +61,10 @@ Load our custom filters to extract info from the django generated markup.
{% endif %}
<tr>
{% with result_value=result.0|extract_value %}
{% with result_label=result.1|extract_a_text %}
{% with result_label=result.1|extract_a_text checkbox_id="select-"|add:result_value %}
<td>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}" class="action-select">
<label class="usa-sr-only" for="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}">{{ result_label|default:'label' }}</label>
<label class="usa-sr-only" for="{{ checkbox_id }}">Select row {{ result_label|default:'label' }}</label>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ checkbox_id }}" class="action-select">
</td>
{% endwith %}
{% endwith %}

View file

@ -0,0 +1,7 @@
{% load i18n %}
{% load admin_urls %}
{% if has_export_permission %}
{% comment %} Uses the initButtonLinks {% endcomment %}
<li><button class="export_link use-button-as-link" data-href="{% url opts|admin_urlname:"export" %}">{% trans "Export" %}</button></li>
{% endif %}

View file

@ -3,6 +3,6 @@
{% if has_import_permission %}
{% if not IS_PRODUCTION %}
<li><a href='{% url opts|admin_urlname:"import" %}' class="import_link">{% trans "Import" %}</a></li>
<li><button class="import_link use-button-as-link" data-href="{% url opts|admin_urlname:"import" %}">{% trans "Import" %}</button></li>
{% endif %}
{% endif %}

View file

@ -0,0 +1,26 @@
{% comment %} This is an override of the django search bar to add better accessibility compliance.
There are no blocks defined here, so we had to copy the code.
https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/search_form.html
{% endcomment %}
{% load i18n static %}
{% if cl.search_fields %}
<div id="toolbar"><form id="changelist-search" method="get" role="search">
<div><!-- DIV needed for valid HTML -->
{% comment %} .gov override - removed for="searchbar" {% endcomment %}
<label><img src="{% static "admin/img/search.svg" %}" alt="Search"></label>
<input type="text" size="40" name="{{ search_var }}" value="{{ cl.query }}" id="searchbar"{% if cl.search_help_text %} aria-describedby="searchbar_helptext"{% endif %}>
<input type="submit" value="{% translate 'Search' %}">
{% if show_result_count %}
<span class="small quiet">{% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %} (<a href="?{% if cl.is_popup %}{{ is_popup_var }}=1{% if cl.add_facets %}&{% endif %}{% endif %}{% if cl.add_facets %}{{ is_facets_var }}{% endif %}">{% if cl.show_full_result_count %}{% blocktranslate with full_result_count=cl.full_result_count %}{{ full_result_count }} total{% endblocktranslate %}{% else %}{% translate "Show all" %}{% endif %}</a>)</span>
{% endif %}
{% for pair in cl.params.items %}
{% if pair.0 != search_var %}<input type="hidden" name="{{ pair.0 }}" value="{{ pair.1 }}">{% endif %}
{% endfor %}
</div>
{% if cl.search_help_text %}
<br class="clear">
{% comment %} .gov override - added for="searchbar" {% endcomment %}
<label class="help" id="searchbar_helptext" for="searchbar">{{ cl.search_help_text }}</label>
{% endif %}
</form></div>
{% endif %}

View file

@ -0,0 +1,17 @@
{% extends "admin/delete_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 emails. 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.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "admin/delete_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 will not send any emails when you click "Save".
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -21,7 +21,7 @@
{% if field and field.field and field.field.descriptions %}
{% with description=field.field.descriptions|get_dict_value:option.value %}
{% if description %}
<p class="margin-0 margin-top-1 font-body-2xs">{{ description }}</p>
<p class="margin-0 font-body-2xs">{{ description }}</p>
{% endif %}
{% endwith %}
{% endif %}

View file

@ -42,17 +42,18 @@
{% endblock breadcrumb %}
<h1>Add a domain manager</h1>
{% if has_organization_feature_flag %}
{% if portfolio %}
<p>
Provide an email address for the domain manager youd like to add.
Theyll need to access the registrar using a Login.gov account thats associated with this email address.
Domain managers can be a member of only one .gov organization.
</p>
{% else %}
<p>
You can add another user to help manage your domain. Users can only be a member of one .gov organization,
and they'll need to sign in with their Login.gov account.
Provide an email address for the domain manager youd like to add.
Theyll need to access the registrar using a Login.gov account thats associated with this email address.
</p>
{% else %}
<p>
You can add another user to help manage your domain. They will need to sign in to the .gov registrar with
their Login.gov account.
</p>
{% endif %}
{% endif %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}

View file

@ -25,29 +25,25 @@
<h1>Domain managers</h1>
{% comment %}Copy below differs depending on whether view is in portfolio mode.{% endcomment %}
{% if not portfolio %}
<p>
Domain managers can update all information related to a domain within the
.gov registrar, including security email and DNS name servers.
Domain managers can update information related to this domain, including security email and DNS name servers.
</p>
{% else %}
<p>
Domain managers can update all information related to a domain within the
.gov registrar, including contact details, senior official, security email, and DNS name servers.
</p>
{% endif %}
<ul class="usa-list">
<li>There is no limit to the number of domain managers you can add.</li>
<li>After adding a domain manager, an email invitation will be sent to that user with
instructions on how to set up an account.</li>
<li>There is no limit on the number of domain managers you can add.</li>
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
{% if not portfolio %}<li>All domain managers will be notified when updates are made to this domain.</li>{% endif %}
<li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain.
{% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}</li>
<li>All domain managers will be notified when updates are made to this domain and when managers are added or removed.</li>
<li>Domains must have at least one manager. You cant remove yourself if youre the only one assigned to this domain.</li>
</ul>
{% if domain_manager_roles and domain_manager_roles|length == 1 %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
This domain has only one manager. Consider adding another manager to ensure the domain has continuous oversight and support.
</div>
</div>
{% endif %}
{% if domain_manager_roles %}
<section class="section-outlined" id="domain-managers">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">

View file

@ -8,7 +8,6 @@ To manage domain information, visit the .gov registrar <{{ manage_url }}>.
----------------------------------------------------------------
{% if not requested_user %}
YOU NEED A LOGIN.GOV ACCOUNT
Youll need a Login.gov account to access the .gov registrar. That account needs to be
associated with the following email address: {{ invitee_email_address }}
@ -17,8 +16,6 @@ Login.gov provides a simple and secure process for signing in to many government
services with one account. If you dont already have one, follow these steps to create
your Login.gov account <https://login.gov/help/get-started/create-your-account/>.
{% endif %}
DOMAIN MANAGEMENT
As a .gov domain manager, you can add or update information like name servers. Youll
also serve as a contact for the domains you manage. Please keep your contact

View file

@ -11,6 +11,7 @@ MANAGER REMOVED: {{ manager_removed.email }}
WHY DID YOU RECEIVE THIS EMAIL?
Youre listed as a domain manager for {{ domain.name }}, so youll receive a notification whenever a domain manager is removed from that domain.
If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email.
THANK YOU

View file

@ -0,0 +1,21 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
{{ requestor_email }} has removed you from {{ portfolio.organization_name }}.
You can no longer view this organization or its related domains within the .gov registrar.
SOMETHING WRONG?
If you have questions or concerns, reach out to the person who removed you from the
organization, or reply to this email.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
You've been removed from a .gov organization

View file

@ -1,20 +1,20 @@
{% load field_helpers %}
<div id="member-basic-permissions" class="margin-top-2">
<h2>What permissions do you want to add?</h2>
<h2>Member permissions</h2>
<p>Configure the permissions for this member. Basic members cannot manage member permissions or organization metadata.</p>
<h3 class="margin-bottom-0">Domains <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %}
<h3 class="margin-bottom-neg-1 margin-top-4">Domains <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" label_classes="margin-top-1" %}
{% input_with_errors form.domain_permissions %}
{% endwith %}
<h3 class="margin-bottom-0">Domain requests <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %}
<h3 class="margin-bottom-neg-1 margin-top-2">Domain requests <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" label_classes="margin-top-1" %}
{% input_with_errors form.domain_request_permissions %}
{% endwith %}
<h3 class="margin-bottom-0">Members <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %}
<h3 class="margin-bottom-neg-1 margin-top-2">Members <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" label_classes="margin-top-1" %}
{% input_with_errors form.member_permissions %}
{% endwith %}
</div>

View file

@ -1,6 +1,6 @@
<h4 class="margin-bottom-0">Assigned domains</h4>
{% if domain_count > 0 %}
<h4 class="margin-bottom-0">Domains assigned</h4>
<p class="margin-top-0">{{domain_count}}</p>
{% else %}
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Manage".{% endif %}</p>
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Edit".{% endif %}</p>
{% endif %}

View file

@ -60,7 +60,7 @@
></div>
</div>
<div class="display-none margin-bottom-4" id="edit-member-domains__no-data">
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p>
<p>This member does not manage any domains.</p>
</div>
<div class="display-none margin-bottom-4" id="edit-member-domains__no-search-results">
<p>No results found</p>

View file

@ -67,7 +67,7 @@
></div>
</div>
<div class="display-none margin-bottom-4" id="member-domains__no-data">
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p>
<p>This member does not manage any domains.</p>
</div>
<div class="display-none margin-bottom-4" id="member-domains__no-search-results">
<p>No results found</p>

View file

@ -2,10 +2,10 @@
<p class="margin-top-0">{{ permissions.role_display }}</p>
<h4 class="margin-bottom-0 text-primary">Domains</h4>
<p class="margin-top-0">{{ permissions.domains_display }}</p>
<p class="margin-top-0">{{ permissions.domains_display }}: {{ permissions.domains_description_display }}</p>
<h4 class="margin-bottom-0 text-primary">Domain requests</h4>
<p class="margin-top-0">{{ permissions.domain_requests_display }}</p>
<p class="margin-top-0">{{ permissions.domain_requests_display }}: {{ permissions.domain_requests_description_display }}</p>
<h4 class="margin-bottom-0 text-primary">Members</h4>
<p class="margin-top-0">{{ permissions.members_display }}</p>
<p class="margin-top-0">{{ permissions.members_display }}: {{ permissions.members_description_display }}</p>

View file

@ -134,18 +134,29 @@
{% endif %}
</div>
{% if editable and edit_link %}
<div class="text-right">
<a
href="{{ edit_link }}"
class="usa-link usa-link--icon font-sans-sm line-height-sans-4"
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{% static 'img/sprite.svg' %}#{% if manage_button %}settings{% elif view_button %}visibility{% else %}edit{% endif %}"></use>
</svg>
{% if manage_button %}Manage{% elif view_button %}View{% else %}Edit{% endif %}<span class="sr-only"> {{ title }}</span>
</a>
</div>
{% comment %}We have conditions where an edit_link is set but editable can be true or false{% endcomment %}
{% if edit_link %}
{% if manage_button or editable or view_button %}
<div class="text-right">
<a
href="{{ edit_link }}"
class="usa-link usa-link--icon font-sans-sm line-height-sans-4"
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{% static 'img/sprite.svg' %}#{% if manage_button %}settings{% elif editable %}edit{% else %}visibility{% endif %}"></use>
</svg>
{% if manage_button %}
Manage
{% elif editable %}
Edit
{% else %}
View
{% endif %}
<span class="sr-only"> {{ title|default:"Page" }}</span>
</a>
</div>
{% endif %}
{% endif %}
</div>
</section>

View file

@ -9,7 +9,7 @@
{# the entire logged in page goes here #}
<div class="grid-row {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="desktop:grid-col-10 {% if not is_widescreen_centered %}tablet:grid-col-11 {% else %}tablet:padding-left-4 tablet:padding-right-4 tablet:grid-col-12 desktop:grid-offset-1{% endif %}">
<div class="desktop:grid-col-10{% if not is_widescreen_centered %} tablet:grid-col-11{% else %} tablet:grid-col-12 desktop:grid-offset-1{% endif %}">
{% block portfolio_content %}{% endblock %}

View file

@ -31,7 +31,7 @@ Organization member
<h1 class="margin-bottom-3">Manage member</h1>
<div class="tablet:display-flex tablet:flex-justify">
<h2 class="margin-top-0 margin-bottom-3 break-word">
<h2 class="margin-top-0 margin-bottom-3 break-word flex-align-self-baseline">
{% if member %}
{{ member.email }}
{% elif portfolio_invitation %}
@ -46,6 +46,7 @@ Organization member
data-member-id="{{ member.id }}"
data-num-domains="{{ portfolio_permission.get_managed_domains_count }}"
data-member-email="{{ member.email }}"
class="flex-align-self-baseline"
>
<!-- JS should inject member kebob here -->
</div>
@ -56,6 +57,7 @@ Organization member
data-member-id="{{ portfolio_invitation.id }}"
data-num-domains="{{ portfolio_invitation.get_managed_domains_count }}"
data-member-email="{{ portfolio_invitation.email }}"
class="flex-align-self-baseline"
>
<!-- JS should inject invited kebob here -->
</div>
@ -65,9 +67,9 @@ Organization member
<form method="post" id="member-delete-form" action="{{ request.path }}/delete"> {% csrf_token %} </form>
<address>
<strong class="text-primary-dark">Last active:</strong>
<strong class="text-primary-darker">Last active:</strong>
{% if member and member.last_login %}
{{ member.last_login }}
{{ member.last_login|date:"F j, Y" }}
{% elif portfolio_invitation %}
Invited
{% else %}
@ -75,7 +77,7 @@ Organization member
{% endif %}
<br />
<strong class="text-primary-dark">Full name:</strong>
<strong class="text-primary-darker">Full name:</strong>
{% if member %}
{% if member.first_name or member.last_name %}
{{ member.get_formatted_name }}
@ -87,7 +89,7 @@ Organization member
{% endif %}
<br />
<strong class="text-primary-dark">Title or organization role:</strong>
<strong class="text-primary-darker">Title or organization role:</strong>
{% if member and member.title %}
{{ member.title }}
{% else %}
@ -101,11 +103,11 @@ Organization member
{% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_invitation edit_link=edit_url editable=has_edit_members_portfolio_permission %}
{% endif %}
{% comment %}view_button is passed below as true in all cases. This is because manage_button logic will trump view_button logic; ie. if manage_button is true, view_button will never be looked at{% endcomment %}
{% comment %}view_button is passed below as true in all cases. This is because editable logic will trump view_button logic; ie. if editable is true, view_button will never be looked at{% endcomment %}
{% if portfolio_permission %}
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link=domains_url editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
{% include "includes/summary_item.html" with title='Domain assignments' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link=domains_url editable=has_edit_members_portfolio_permission view_button=True %}
{% elif portfolio_invitation %}
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link=domains_url editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
{% include "includes/summary_item.html" with title='Domain assignments' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link=domains_url editable=has_edit_members_portfolio_permission view_button=True %}
{% endif %}
</div>

View file

@ -52,7 +52,7 @@
</div>
<p class="margin-top-0 margin-bottom-4 maxw-none">
A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains.
Members can update information related to their assigned domains, including security email and DNS name servers. They can also invite other managers to those domains.
</p>
{% include "includes/member_domains_table.html" %}

View file

@ -41,9 +41,11 @@
<section id="domain-assignments-edit-view">
<h1>Edit domain assignments</h1>
<p class="margin-top-0 margin-bottom-4 maxw-none">
A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains.
When you save this form the member will get an email to notify them of any changes.
<p class="margin-top-0 maxw-none">
Use the checkboxes to add or remove domain assignments for this member. Then proceed to the next step to confirm and save your changes.
</p>
<p class="margin-bottom-4 maxw-none">
Domains must have at least one domain manager. You can't remove this member from a domain if theyre the only one assigned to it.
</p>
{% include "includes/member_domains_edit_table.html" %}
@ -72,33 +74,19 @@
</section>
<section id="domain-assignments-readonly-view" class="display-none">
<h1 class="margin-bottom-3">Review domain assignments</h1>
<h1 class="margin-bottom-4">Review and apply domain assignment changes</h1>
<h2 class="margin-top-0">Would you like to continue with the following domain assignment changes for
<h3 class="margin-bottom-05 h4">Member</h3>
<p class="margin-top-0">
{% if member %}
{{ member.email }}
{% else %}
{{ portfolio_invitation.email }}
{% endif %}
</h2>
<p class="margin-bottom-4">
When you save this form the member will get an email to notify them of any changes.
</p>
<div id="domain-assignments-summary" class="margin-bottom-5">
<!-- AJAX will populate this summary -->
<h3 class="margin-bottom-1 h4">Unassigned domains</h3>
<ul class="usa-list usa-list--unstyled">
<li>item1</li>
<li>item2</li>
</ul>
<h3 class="margin-bottom-0 h4">Assigned domains</h3>
<ul class="usa-list usa-list--unstyled">
<li>item1</li>
<li>item2</li>
</ul>
</div>
<ul class="usa-button-group">
@ -118,7 +106,7 @@
type="button"
class="usa-button"
>
Save
Apply changes
</button>
</li>
</ul>

View file

@ -18,24 +18,24 @@
{% endblock messages%}
<!-- Navigation breadcrumbs -->
<nav class="usa-breadcrumb padding-top-0 bg-gray-1" aria-label="Domain request breadcrumb">
<ol class="usa-breadcrumb__list">
{% url 'members' as url %}
{% if portfolio_permission %}
{% url 'member' pk=portfolio_permission.id as url2 %}
{% else %}
{% url '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">
<li class="usa-breadcrumb__list-item">
<a href="{% url 'members' %}" class="usa-breadcrumb__link"><span>Members</span></a>
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
</li>
<li class="usa-breadcrumb__list-item">
{% if member %}
{% url 'member' pk=member.pk as back_url %}
{% elif invitation %}
{% url 'invitedmember' pk=invitation.pk as back_url %}
{% endif %}
<a href="{{ back_url }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
<a href="{{ url2 }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
</li>
{% comment %} Manage members {% endcomment %}
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Member access and permissions</span>
</li>
</ol>
</ol>
</nav>
<!-- Page header -->
@ -78,12 +78,12 @@
<!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset">
<legend>
<h2 class="margin-top-0">Member Access</h2>
<h2 class="margin-top-0">Member access</h2>
</legend>
<em>Select the level of access for this member. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% with group_classes="margin-top-0" add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors form.role %}
{% endwith %}
@ -98,14 +98,14 @@
<div class="margin-top-3">
<a
type="button"
href="{{ back_url }}"
href="{{ url2 }}"
class="usa-button usa-button--outline"
name="btn-cancel-click"
aria-label="Cancel editing member"
>
Cancel
</a>
<button type="submit" class="usa-button">Update Member</button>
<button type="submit" class="usa-button">Update member</button>
</div>
</form>
</div>

View file

@ -55,9 +55,9 @@
<h2>What level of access would you like to grant this member?</h2>
</legend>
<p class="margin-y-0">Select one <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
<p class="margin-y-0">Select the level of access for this member. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% with group_classes="margin-top-0" add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors form.role %}
{% endwith %}
</fieldset>
@ -67,7 +67,7 @@
<!-- Basic access form -->
{% include "includes/member_basic_permissions.html" %}
<h3 class="margin-bottom-1">Domain management</h3>
<h3 class="margin-bottom-1">Domain assignments</h3>
<p class="margin-top-0">After you invite this person to your organization, you can assign domain management permissions on their member profile.</p>
@ -88,7 +88,7 @@
aria-controls="invite-member-modal"
data-open-modal
>Trigger invite member modal</a>
<button id="invite_new_member_submit" type="submit" class="usa-button">Invite Member</button>
<button id="invite_new_member_submit" type="submit" class="usa-button">Invite member</button>
</div>
</form>
@ -104,13 +104,10 @@
<h2 class="usa-modal__heading" id="invite-member-heading">
Invite this member to the organization?
</h2>
<h3>Member information and permissions</h3>
<!-- Display email as a header and access level -->
<h4 class="margin-bottom-0">Email</h4>
<p class="margin-top-0" id="modalEmail"></p>
<h3>Member access and permissions</h3>
<h4 class="margin-bottom-0">Member Access</h4>
<p class="margin-top-0" id="modalAccessLevel"></p>
<p class="margin-bottom-1"><strong class="text-primary-darker">Email:</strong> <span id="modalEmail"></span></p>
<p class="margin-top-0 margin-bottom-1"><strong class="text-primary-darker">Member access:</strong> <span id="modalAccessLevel"></span></p>
<!-- Dynamic Permissions Details -->
<div id="permission_details"></div>
@ -123,7 +120,7 @@
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled"
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
onclick="closeModal()"
>

View file

@ -25,11 +25,15 @@ def extract_a_text(value):
pattern = r"<a\b[^>]*>(.*?)</a>"
match = re.search(pattern, value)
if match:
extracted_text = match.group(1)
else:
extracted_text = ""
# Get the content and strip any nested HTML tags
content = match.group(1)
# Remove any nested HTML tags (like <img>)
text_pattern = r"<[^>]+>"
text_only = re.sub(text_pattern, "", content)
# Clean up any extra whitespace
return text_only.strip()
return extracted_text
return ""
@register.filter

View file

@ -55,6 +55,7 @@ from .common import (
MockDbForSharedTests,
AuditedAdminMockData,
completed_domain_request,
create_test_user,
generic_domain_object,
less_console_noise,
mock_user,
@ -1135,6 +1136,7 @@ class TestUserPortfolioPermissionAdmin(TestCase):
"""Create a client object"""
self.client = Client(HTTP_HOST="localhost:8080")
self.superuser = create_superuser()
self.testuser = create_test_user()
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
def tearDown(self):
@ -1167,6 +1169,21 @@ class TestUserPortfolioPermissionAdmin(TestCase):
"If you add someone to a portfolio here, it will not trigger an invitation email.",
)
@less_console_noise_decorator
def test_delete_confirmation_page_contains_static_message(self):
"""Ensure the custom message appears in the delete confirmation page."""
self.client.force_login(self.superuser)
# Create a test portfolio permission
self.permission = UserPortfolioPermission.objects.create(
user=self.testuser, portfolio=self.portfolio, roles=["organization_member"]
)
delete_url = reverse("admin:registrar_userportfoliopermission_delete", args=[self.permission.pk])
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"
self.assertIn(expected_message, response.content.decode("utf-8"))
class TestPortfolioInvitationAdmin(TestCase):
"""Tests for the PortfolioInvitationAdmin class as super user
@ -1605,6 +1622,21 @@ class TestPortfolioInvitationAdmin(TestCase):
request, "Could not send email notification to existing organization admins."
)
@less_console_noise_decorator
def test_delete_confirmation_page_contains_static_message(self):
"""Ensure the custom message appears in the delete confirmation page."""
self.client.force_login(self.superuser)
# Create a test portfolio invitation
self.invitation = PortfolioInvitation.objects.create(
email="testuser@example.com", portfolio=self.portfolio, roles=["organization_member"]
)
delete_url = reverse("admin:registrar_portfolioinvitation_delete", args=[self.invitation.pk])
response = self.client.get(delete_url)
# Check if the response contains the expected static message
expected_message = "If you cancel the portfolio invitation here"
self.assertIn(expected_message, response.content.decode("utf-8"))
class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user
@ -3816,7 +3848,7 @@ class TestTransferUser(WebTest):
with self.assertRaises(User.DoesNotExist):
self.user2.refresh_from_db()
# @less_console_noise_decorator
@less_console_noise_decorator
def test_transfer_user_throws_transfer_and_delete_success_messages(self):
"""Test that success messages for data transfer and user deletion are displayed."""
# Ensure the setup for VerifiedByStaff

View file

@ -178,7 +178,7 @@ class TestDomainAdminAsStaff(MockEppLib):
Then a user-friendly success message is returned for displaying on the web
And `state` is set to `DELETED`
"""
domain = create_ready_domain()
domain, _ = Domain.objects.get_or_create(name="my-nameserver.gov", state=Domain.State.READY)
# Put in client hold
domain.place_client_hold()
# Ensure everything is displaying correctly
@ -212,7 +212,7 @@ class TestDomainAdminAsStaff(MockEppLib):
mock_add_message.assert_called_once_with(
request,
messages.INFO,
"Domain city.gov has been deleted. Thanks!",
"Domain my-nameserver.gov has been deleted. Thanks!",
extra_tags="",
fail_silently=False,
)
@ -266,7 +266,7 @@ class TestDomainAdminAsStaff(MockEppLib):
mock_add_message.assert_called_once_with(
request,
messages.ERROR,
"Error deleting this Domain: This subdomain is being used as a hostname on another domain: ns1.sharedhost.com", # noqa
"Error deleting this Domain: Command failed with note: Domain has associated objects that prevent deletion.", # noqa
extra_tags="",
fail_silently=False,
)
@ -321,7 +321,7 @@ class TestDomainAdminAsStaff(MockEppLib):
Then `commands.DeleteDomain` is sent to the registry
And Domain returns normally without an error dialog
"""
domain = create_ready_domain()
domain, _ = Domain.objects.get_or_create(name="my-nameserver.gov", state=Domain.State.READY)
# Put in client hold
domain.place_client_hold()
# Ensure everything is displaying correctly
@ -340,12 +340,13 @@ class TestDomainAdminAsStaff(MockEppLib):
)
request.user = self.client
# Delete it once
with patch("django.contrib.messages.add_message") as mock_add_message:
self.admin.do_delete_domain(request, domain)
mock_add_message.assert_called_once_with(
request,
messages.INFO,
"Domain city.gov has been deleted. Thanks!",
"Domain my-nameserver.gov has been deleted. Thanks!",
extra_tags="",
fail_silently=False,
)
@ -881,7 +882,7 @@ class TestDomainAdminWithClient(TestCase):
response = self.client.get("/admin/registrar/domain/")
# There are 4 template references to Federal (4) plus four references in the table
# for our actual domain_request
self.assertContains(response, "Federal", count=56)
self.assertContains(response, "Federal", count=57)
# This may be a bit more robust
self.assertContains(response, '<td class="field-converted_generic_org_type">Federal</td>', count=1)
# Now let's make sure the long description does not exist

View file

@ -662,7 +662,7 @@ class TestDomainRequestAdmin(MockEppLib):
response = self.client.get("/admin/registrar/domainrequest/?generic_org_type__exact=federal")
# There are 2 template references to Federal (4) and two in the results data
# of the request
self.assertContains(response, "Federal", count=54)
self.assertContains(response, "Federal", count=55)
# This may be a bit more robust
self.assertContains(response, '<td class="field-converted_generic_org_type">Federal</td>', count=1)
# Now let's make sure the long description does not exist

View file

@ -3,6 +3,7 @@ from unittest.mock import patch, MagicMock
from datetime import date
from registrar.models.domain import Domain
from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user import User
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
@ -16,6 +17,8 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
send_portfolio_invitation_remove_email,
send_portfolio_member_permission_remove_email,
send_portfolio_member_permission_update_email,
)
@ -963,3 +966,149 @@ class TestSendPortfolioMemberPermissionUpdateEmail(unittest.TestCase):
# Assertions
mock_logger.warning.assert_not_called() # Function should fail before logging email failure
class TestSendPortfolioMemberPermissionRemoveEmail(unittest.TestCase):
"""Unit tests for send_portfolio_member_permission_remove_email function."""
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.utility.email_invitations._get_requestor_email")
def test_send_email_success(self, mock_get_requestor_email, mock_send_email):
"""Test that the email is sent successfully when there are no errors."""
# Mock data
requestor = MagicMock()
permissions = MagicMock(spec=UserPortfolioPermission)
permissions.user.email = "user@example.com"
permissions.portfolio.organization_name = "Test Portfolio"
mock_get_requestor_email.return_value = "requestor@example.com"
# Call function
result = send_portfolio_member_permission_remove_email(requestor, permissions)
# Assertions
mock_get_requestor_email.assert_called_once_with(requestor, portfolio=permissions.portfolio)
mock_send_email.assert_called_once_with(
"emails/portfolio_removal.txt",
"emails/portfolio_removal_subject.txt",
to_address="user@example.com",
context={
"requested_user": permissions.user,
"portfolio": permissions.portfolio,
"requestor_email": "requestor@example.com",
},
)
self.assertTrue(result)
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError("Email failed"))
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations.logger")
def test_send_email_failure(self, mock_logger, mock_get_requestor_email, mock_send_email):
"""Test that the function returns False and logs an error when email sending fails."""
# Mock data
requestor = MagicMock()
permissions = MagicMock(spec=UserPortfolioPermission)
permissions.user.email = "user@example.com"
permissions.portfolio.organization_name = "Test Portfolio"
mock_get_requestor_email.return_value = "requestor@example.com"
# Call function
result = send_portfolio_member_permission_remove_email(requestor, permissions)
# Assertions
mock_logger.warning.assert_called_once_with(
"Could not send email organization member removal notification to %s for portfolio: %s",
permissions.user.email,
permissions.portfolio.organization_name,
exc_info=True,
)
self.assertFalse(result)
@patch("registrar.utility.email_invitations._get_requestor_email", side_effect=Exception("Unexpected error"))
@patch("registrar.utility.email_invitations.logger")
def test_requestor_email_retrieval_failure(self, mock_logger, mock_get_requestor_email):
"""Test that an exception in retrieving requestor email is logged."""
# Mock data
requestor = MagicMock()
permissions = MagicMock(spec=UserPortfolioPermission)
# Call function
with self.assertRaises(Exception):
send_portfolio_member_permission_remove_email(requestor, permissions)
# Assertions
mock_logger.warning.assert_not_called() # Function should fail before logging email failure
class TestSendPortfolioInvitationRemoveEmail(unittest.TestCase):
"""Unit tests for send_portfolio_invitation_remove_email function."""
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.utility.email_invitations._get_requestor_email")
def test_send_email_success(self, mock_get_requestor_email, mock_send_email):
"""Test that the email is sent successfully when there are no errors."""
# Mock data
requestor = MagicMock()
invitation = MagicMock(spec=PortfolioInvitation)
invitation.email = "user@example.com"
invitation.portfolio.organization_name = "Test Portfolio"
mock_get_requestor_email.return_value = "requestor@example.com"
# Call function
result = send_portfolio_invitation_remove_email(requestor, invitation)
# Assertions
mock_get_requestor_email.assert_called_once_with(requestor, portfolio=invitation.portfolio)
mock_send_email.assert_called_once_with(
"emails/portfolio_removal.txt",
"emails/portfolio_removal_subject.txt",
to_address="user@example.com",
context={
"requested_user": None,
"portfolio": invitation.portfolio,
"requestor_email": "requestor@example.com",
},
)
self.assertTrue(result)
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError("Email failed"))
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations.logger")
def test_send_email_failure(self, mock_logger, mock_get_requestor_email, mock_send_email):
"""Test that the function returns False and logs an error when email sending fails."""
# Mock data
requestor = MagicMock()
invitation = MagicMock(spec=PortfolioInvitation)
invitation.email = "user@example.com"
invitation.portfolio.organization_name = "Test Portfolio"
mock_get_requestor_email.return_value = "requestor@example.com"
# Call function
result = send_portfolio_invitation_remove_email(requestor, invitation)
# Assertions
mock_logger.warning.assert_called_once_with(
"Could not send email organization member removal notification to %s for portfolio: %s",
invitation.email,
invitation.portfolio.organization_name,
exc_info=True,
)
self.assertFalse(result)
@patch("registrar.utility.email_invitations._get_requestor_email", side_effect=Exception("Unexpected error"))
@patch("registrar.utility.email_invitations.logger")
def test_requestor_email_retrieval_failure(self, mock_logger, mock_get_requestor_email):
"""Test that an exception in retrieving requestor email is logged."""
# Mock data
requestor = MagicMock()
invitation = MagicMock(spec=PortfolioInvitation)
# Call function
with self.assertRaises(Exception):
send_portfolio_invitation_remove_email(requestor, invitation)
# Assertions
mock_logger.warning.assert_not_called() # Function should fail before logging email failure

View file

@ -35,6 +35,7 @@ from epplibwrapper import (
from .common import MockEppLib, MockSESClient, less_console_noise
import logging
import boto3_mocking # type: ignore
import copy
logger = logging.getLogger(__name__)
@ -97,58 +98,59 @@ class TestDomainCache(MockEppLib):
self.mockedSendFunction.assert_has_calls(expectedCalls)
# @less_console_noise_decorator
def test_cache_nested_elements_not_subdomain(self):
"""Cache works correctly with the nested objects cache and hosts"""
with less_console_noise():
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
# The contact list will initially contain objects of type 'DomainContact'
# this is then transformed into PublicContact, and cache should NOT
# hold onto the DomainContact object
expectedUnfurledContactsList = [
common.DomainContact(contact="123", type="security"),
]
expectedContactsDict = {
PublicContact.ContactTypeChoices.ADMINISTRATIVE: "adminContact",
PublicContact.ContactTypeChoices.SECURITY: "securityContact",
PublicContact.ContactTypeChoices.TECHNICAL: "technicalContact",
}
expectedHostsDict = {
"name": self.mockDataInfoDomain.hosts[0],
"addrs": [], # should return empty bc fake.host.com is not a subdomain of igorville.gov
"cr_date": self.mockDataInfoHosts.cr_date,
}
# this can be changed when the getter for contacts is implemented
domain._get_property("contacts")
domain, _ = Domain.objects.get_or_create(name="igorville.gov")
# The contact list will initially contain objects of type 'DomainContact'
# this is then transformed into PublicContact, and cache should NOT
# hold onto the DomainContact object
expectedUnfurledContactsList = [
common.DomainContact(contact="123", type="security"),
]
expectedContactsDict = {
PublicContact.ContactTypeChoices.ADMINISTRATIVE: "adminContact",
PublicContact.ContactTypeChoices.SECURITY: "securityContact",
PublicContact.ContactTypeChoices.TECHNICAL: "technicalContact",
}
expectedHostsDict = {
"name": self.mockDataInfoDomain.hosts[0],
"addrs": [], # should return empty bc fake.host.com is not a subdomain of igorville.gov
"cr_date": self.mockDataInfoHosts.cr_date,
}
# check domain info is still correct and not overridden
self.assertEqual(domain._cache["auth_info"], self.mockDataInfoDomain.auth_info)
self.assertEqual(domain._cache["cr_date"], self.mockDataInfoDomain.cr_date)
# this can be changed when the getter for contacts is implemented
domain._get_property("contacts")
# check contacts
self.assertEqual(domain._cache["_contacts"], self.mockDataInfoDomain.contacts)
# The contact list should not contain what is sent by the registry by default,
# as _fetch_cache will transform the type to PublicContact
self.assertNotEqual(domain._cache["contacts"], expectedUnfurledContactsList)
# check domain info is still correct and not overridden
self.assertEqual(domain._cache["auth_info"], self.mockDataInfoDomain.auth_info)
self.assertEqual(domain._cache["cr_date"], self.mockDataInfoDomain.cr_date)
self.assertEqual(domain._cache["contacts"], expectedContactsDict)
# check contacts
self.assertEqual(domain._cache["_contacts"], self.mockDataInfoDomain.contacts)
# The contact list should not contain what is sent by the registry by default,
# as _fetch_cache will transform the type to PublicContact
self.assertNotEqual(domain._cache["contacts"], expectedUnfurledContactsList)
# get and check hosts is set correctly
domain._get_property("hosts")
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
self.assertEqual(domain._cache["contacts"], expectedContactsDict)
# invalidate cache
domain._cache = {}
self.assertEqual(domain._cache["contacts"], expectedContactsDict)
# get host
domain._get_property("hosts")
# Should return empty bc fake.host.com is not a subdomain of igorville.gov
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
# get and check hosts is set correctly
domain._get_property("hosts")
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
self.assertEqual(domain._cache["contacts"], expectedContactsDict)
# invalidate cache
domain._cache = {}
# get contacts
domain._get_property("contacts")
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
self.assertEqual(domain._cache["contacts"], expectedContactsDict)
# get host
domain._get_property("hosts")
# Should return empty bc fake.host.com is not a subdomain of igorville.gov
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
# get contacts
domain._get_property("contacts")
self.assertEqual(domain._cache["hosts"], [expectedHostsDict])
self.assertEqual(domain._cache["contacts"], expectedContactsDict)
def test_cache_nested_elements_is_subdomain(self):
"""Cache works correctly with the nested objects cache and hosts"""
@ -1248,6 +1250,13 @@ class TestRegistrantNameservers(MockEppLib):
name="threenameserversDomain.gov", state=Domain.State.READY
)
def tearDown(self):
PublicContact.objects.all().delete()
HostIP.objects.all().delete()
Host.objects.all().delete()
Domain.objects.all().delete()
super().tearDown()
def test_get_nameserver_changes_success_deleted_vals(self):
"""Testing only deleting and no other changes"""
with less_console_noise():
@ -1797,6 +1806,7 @@ class TestRegistrantNameservers(MockEppLib):
mock_host_ip_get_or_create.assert_not_called()
self.assertEqual(mock_host_ip_get_or_create.call_count, 0)
# @less_console_noise_decorator
def test_nameservers_stored_on_fetch_cache_not_subdomain_with_ip(self):
"""
Scenario: Nameservers are stored in db when they are retrieved from fetch_cache.
@ -1808,21 +1818,20 @@ class TestRegistrantNameservers(MockEppLib):
#3: Nameserver is not a subdomain, but it does have an IP address returned
due to how we set up our defaults
"""
with less_console_noise():
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
domain, _ = Domain.objects.get_or_create(name="freeman.gov", state=Domain.State.READY)
with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object(
HostIP.objects, "get_or_create"
) as mock_host_ip_get_or_create:
mock_host_get_or_create.return_value = (Host(domain=domain), True)
mock_host_ip_get_or_create.return_value = (HostIP(), True)
with patch.object(Host.objects, "get_or_create") as mock_host_get_or_create, patch.object(
HostIP.objects, "get_or_create"
) as mock_host_ip_get_or_create:
mock_host_get_or_create.return_value = (Host(domain=domain), True)
mock_host_ip_get_or_create.return_value = (HostIP(), True)
# force fetch_cache to be called, which will return above documented mocked hosts
domain.nameservers
# force fetch_cache to be called, which will return above documented mocked hosts
domain.nameservers
mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.host.com")
mock_host_ip_get_or_create.assert_not_called()
self.assertEqual(mock_host_ip_get_or_create.call_count, 0)
mock_host_get_or_create.assert_called_once_with(domain=domain, name="fake.host.com")
mock_host_ip_get_or_create.assert_not_called()
self.assertEqual(mock_host_ip_get_or_create.call_count, 0)
def test_nameservers_stored_on_fetch_cache_not_subdomain_without_ip(self):
"""
@ -1861,12 +1870,6 @@ class TestRegistrantNameservers(MockEppLib):
with self.assertRaises(RegistryError):
domain.nameservers = [("ns1.failednameserver.gov", ["4.5.6"])]
def tearDown(self):
HostIP.objects.all().delete()
Host.objects.all().delete()
Domain.objects.all().delete()
return super().tearDown()
class TestNameserverValidation(TestCase):
"""Test the isValidDomain method which validates nameservers"""
@ -1947,8 +1950,6 @@ class TestRegistrantDNSSEC(MockEppLib):
And a domain exists in the registry
"""
super().setUp()
# for the tests, need a domain in the unknown state
self.domain, _ = Domain.objects.get_or_create(name="fake.gov")
def tearDown(self):
PublicContact.objects.all().delete()
@ -2041,6 +2042,7 @@ class TestRegistrantDNSSEC(MockEppLib):
self.assertEquals(dnssecdata_get.dsData, self.dnssecExtensionWithDsData.dsData)
patcher.stop()
@less_console_noise_decorator
def test_dnssec_is_idempotent(self):
"""
Scenario: Registrant adds DNS data twice, due to a UI glitch
@ -2126,6 +2128,7 @@ class TestRegistrantDNSSEC(MockEppLib):
self.assertEquals(dnssecdata_get.dsData, self.dnssecExtensionWithDsData.dsData)
patcher.stop()
@less_console_noise_decorator
def test_user_adds_dnssec_data_multiple_dsdata(self):
"""
Scenario: Registrant adds DNSSEC data with multiple DSData.
@ -2194,6 +2197,7 @@ class TestRegistrantDNSSEC(MockEppLib):
self.assertEquals(dnssecdata_get.dsData, self.dnssecExtensionWithMultDsData.dsData)
patcher.stop()
# @less_console_noise_decorator
def test_user_removes_dnssec_data(self):
"""
Scenario: Registrant removes DNSSEC ds data.
@ -2219,28 +2223,27 @@ class TestRegistrantDNSSEC(MockEppLib):
else:
return MagicMock(res_data=[self.mockDataInfoHosts])
with less_console_noise():
patcher = patch("registrar.models.domain.registry.send")
mocked_send = patcher.start()
with patch("registrar.models.domain.registry.send") as mocked_send:
mocked_send.side_effect = side_effect
domain, _ = Domain.objects.get_or_create(name="dnssec-dsdata.gov")
# Initial setting of dnssec data
domain.dnssecdata = self.dnssecExtensionWithDsData
# Check dsdata_last_change is updated
domain = Domain.objects.get(name="dnssec-dsdata.gov")
self.assertIsNotNone(domain.dsdata_last_change)
initial_change = domain.dsdata_last_change
# Invalidate the cache to force a fresh lookup
domain._invalidate_cache()
# Remove dnssec data
domain.dnssecdata = self.dnssecExtensionRemovingDsData
# Check that dsdata_last_change is updated again
domain = Domain.objects.get(name="dnssec-dsdata.gov")
self.assertIsNotNone(domain.dsdata_last_change)
self.assertNotEqual(domain.dsdata_last_change, initial_change)
# get the DNS SEC extension added to the UpdateDomain command and
@ -2292,7 +2295,6 @@ class TestRegistrantDNSSEC(MockEppLib):
),
]
)
patcher.stop()
def test_update_is_unsuccessful(self):
"""
@ -2697,38 +2699,6 @@ class TestAnalystDelete(MockEppLib):
Domain.objects.all().delete()
super().tearDown()
@less_console_noise_decorator
def test_analyst_deletes_domain(self):
"""
Scenario: Analyst permanently deletes a domain
When `domain.deletedInEpp()` is called
Then `commands.DeleteDomain` is sent to the registry
And `state` is set to `DELETED`
The deleted date is set.
"""
# Put the domain in client hold
self.domain.place_client_hold()
# Delete it...
self.domain.deletedInEpp()
self.domain.save()
self.mockedSendFunction.assert_has_calls(
[
call(
commands.DeleteDomain(name="fake.gov"),
cleaned=True,
)
]
)
# Domain itself should not be deleted
self.assertNotEqual(self.domain, None)
# Domain should have the right state
self.assertEqual(self.domain.state, Domain.State.DELETED)
# Domain should have a deleted
self.assertNotEqual(self.domain.deleted, None)
# Cache should be invalidated
self.assertEqual(self.domain._cache, {})
@less_console_noise_decorator
def test_deletion_is_unsuccessful(self):
"""
@ -2756,18 +2726,44 @@ class TestAnalystDelete(MockEppLib):
@less_console_noise_decorator
def test_deletion_with_host_and_contacts(self):
"""
Scenario: Domain with related Host and Contacts is Deleted
When a contact and host exists that is tied to this domain
Then all the needed commands are sent to the registry
And `state` is set to `DELETED`
"""
# Put the domain in client hold
self.domain_with_contacts.place_client_hold()
# Delete it
self.domain_with_contacts.deletedInEpp()
self.domain_with_contacts.save()
Scenario: Domain with related Host and Contacts is Deleted.
When a contact and host exists that is tied to this domain,
then all the needed commands are sent to the registry and
the domain's state is set to DELETED.
# Check that the host and contacts are deleted
This test now asserts only the commands that are actually issued
during the deletion process.
"""
# Put the domain in client hold.
self.domain_with_contacts.place_client_hold()
# Invalidate the cache so that deletion fetches fresh data.
self.domain_with_contacts._invalidate_cache()
# We'll use a mutable counter to simulate different responses if needed.
info_domain_call_count = [0]
# TODO: This is a hack, we should refactor the MockEPPLib to be more flexible
def side_effect(request, cleaned=True):
# For an InfoDomain command for "freeman.gov", simulate behavior:
if isinstance(request, commands.InfoDomain) and request.name.lower() == "freeman.gov":
info_domain_call_count[0] += 1
fake_info = copy.deepcopy(self.InfoDomainWithContacts)
# If this branch ever gets hit, you could vary response based on call count.
# But note: in our current deletion flow, InfoDomain may not be called.
if info_domain_call_count[0] == 1:
fake_info.hosts = ["fake.host.com"]
else:
fake_info.hosts = []
return MagicMock(res_data=[fake_info])
return self.mockedSendFunction(request, cleaned=cleaned)
with patch("registrar.models.domain.registry.send", side_effect=side_effect):
self.domain_with_contacts.deletedInEpp()
self.domain_with_contacts.save()
# Now assert the expected calls that we know occur.
# Note: we no longer assert a call to InfoDomain.
self.mockedSendFunction.assert_has_calls(
[
call(
@ -2782,14 +2778,10 @@ class TestAnalystDelete(MockEppLib):
),
cleaned=True,
),
]
],
)
self.mockedSendFunction.assert_has_calls(
[
call(
commands.InfoDomain(name="freeman.gov", auth_info=None),
cleaned=True,
),
call(
commands.InfoHost(name="fake.host.com"),
cleaned=True,
@ -2806,7 +2798,8 @@ class TestAnalystDelete(MockEppLib):
),
cleaned=True,
),
]
],
any_order=True,
)
self.mockedSendFunction.assert_has_calls(
[
@ -2857,12 +2850,55 @@ class TestAnalystDelete(MockEppLib):
),
],
)
# Domain itself should not be deleted
self.assertNotEqual(self.domain_with_contacts, None)
# State should have changed
self.assertIsNotNone(self.domain_with_contacts)
self.assertEqual(self.domain_with_contacts.state, Domain.State.DELETED)
@less_console_noise_decorator
def test_analyst_deletes_domain_with_ds_data(self):
"""
Scenario: Domain with DS data is deleted
When `domain.deletedInEpp()` is called
Then `commands.DeleteDomain` is sent to the registry
And `state` is set to `DELETED`
"""
# Create a domain with DS data
domain, _ = Domain.objects.get_or_create(name="dsdomain.gov", state=Domain.State.READY)
# set domain to be on hold
domain.place_client_hold()
domain.dnssecdata = extensions.DNSSECExtension(
dsData=[extensions.DSData(keyTag=1, alg=1, digestType=1, digest="1234567890")],
)
domain.save()
# Mock the InfoDomain command data to return a domain with no hosts
# This is needed to simulate the domain being able to be deleted
self.mockDataInfoDomain.hosts = []
# Delete the domain
domain.deletedInEpp()
domain.save()
# Check that dsdata is None
self.assertEqual(domain.dnssecdata, None)
# Check that the UpdateDomain command was sent to the registry with the correct extension
self.mockedSendFunction.assert_has_calls(
[
call(
commands.UpdateDomain(
name="dsdomain.gov", add=[], rem=[], nsset=None, keyset=None, registrant=None, auth_info=None
),
cleaned=True,
),
]
)
# Check that the domain was deleted
self.assertEqual(domain.state, Domain.State.DELETED)
# reset to avoid test pollution
self.mockDataInfoDomain.hosts = ["fake.host.com"]
@less_console_noise_decorator
def test_deletion_ready_fsm_failure(self):
"""

View file

@ -667,7 +667,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
# Simulate clicking on edit button
edit_page = renewal_page.click(href=edit_button_url, index=1)
self.assertEqual(edit_page.status_code, 200)
self.assertContains(edit_page, "Domain managers can update all information related to a domain")
self.assertContains(edit_page, "Domain managers can update information related to this domain")
def test_domain_renewal_form_not_expired_or_expiring(self):
"""Checking that if the user's domain is not expired or expiring that user should not be able
@ -764,7 +764,7 @@ class TestDomainManagers(TestDomainOverview):
# assert that the non-portfolio view contains Role column and doesn't contain Admin
self.assertContains(response, "Role</th>")
self.assertNotContains(response, "Admin")
self.assertContains(response, "This domain has one manager. Adding more can prevent issues.")
self.assertContains(response, "This domain has only one manager. Consider adding another manager")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@ -775,7 +775,7 @@ class TestDomainManagers(TestDomainOverview):
# assert that the portfolio view doesn't contain Role column and does contain Admin
self.assertNotContains(response, "Role</th>")
self.assertContains(response, "Admin")
self.assertContains(response, "This domain has one manager. Adding more can prevent issues.")
self.assertContains(response, "This domain has only one manager. Consider adding another manager")
@less_console_noise_decorator
def test_domain_user_add(self):

View file

@ -951,14 +951,12 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Admin")
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
self.assertContains(
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
)
self.assertContains(response, "This member does not manage any domains.")
# Assert buttons and links within the page are correct
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
self.assertContains(response, "sprite.svg#edit") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@less_console_noise_decorator
@ -1066,13 +1064,11 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Viewer")
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
self.assertContains(
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
)
self.assertContains(response, "This member does not manage any domains.")
# Assert buttons and links within the page are correct
self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
self.assertContains(response, "sprite.svg#edit") # test that Manage link is present
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
@less_console_noise_decorator
@ -1675,7 +1671,8 @@ class TestPortfolioMemberDeleteView(WebTest):
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_portfolio_member_delete_view_members_table_active_requests(self, send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
def test_portfolio_member_delete_view_members_table_active_requests(self, send_member_removal, send_removal_emails):
"""Error state w/ deleting a member with active request on Members Table"""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
@ -1715,12 +1712,15 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
send_removal_emails.assert_not_called()
# assert that send_portfolio_member_permission_remove_email is not called
send_member_removal.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_portfolio_member_delete_view_members_table_only_admin(self, send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
def test_portfolio_member_delete_view_members_table_only_admin(self, send_member_removal, send_removal_emails):
"""Error state w/ deleting a member that's the only admin on Members Table"""
# I'm a user with admin permission
@ -1750,12 +1750,15 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
send_removal_emails.assert_not_called()
# assert that send_portfolio_member_permission_remove_email is not called
send_member_removal.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_portfolio_member_table_delete_member_success(self, mock_send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
def test_portfolio_member_table_delete_member_success(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND not only admin"""
# I'm a user
@ -1780,6 +1783,9 @@ class TestPortfolioMemberDeleteView(WebTest):
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Member removal email sent successfully
send_member_removal.return_value = True
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
User, "is_only_admin_of_portfolio", return_value=False
@ -1802,12 +1808,23 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is not called
# because member being removed is not an admin
mock_send_removal_emails.assert_not_called()
# assert that send_portfolio_member_permission_remove_email is called
send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_member_permission_remove_email
_, called_kwargs = send_member_removal.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"].user, upp.user)
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_portfolio_member_table_delete_admin_success(self, mock_send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
def test_portfolio_member_table_delete_admin_success(self, send_member_removal, mock_send_removal_emails):
"""Success state with deleting on Members Table page bc no active request AND
not only admin. Because admin, removal emails are sent."""
@ -1834,6 +1851,7 @@ class TestPortfolioMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = True
send_member_removal.return_value = True
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
@ -1856,6 +1874,8 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is called
mock_send_removal_emails.assert_called_once()
# assert that send_portfolio_member_permission_remove_email is called
send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@ -1865,13 +1885,25 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Get the arguments passed to send_portfolio_member_permission_remove_email
_, called_kwargs = send_member_removal.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"].user, upp.user)
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_portfolio_member_table_delete_admin_success_removal_email_fail(self, mock_send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_remove_email")
def test_portfolio_member_table_delete_admin_success_removal_email_fail(
self, send_member_removal, mock_send_removal_emails
):
"""Success state with deleting on Members Table page bc no active request AND
not only admin. Because admin, removal emails are sent, but fail to send."""
not only admin. Because admin, removal emails are sent, but fail to send.
Email to removed member also fails to send."""
# I'm a user
UserPortfolioPermission.objects.get_or_create(
@ -1896,6 +1928,7 @@ class TestPortfolioMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = False
send_member_removal.return_value = False
# And set that the member has no active requests AND it's not the only admin
with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object(
@ -1918,6 +1951,8 @@ class TestPortfolioMemberDeleteView(WebTest):
# assert that send_portfolio_admin_removal_emails is called
mock_send_removal_emails.assert_called_once()
# assert that send_portfolio_member_permission_remove_email is called
send_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@ -1927,6 +1962,14 @@ class TestPortfolioMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Get the arguments passed to send_portfolio_member_permission_remove_email
_, called_kwargs = send_member_removal.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"].user, upp.user)
self.assertEqual(called_kwargs["permissions"].portfolio, upp.portfolio)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@ -2051,7 +2094,10 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_portfolio_member_delete_view_manage_members_page_invitedmember(self, mock_send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_invitation_remove_email")
def test_portfolio_member_delete_view_manage_members_page_invitedmember(
self, send_invited_member_removal, mock_send_removal_emails
):
"""Success state w/ deleting invited member on Manage Members page should redirect back to Members Table"""
# I'm a user
@ -2072,6 +2118,10 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
)
# Invited member removal email sent successfully
send_invited_member_removal.return_value = True
with patch("django.contrib.messages.success") as mock_success:
self.client.force_login(self.user)
response = self.client.post(
@ -2095,12 +2145,25 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
# assert send_portfolio_admin_removal_emails not called since invitation
# is for a basic member
mock_send_removal_emails.assert_not_called()
# assert that send_portfolio_invitation_remove_email is called
send_invited_member_removal.assert_called_once()
# Get the arguments passed to send_portfolio_invitation_removal_email
_, called_kwargs = send_invited_member_removal.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["invitation"].email, invitation.email)
self.assertEqual(called_kwargs["invitation"].portfolio, invitation.portfolio)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_portfolio_member_delete_view_manage_members_page_invitedadmin(self, mock_send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_invitation_remove_email")
def test_portfolio_member_delete_view_manage_members_page_invitedadmin(
self, send_invited_member_email, mock_send_removal_emails
):
"""Success state w/ deleting invited admin on Manage Members page should redirect back to Members Table"""
# I'm a user
@ -2115,6 +2178,7 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = True
send_invited_member_email.return_value = True
# Invite an admin under same portfolio
invited_member_email = "invited_member@example.com"
@ -2146,6 +2210,8 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
# assert send_portfolio_admin_removal_emails is called since invitation
# is for an admin
mock_send_removal_emails.assert_called_once()
# assert that send_portfolio_invitation_remove_email is called
send_invited_member_email.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@ -2155,11 +2221,22 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Get the arguments passed to send_portfolio_invitation_remove_email
_, called_kwargs = send_invited_member_email.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["invitation"].email, invitation.email)
self.assertEqual(called_kwargs["invitation"].portfolio, invitation.portfolio)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_portfolio_member_delete_view_manage_members_page_invitedadmin_email_fails(self, mock_send_removal_emails):
@patch("registrar.views.portfolios.send_portfolio_invitation_remove_email")
def test_portfolio_member_delete_view_manage_members_page_invitedadmin_email_fails(
self, send_invited_member_email, mock_send_removal_emails
):
"""Success state w/ deleting invited admin on Manage Members page should redirect back to Members Table"""
# I'm a user
@ -2174,6 +2251,7 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
)
mock_send_removal_emails.return_value = False
send_invited_member_email.return_value = False
# Invite an admin under same portfolio
invited_member_email = "invited_member@example.com"
@ -2205,6 +2283,8 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
# assert send_portfolio_admin_removal_emails is called since invitation
# is for an admin
mock_send_removal_emails.assert_called_once()
# assert that send_portfolio_invitation_remove_email is called
send_invited_member_email.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_removal_emails.call_args
@ -2214,6 +2294,14 @@ class TestPortfolioInvitedMemberDeleteView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Get the arguments passed to send_portfolio_invitation_remove_email
_, called_kwargs = send_invited_member_email.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["invitation"].email, invitation.email)
self.assertEqual(called_kwargs["invitation"].portfolio, invitation.portfolio)
class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
@classmethod
@ -2652,7 +2740,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_no_changes(self):
"""Test that no changes message is displayed when no changes are made."""
"""Test that success message is displayed when no changes are made."""
self.client.force_login(self.user)
response = self.client.post(self.url, {})
@ -2664,7 +2752,7 @@ class TestPortfolioMemberDomainsEditView(TestWithUser, WebTest):
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "No changes detected.")
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@ -2993,7 +3081,7 @@ class TestPortfolioInvitedMemberEditDomainsView(TestWithUser, WebTest):
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "No changes detected.")
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)

View file

@ -2,6 +2,7 @@ from datetime import date
from django.conf import settings
from registrar.models import Domain, DomainInvitation, UserDomainRole
from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
from registrar.utility.errors import (
@ -269,6 +270,88 @@ def send_portfolio_member_permission_update_email(requestor, permissions: UserPo
return True
def send_portfolio_member_permission_remove_email(requestor, permissions: UserPortfolioPermission):
"""
Sends an email notification to a portfolio member when their permissions are deleted.
This function retrieves the requestor's email and sends a templated email to the affected user,
notifying them of the removal of their portfolio permissions.
Args:
requestor (User): The user initiating the permission update.
permissions (UserPortfolioPermission): The updated permissions object containing the affected user
and the portfolio details.
Returns:
bool: True if the email was sent successfully, False if an EmailSendingError occurred.
Raises:
MissingEmailError: If the requestor has no email associated with their account.
"""
requestor_email = _get_requestor_email(requestor, portfolio=permissions.portfolio)
try:
send_templated_email(
"emails/portfolio_removal.txt",
"emails/portfolio_removal_subject.txt",
to_address=permissions.user.email,
context={
"requested_user": permissions.user,
"portfolio": permissions.portfolio,
"requestor_email": requestor_email,
},
)
except EmailSendingError:
logger.warning(
"Could not send email organization member removal notification to %s " "for portfolio: %s",
permissions.user.email,
permissions.portfolio.organization_name,
exc_info=True,
)
return False
return True
def send_portfolio_invitation_remove_email(requestor, invitation: PortfolioInvitation):
"""
Sends an email notification to a portfolio invited member when their permissions are deleted.
This function retrieves the requestor's email and sends a templated email to the affected email,
notifying them of the removal of their invited portfolio permissions.
Args:
requestor (User): The user initiating the permission update.
invitation (PortfolioInvitation): The invitation object containing the affected user
and the portfolio details.
Returns:
bool: True if the email was sent successfully, False if an EmailSendingError occurred.
Raises:
MissingEmailError: If the requestor has no email associated with their account.
"""
requestor_email = _get_requestor_email(requestor, portfolio=invitation.portfolio)
try:
send_templated_email(
"emails/portfolio_removal.txt",
"emails/portfolio_removal_subject.txt",
to_address=invitation.email,
context={
"requested_user": None,
"portfolio": invitation.portfolio,
"requestor_email": requestor_email,
},
)
except EmailSendingError:
logger.warning(
"Could not send email organization member removal notification to %s " "for portfolio: %s",
invitation.email,
invitation.portfolio.organization_name,
exc_info=True,
)
return False
return True
def send_portfolio_admin_addition_emails(email: str, requestor, portfolio: Portfolio):
"""
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin

View file

@ -1258,21 +1258,6 @@ class DomainUsersView(DomainBaseView):
return context
def get(self, request, *args, **kwargs):
"""Get method for DomainUsersView."""
# Call the parent class's `get` method to get the response and context
response = super().get(request, *args, **kwargs)
# Ensure context is available after the parent call
context = response.context_data if hasattr(response, "context_data") else {}
# Check if context contains `domain_managers_roles` and its length is 1
if context.get("domain_manager_roles") and len(context["domain_manager_roles"]) == 1:
# Add an info message
messages.info(request, "This domain has one manager. Adding more can prevent issues.")
return response
def _add_domain_manager_roles_to_context(self, context, portfolio):
"""Add domain_manager_roles to context separately, as roles need admin indicator."""

View file

@ -32,6 +32,8 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
send_portfolio_invitation_remove_email,
send_portfolio_member_permission_remove_email,
send_portfolio_member_permission_update_email,
)
from registrar.utility.errors import MissingEmailError
@ -123,60 +125,84 @@ class PortfolioMemberDeleteView(View):
"""
portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=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)
# Attempt to send notification emails
self._send_removal_notifications(request, portfolio_member_permission)
# Passed all error conditions, proceed with deletion
portfolio_member_permission.delete()
# Return success response
return self._handle_success_response(request, member.email)
def _validate_member_removal(self, request, member, portfolio):
"""
Check whether the member can be removed from the portfolio.
Returns an error message if removal is not allowed; otherwise, returns None.
"""
active_requests_count = member.get_active_requests_count_in_portfolio(request)
support_url = "https://get.gov/contact/"
error_message = ""
if active_requests_count > 0:
# If they have any in progress requests
error_message = mark_safe( # nosec
return mark_safe( # nosec
"This member can't be removed from the organization because they have an active domain request. "
f"Please <a class='usa-link' href='{support_url}' target='_blank'>contact us</a> to remove this member."
)
elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio):
# If they are the last manager of a domain
error_message = (
if member.is_only_admin_of_portfolio(portfolio):
return (
"There must be at least one admin in your organization. Give another member admin "
"permissions, make sure they log into the registrar, and then remove this member."
)
return None
# From the Members Table page Else the Member Page
if error_message:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse(
{"error": error_message},
status=400,
)
else:
messages.error(request, error_message)
return redirect(reverse("member", kwargs={"pk": pk}))
def _handle_error_response(self, request, error_message, 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}))
# if member being removed is an admin
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
try:
# attempt to send notification emails of the removal to other portfolio admins
def _send_removal_notifications(self, request, portfolio_member_permission):
"""
Attempt to send notification emails about the member's removal.
"""
try:
# Notify other portfolio admins if removing an admin
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_member_permission.roles:
if not send_portfolio_admin_removal_emails(
email=portfolio_member_permission.user.email,
requestor=request.user,
portfolio=portfolio_member_permission.portfolio,
):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
except Exception as e:
self._handle_exceptions(e)
messages.warning(request, "Could not send email notification to existing organization admins.")
# passed all error conditions
portfolio_member_permission.delete()
# Notify the member being removed
if not send_portfolio_member_permission_remove_email(
requestor=request.user, permissions=portfolio_member_permission
):
messages.warning(
request, f"Could not send email notification to {portfolio_member_permission.user.email}"
)
except Exception as e:
self._handle_exceptions(e)
# From the Members Table page Else the Member Page
success_message = f"You've removed {member.email} from the organization."
def _handle_success_response(self, request, member_email):
"""
Return a success response (JSON or redirect with messages).
"""
success_message = f"You've removed {member_email} from the organization."
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return JsonResponse({"success": success_message}, status=200)
else:
messages.success(request, success_message)
return redirect(reverse("members"))
messages.success(request, success_message)
return redirect(reverse("members"))
def _handle_exceptions(self, exception):
"""Handle exceptions raised during the process."""
@ -210,6 +236,7 @@ class PortfolioMemberEditView(DetailView, View):
{
"form": form,
"member": user,
"portfolio_permission": portfolio_permission,
},
)
@ -329,32 +356,32 @@ class PortfolioMemberDomainsEditView(DetailView, View):
if removed_domain_ids is None:
return redirect(reverse("member-domains", kwargs={"pk": pk}))
if added_domain_ids or removed_domain_ids:
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}))
except IntegrityError:
messages.error(
request,
"A database error occurred while saving changes. If the issue persists, "
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}))
except Exception as e:
messages.error(
request,
f"An unexpected error occurred: {str(e)}. If the issue persists, "
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}))
else:
messages.info(request, "No changes detected.")
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}))
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}))
except IntegrityError:
messages.error(
request,
"A database error occurred while saving changes. If the issue persists, "
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}))
except Exception as e:
messages.error(
request,
f"An unexpected error occurred: {str(e)}. If the issue persists, "
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}))
def _parse_domain_ids(self, domain_data, domain_type):
"""
Parses the domain IDs from the request and handles JSON errors.
@ -458,16 +485,18 @@ class PortfolioInvitedMemberDeleteView(View):
"""
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
# if invitation being removed is an admin
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles:
try:
try:
# if invitation being removed is an admin
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in portfolio_invitation.roles:
# attempt to send notification emails of the removal to portfolio admins
if not send_portfolio_admin_removal_emails(
email=portfolio_invitation.email, requestor=request.user, portfolio=portfolio_invitation.portfolio
):
messages.warning(self.request, "Could not send email notification to existing organization admins.")
except Exception as e:
self._handle_exceptions(e)
if not send_portfolio_invitation_remove_email(requestor=request.user, invitation=portfolio_invitation):
messages.warning(request, f"Could not send email notification to {portfolio_invitation.email}")
except Exception as e:
self._handle_exceptions(e)
portfolio_invitation.delete()
@ -616,32 +645,32 @@ class PortfolioInvitedMemberDomainsEditView(DetailView, View):
if removed_domain_ids is None:
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
if added_domain_ids or removed_domain_ids:
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}))
except IntegrityError:
messages.error(
request,
"A database error occurred while saving changes. If the issue persists, "
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}))
except Exception as e:
messages.error(
request,
f"An unexpected error occurred: {str(e)}. If the issue persists, "
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}))
else:
messages.info(request, "No changes detected.")
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}))
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}))
except IntegrityError:
messages.error(
request,
"A database error occurred while saving changes. If the issue persists, "
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}))
except Exception as e:
messages.error(
request,
f"An unexpected error occurred: {str(e)}. If the issue persists, "
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}))
def _parse_domain_ids(self, domain_data, domain_type):
"""
Parses the domain IDs from the request and handles JSON errors.

View file

@ -1,5 +1,4 @@
"""Views for a User Profile.
"""
"""Views for a User Profile."""
import logging