resolve merge conflicts

This commit is contained in:
CocoByte 2025-02-28 12:43:05 -07:00
commit 621b291239
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
123 changed files with 4488 additions and 3141 deletions

View file

@ -1,6 +1,6 @@
name: Bug
description: Report a bug or problem with the application
labels: ["bug"]
labels: ["bug","dev"]
body:
- type: markdown

1030
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@
"license": "ISC",
"dependencies": {
"@uswds/uswds": "3.8.1",
"pa11y-ci": "^3.0.1",
"pa11y-ci": "^3.1.0",
"sass": "^1.54.8"
},
"devDependencies": {

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

@ -1,179 +0,0 @@
/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
* attach the seleted start and end dates to a url that'll trigger the view, and finally
* redirect to that url.
*
* This function also sets the start and end dates to match the url params if they exist
*/
(function () {
// Function to get URL parameter value by name
function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
// Get the current date in the format YYYY-MM-DD
let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date
let startDateInput = document.getElementById('start');
// Default the value of the end date input field to the current date
let endDateInput = document.getElementById('end');
let exportButtons = document.querySelectorAll('.exportLink');
if (exportButtons.length > 0) {
// Check if start and end dates are present in the URL
let urlStartDate = getParameterByName('start_date');
let urlEndDate = getParameterByName('end_date');
// Set input values based on URL parameters or current date
startDateInput.value = urlStartDate || currentDate;
endDateInput.value = urlEndDate || currentDate;
exportButtons.forEach((btn) => {
btn.addEventListener('click', function () {
// Get the selected start and end dates
let startDate = startDateInput.value;
let endDate = endDateInput.value;
let exportUrl = btn.dataset.exportUrl;
// Build the URL with parameters
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
// Redirect to the export URL
window.location.href = exportUrl;
});
});
}
})();
/** An IIFE to initialize the analytics page
*/
(function () {
/**
* Creates a diagonal stripe pattern for chart.js
* Inspired by https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
* and https://github.com/ashiguruma/patternomaly
* @param {string} backgroundColor - Background color of the pattern
* @param {string} [lineColor="white"] - Color of the diagonal lines
* @param {boolean} [rightToLeft=false] - Direction of the diagonal lines
* @param {number} [lineGap=1] - Gap between lines
* @returns {CanvasPattern} A canvas pattern object for use with backgroundColor
*/
function createDiagonalPattern(backgroundColor, lineColor, rightToLeft=false, lineGap=1) {
// Define the canvas and the 2d context so we can draw on it
let shape = document.createElement("canvas");
shape.width = 20;
shape.height = 20;
let context = shape.getContext("2d");
// Fill with specified background color
context.fillStyle = backgroundColor;
context.fillRect(0, 0, shape.width, shape.height);
// Set stroke properties
context.strokeStyle = lineColor;
context.lineWidth = 2;
// Rotate canvas for a right-to-left pattern
if (rightToLeft) {
context.translate(shape.width, 0);
context.rotate(90 * Math.PI / 180);
};
// First diagonal line
let halfSize = shape.width / 2;
context.moveTo(halfSize - lineGap, -lineGap);
context.lineTo(shape.width + lineGap, halfSize + lineGap);
// Second diagonal line (x,y are swapped)
context.moveTo(-lineGap, halfSize - lineGap);
context.lineTo(halfSize + lineGap, shape.width + lineGap);
context.stroke();
return context.createPattern(shape, "repeat");
}
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
var canvas = document.getElementById(canvasId);
if (!canvas) {
return
}
var ctx = canvas.getContext("2d");
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [
{
label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.3)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
data: listOne,
// Set this line style to be rightToLeft for visual distinction
backgroundColor: createDiagonalPattern('rgba(255, 99, 132, 0.3)', 'white', true)
},
{
label: labelTwo,
backgroundColor: "rgba(75, 192, 192, 0.3)",
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 1,
data: listTwo,
backgroundColor: createDiagonalPattern('rgba(75, 192, 192, 0.3)', 'white')
},
],
};
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: title
}
},
scales: {
y: {
beginAtZero: true,
},
},
};
new Chart(ctx, {
type: "bar",
data: data,
options: options,
});
}
function initComparativeColumnCharts() {
document.addEventListener("DOMContentLoaded", function () {
createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date");
createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date");
createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date");
createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date");
createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date");
createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date");
});
};
initComparativeColumnCharts();
})();

View file

@ -5695,19 +5695,35 @@ const createHeaderButton = (header, headerName) => {
buttonEl.setAttribute("tabindex", "0");
buttonEl.classList.add(SORT_BUTTON_CLASS);
// ICON_SOURCE
// ---- END DOTGOV EDIT
// Change icons on sort, use source from arro_upward and arrow_downward
// buttonEl.innerHTML = Sanitizer.escapeHTML`
// <svg class="${PREFIX}-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
// <g class="descending" fill="transparent">
// <path d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
// </g>
// <g class="ascending" fill="transparent">
// <path transform="rotate(180, 12, 12)" d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
// </g>
// <g class="unsorted" fill="transparent">
// <polygon points="15.17 15 13 17.17 13 6.83 15.17 9 16.58 7.59 12 3 7.41 7.59 8.83 9 11 6.83 11 17.17 8.83 15 7.42 16.41 12 21 16.59 16.41 15.17 15"/>
// </g>
// </svg>
// `;
buttonEl.innerHTML = Sanitizer.escapeHTML`
<svg class="${PREFIX}-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g class="descending" fill="transparent">
<path d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
<path d="m20 12-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z"/>
</g>
<g class="ascending" fill="transparent">
<path transform="rotate(180, 12, 12)" d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
<path d="m4 12 1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z"/>
</g>
<g class="unsorted" fill="transparent">
<polygon points="15.17 15 13 17.17 13 6.83 15.17 9 16.58 7.59 12 3 7.41 7.59 8.83 9 11 6.83 11 17.17 8.83 15 7.42 16.41 12 21 16.59 16.41 15.17 15"/>
</g>
</svg>
`;
// ---- END DOTGOV EDIT
header.appendChild(buttonEl);
updateSortLabel(header, headerName);
};

View file

@ -0,0 +1,177 @@
import { debounce } from '../getgov/helpers.js';
import { getParameterByName } from './helpers-admin.js';
/** This function also sets the start and end dates to match the url params if they exist
*/
function initAnalyticsExportButtons() {
// Get the current date in the format YYYY-MM-DD
let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date
let startDateInput = document.getElementById('start');
// Default the value of the end date input field to the current date
let endDateInput = document.getElementById('end');
let exportButtons = document.querySelectorAll('.exportLink');
if (exportButtons.length > 0) {
// Check if start and end dates are present in the URL
let urlStartDate = getParameterByName('start_date');
let urlEndDate = getParameterByName('end_date');
// Set input values based on URL parameters or current date
startDateInput.value = urlStartDate || currentDate;
endDateInput.value = urlEndDate || currentDate;
exportButtons.forEach((btn) => {
btn.addEventListener('click', function () {
// Get the selected start and end dates
let startDate = startDateInput.value;
let endDate = endDateInput.value;
let exportUrl = btn.dataset.exportUrl;
// Build the URL with parameters
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
// Redirect to the export URL
window.location.href = exportUrl;
});
});
}
};
/**
* Creates a diagonal stripe pattern for chart.js
* Inspired by https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
* and https://github.com/ashiguruma/patternomaly
* @param {string} backgroundColor - Background color of the pattern
* @param {string} [lineColor="white"] - Color of the diagonal lines
* @param {boolean} [rightToLeft=false] - Direction of the diagonal lines
* @param {number} [lineGap=1] - Gap between lines
* @returns {CanvasPattern} A canvas pattern object for use with backgroundColor
*/
function createDiagonalPattern(backgroundColor, lineColor, rightToLeft=false, lineGap=1) {
// Define the canvas and the 2d context so we can draw on it
let shape = document.createElement("canvas");
shape.width = 20;
shape.height = 20;
let context = shape.getContext("2d");
// Fill with specified background color
context.fillStyle = backgroundColor;
context.fillRect(0, 0, shape.width, shape.height);
// Set stroke properties
context.strokeStyle = lineColor;
context.lineWidth = 2;
// Rotate canvas for a right-to-left pattern
if (rightToLeft) {
context.translate(shape.width, 0);
context.rotate(90 * Math.PI / 180);
};
// First diagonal line
let halfSize = shape.width / 2;
context.moveTo(halfSize - lineGap, -lineGap);
context.lineTo(shape.width + lineGap, halfSize + lineGap);
// Second diagonal line (x,y are swapped)
context.moveTo(-lineGap, halfSize - lineGap);
context.lineTo(halfSize + lineGap, shape.width + lineGap);
context.stroke();
return context.createPattern(shape, "repeat");
}
function createComparativeColumnChart(id, title, labelOne, labelTwo) {
var canvas = document.getElementById(id);
if (!canvas) {
return
}
var ctx = canvas.getContext("2d");
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [
{
label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.3)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
data: listOne,
// Set this line style to be rightToLeft for visual distinction
backgroundColor: createDiagonalPattern('rgba(255, 99, 132, 0.3)', 'white', true)
},
{
label: labelTwo,
backgroundColor: "rgba(75, 192, 192, 0.3)",
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 1,
data: listTwo,
backgroundColor: createDiagonalPattern('rgba(75, 192, 192, 0.3)', 'white')
},
],
};
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: title
}
},
scales: {
y: {
beginAtZero: true,
},
},
};
return new Chart(ctx, {
type: "bar",
data: data,
options: options,
});
}
/** An IIFE to initialize the analytics page
*/
export function initAnalyticsDashboard() {
const analyticsPageContainer = document.querySelector('.analytics-dashboard-charts');
if (analyticsPageContainer) {
document.addEventListener("DOMContentLoaded", function () {
initAnalyticsExportButtons();
// Create charts and store each instance of it
const chartInstances = new Map();
const charts = [
{ id: "managed-domains-chart", title: "Managed domains" },
{ id: "unmanaged-domains-chart", title: "Unmanaged domains" },
{ id: "deleted-domains-chart", title: "Deleted domains" },
{ id: "ready-domains-chart", title: "Ready domains" },
{ id: "submitted-requests-chart", title: "Submitted requests" },
{ id: "all-requests-chart", title: "All requests" }
];
charts.forEach(chart => {
if (chartInstances.has(chart.id)) chartInstances.get(chart.id).destroy();
chartInstances.set(chart.id, createComparativeColumnChart(chart.id, chart.title, "Start Date", "End Date"));
});
// Add resize listener to each chart
window.addEventListener("resize", debounce(() => {
chartInstances.forEach((chart) => {
if (chart?.canvas) chart.resize();
});
}, 200));
});
}
};

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

@ -23,6 +23,16 @@ export function addOrRemoveSessionBoolean(name, add){
}
}
export function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
/**
* Creates a temporary live region to announce messages for screen readers.
*/

View file

@ -16,6 +16,8 @@ import { initDomainFormTargetBlankButtons } from './domain-form.js';
import { initDynamicPortfolioFields } from './portfolio-form.js';
import { initDynamicDomainInformationFields } from './domain-information-form.js';
import { initDynamicDomainFields } from './domain-form.js';
import { initAnalyticsDashboard } from './analytics.js';
import { initButtonLinks } from './button-utils.js';
// General
initModals();
@ -23,6 +25,7 @@ initCopyToClipboard();
initFilterHorizontalWidget();
initDescriptions();
initSubmitBar();
initButtonLinks();
// Domain request
initIneligibleModal();
@ -43,3 +46,6 @@ initDynamicPortfolioFields();
// Domain information
initDynamicDomainInformationFields();
// Analytics dashboard
initAnalyticsDashboard();

View file

@ -2,11 +2,41 @@ import { submitForm } from './helpers.js';
export function initDomainRequestForm() {
document.addEventListener('DOMContentLoaded', function() {
// These are the request steps in DomainRequestWizard, such as current_websites or review
initRequestStepCurrentWebsitesListener();
initRequestStepReviewListener();
});
}
function initRequestStepReviewListener() {
const button = document.getElementById("domain-request-form-submit-button");
if (button) {
button.addEventListener("click", function () {
submitForm("submit-domain-request-form");
});
}
}
function initRequestStepCurrentWebsitesListener() {
//register-form-step
const addAnotherSiteButton = document.getElementById("submit-domain-request--site-button");
if (addAnotherSiteButton) {
// Check for focus state in sessionStorage
const focusTarget = sessionStorage.getItem("lastFocusedElement");
if (focusTarget) {
document.querySelector(focusTarget)?.focus();
}
// Add form submit handler to store focus state
const form = document.querySelector("form");
if (form) {
form.addEventListener("submit", () => {
const activeElement = document.activeElement;
if (activeElement) {
sessionStorage.setItem("lastFocusedElement", "#" + activeElement.id);
}
});
}
// We only want to do this action once, so we clear out the session
sessionStorage.removeItem("lastFocusedElement");
}
}

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, all', 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

@ -24,9 +24,12 @@ export class EditMemberDomainsTable extends BaseTable {
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();
}
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;
if (!baseUrlValue) {
console.error("Base URL not found");
return;
}
try {
let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue);
let url = baseUrlValue + "?" + searchParams.toString();
fetch(url)
.then(response => response.json())
.then(data => {
let response = await fetch(url);
let data = await response.json();
if (data.error) {
console.error('Error in AJAX call: ' + 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));
} 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() {
document.addEventListener('DOMContentLoaded', async function() {
const isEditMemberDomainsPage = document.getElementById("edit-member-domains");
if (isEditMemberDomainsPage) {
const editMemberDomainsTable = new EditMemberDomainsTable();
if (!isEditMemberDomainsPage) return; // Exit if not on the right page
const editMemberDomainsTable = await new EditMemberDomainsTable().init();
if (editMemberDomainsTable.tableWrapper) {
// Initial load
editMemberDomainsTable.loadTable(1);
}
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,17 +243,21 @@ 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 += `<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
@ -259,12 +266,18 @@ export class MembersTable extends BaseTable {
}
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>`;
}
} 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 assigned domains</a></p>`;
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 = '';
generatePermissionsHTML(is_admin, member_permissions, UserPortfolioPermissionChoices, unique_id) {
// 1. Role
const memberAccessValue = is_admin ? "Admin" : "Basic";
// 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
// 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 {
@ -558,13 +557,18 @@ details.dja-detail-table {
background-color: transparent;
}
thead tr {
background-color: var(--darkened-bg);
}
td, th {
padding-left: 12px;
border: none
border: none;
background-color: var(--darkened-bg);
color: var(--body-quiet-color);
}
thead > tr > th {
border-radius: 4px;
border-top: none;
border-bottom: none;
}
@ -834,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;
@ -946,3 +961,38 @@ ul.add-list-reset {
background-color: transparent !important;
}
}
@media (min-width: 1080px) {
.analytics-dashboard-charts {
// Desktop layout - charts in top row, details in bottom row
display: grid;
gap: 2rem;
// Equal columns each gets 1/2 of the space
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"chart1 chart2"
"details1 details2"
"chart3 chart4"
"details3 details4"
"chart5 chart6"
"details5 details6";
.chart-1 { grid-area: chart1; }
.chart-2 { grid-area: chart2; }
.chart-3 { grid-area: chart3; }
.chart-4 { grid-area: chart4; }
.chart-5 { grid-area: chart5; }
.chart-6 { grid-area: chart6; }
.details-1 { grid-area: details1; }
.details-2 { grid-area: details2; }
.details-3 { grid-area: details3; }
.details-4 { grid-area: details4; }
.details-5 { grid-area: details5; }
.details-6 { grid-area: details6; }
}
}
#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

@ -41,13 +41,8 @@ th {
}
}
// The member table has an extra "expand" row, which looks like a single row.
// But the DOM disagrees - so we basically need to hide the border on both rows.
#members__table-wrapper .dotgov-table tr:nth-last-child(2) td,
#members__table-wrapper .dotgov-table tr:nth-last-child(2) th {
border-bottom: none;
}
// .dotgov-table allows us to customize .usa-table on the user-facing pages,
// while leaving the default styles for use on the admin pages
.dotgov-table {
width: 100%;
@ -68,7 +63,8 @@ th {
border-bottom: 1px solid color('base-lighter');
}
thead th {
thead th,
thead th[aria-sort] {
color: color('primary-darker');
border-bottom: 2px solid color('base-light');
}
@ -93,17 +89,46 @@ th {
}
}
// Sortable headers
th[data-sortable][aria-sort=ascending],
th[data-sortable][aria-sort=descending] {
background-color: transparent;
.usa-table__header__button {
background-color: color('accent-cool-lightest');
border-radius: units(.5);
color: color('primary-darker');
&:hover {
background-color: color('accent-cool-lightest');
}
}
}
@include at-media(tablet-lg) {
th[data-sortable] .usa-table__header__button {
th[data-sortable]:not(.left-align-sort-button) .usa-table__header__button {
// position next to the copy
right: auto;
// slide left to mock a margin between the copy and the icon
transform: translateX(units(1));
// fix vertical alignment
top: units(1.5);
}
th[data-sortable].left-align-sort-button .usa-table__header__button {
left: 0;
}
}
&[aria-sort=ascending],
&[aria-sort=descending],
&:not([aria-sort]) {
right: auto;
}
// Currently the 'flash' when sort is clicked,
// this will become persistent if the double-sort bug is fixed
td[data-sort-active],
th[data-sort-active] {
background-color: color('primary-lightest');
}
}
// The member table has an extra "expand" row, which looks like a single row.
// But the DOM disagrees - so we basically need to hide the border on both rows.
#members__table-wrapper .dotgov-table tr:nth-last-child(2) td,
#members__table-wrapper .dotgov-table tr:nth-last-child(2) th {
border-bottom: none;
}
.dotgov-table--cell-padding-2 {
@ -118,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

@ -201,6 +201,8 @@ MIDDLEWARE = [
"waffle.middleware.WaffleMiddleware",
"registrar.registrar_middleware.CheckUserProfileMiddleware",
"registrar.registrar_middleware.CheckPortfolioMiddleware",
# Restrict access using Opt-Out approach
"registrar.registrar_middleware.RestrictAccessMiddleware",
]
# application object used by Django's built-in servers (e.g. `runserver`)

View file

@ -68,7 +68,7 @@ for step, view in [
(PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity),
(PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails),
]:
domain_request_urls.append(path(f"<int:id>/{step}/", view.as_view(), name=step))
domain_request_urls.append(path(f"<int:domain_request_pk>/{step}/", view.as_view(), name=step))
urlpatterns = [
@ -260,27 +260,27 @@ urlpatterns = [
name="export_data_type_user",
),
path(
"domain-request/<int:id>/edit/",
"domain-request/<int:domain_request_pk>/edit/",
views.DomainRequestWizard.as_view(),
name=views.DomainRequestWizard.EDIT_URL_NAME,
),
path(
"domain-request/<int:pk>",
"domain-request/<int:domain_request_pk>",
views.DomainRequestStatus.as_view(),
name="domain-request-status",
),
path(
"domain-request/viewonly/<int:pk>",
"domain-request/viewonly/<int:domain_request_pk>",
views.PortfolioDomainRequestStatusViewOnly.as_view(),
name="domain-request-status-viewonly",
),
path(
"domain-request/<int:pk>/withdraw",
"domain-request/<int:domain_request_pk>/withdraw",
views.DomainRequestWithdrawConfirmation.as_view(),
name="domain-request-withdraw-confirmation",
),
path(
"domain-request/<int:pk>/withdrawconfirmed",
"domain-request/<int:domain_request_pk>/withdrawconfirmed",
views.DomainRequestWithdrawn.as_view(),
name="domain-request-withdrawn",
),
@ -296,56 +296,60 @@ urlpatterns = [
lambda r: always_404(r, "We forgot to include this link, sorry."),
name="todo",
),
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"),
path("domain/<int:pk>/prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"),
path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path("domain/<int:domain_pk>", views.DomainView.as_view(), name="domain"),
path(
"domain/<int:pk>/dns",
"domain/<int:domain_pk>/prototype-dns",
views.PrototypeDomainDNSRecordView.as_view(),
name="prototype-domain-dns",
),
path("domain/<int:domain_pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path(
"domain/<int:domain_pk>/dns",
views.DomainDNSView.as_view(),
name="domain-dns",
),
path(
"domain/<int:pk>/dns/nameservers",
"domain/<int:domain_pk>/dns/nameservers",
views.DomainNameserversView.as_view(),
name="domain-dns-nameservers",
),
path(
"domain/<int:pk>/dns/dnssec",
"domain/<int:domain_pk>/dns/dnssec",
views.DomainDNSSECView.as_view(),
name="domain-dns-dnssec",
),
path(
"domain/<int:pk>/dns/dnssec/dsdata",
"domain/<int:domain_pk>/dns/dnssec/dsdata",
views.DomainDsDataView.as_view(),
name="domain-dns-dnssec-dsdata",
),
path(
"domain/<int:pk>/org-name-address",
"domain/<int:domain_pk>/org-name-address",
views.DomainOrgNameAddressView.as_view(),
name="domain-org-name-address",
),
path(
"domain/<int:pk>/suborganization",
"domain/<int:domain_pk>/suborganization",
views.DomainSubOrganizationView.as_view(),
name="domain-suborganization",
),
path(
"domain/<int:pk>/senior-official",
"domain/<int:domain_pk>/senior-official",
views.DomainSeniorOfficialView.as_view(),
name="domain-senior-official",
),
path(
"domain/<int:pk>/security-email",
"domain/<int:domain_pk>/security-email",
views.DomainSecurityEmailView.as_view(),
name="domain-security-email",
),
path(
"domain/<int:pk>/renewal",
"domain/<int:domain_pk>/renewal",
views.DomainRenewalView.as_view(),
name="domain-renewal",
),
path(
"domain/<int:pk>/users/add",
"domain/<int:domain_pk>/users/add",
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
@ -360,17 +364,17 @@ urlpatterns = [
name="user-profile",
),
path(
"invitation/<int:pk>/cancel",
"invitation/<int:domain_invitation_pk>/cancel",
views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
name="invitation-cancel",
),
path(
"domain-request/<int:pk>/delete",
"domain-request/<int:domain_request_pk>/delete",
views.DomainRequestDeleteView.as_view(http_method_names=["post"]),
name="domain-request-delete",
),
path(
"domain/<int:pk>/users/<int:user_pk>/delete",
"domain/<int:domain_pk>/users/<int:user_pk>/delete",
views.DomainDeleteUserView.as_view(http_method_names=["post"]),
name="domain-user-delete",
),
@ -392,6 +396,7 @@ urlpatterns = [
# This way, we can share a view for djangooidc, and other pages as we see fit.
handler500 = "registrar.views.utility.error_views.custom_500_error_view"
handler403 = "registrar.views.utility.error_views.custom_403_error_view"
handler404 = "registrar.views.utility.error_views.custom_404_error_view"
# we normally would guard these with `if settings.DEBUG` but tests run with
# DEBUG = False even when these apps have been loaded because settings.DEBUG

300
src/registrar/decorators.py Normal file
View file

@ -0,0 +1,300 @@
import functools
from django.core.exceptions import PermissionDenied
from django.utils.decorators import method_decorator
from registrar.models import Domain, DomainInformation, DomainInvitation, DomainRequest, UserDomainRole
# Constants for clarity
ALL = "all"
IS_STAFF = "is_staff"
IS_DOMAIN_MANAGER = "is_domain_manager"
IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator"
IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain"
IS_PORTFOLIO_MEMBER = "is_portfolio_member"
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER = "is_portfolio_member_and_domain_manager"
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER = "is_domain_manager_and_not_portfolio_member"
HAS_PORTFOLIO_DOMAINS_ANY_PERM = "has_portfolio_domains_any_perm"
HAS_PORTFOLIO_DOMAINS_VIEW_ALL = "has_portfolio_domains_view_all"
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM = "has_portfolio_domain_requests_any_perm"
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL = "has_portfolio_domain_requests_view_all"
HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT = "has_portfolio_domain_requests_edit"
HAS_PORTFOLIO_MEMBERS_ANY_PERM = "has_portfolio_members_any_perm"
HAS_PORTFOLIO_MEMBERS_EDIT = "has_portfolio_members_edit"
HAS_PORTFOLIO_MEMBERS_VIEW = "has_portfolio_members_view"
def grant_access(*rules):
"""
A decorator that enforces access control based on specified rules.
Usage:
- Multiple rules in a single decorator:
@grant_access(IS_STAFF, IS_SUPERUSER, IS_DOMAIN_MANAGER)
- Stacked decorators for separate rules:
@grant_access(IS_SUPERUSER)
@grant_access(IS_DOMAIN_MANAGER)
The decorator supports both function-based views (FBVs) and class-based views (CBVs).
"""
def decorator(view):
if isinstance(view, type): # Check if decorating a class-based view (CBV)
original_dispatch = view.dispatch # Store the original dispatch method
@method_decorator(grant_access(*rules)) # Apply the decorator to dispatch
def wrapped_dispatch(self, request, *args, **kwargs):
if not _user_has_permission(request.user, request, rules, **kwargs):
raise PermissionDenied # Deny access if the user lacks permission
return original_dispatch(self, request, *args, **kwargs)
view.dispatch = wrapped_dispatch # Replace the dispatch method
return view
else: # If decorating a function-based view (FBV)
view.has_explicit_access = True # Mark the view as having explicit access control
existing_rules = getattr(view, "_access_rules", set()) # Retrieve existing rules
existing_rules.update(rules) # Merge with new rules
view._access_rules = existing_rules # Store updated rules
@functools.wraps(view)
def wrapper(request, *args, **kwargs):
if not _user_has_permission(request.user, request, rules, **kwargs):
raise PermissionDenied # Deny access if the user lacks permission
return view(request, *args, **kwargs) # Proceed with the original view
return wrapper
return decorator
def _user_has_permission(user, request, rules, **kwargs):
"""
Determines if the user meets the required permission rules.
This function evaluates a set of predefined permission rules to check whether a user has access
to a specific view. It supports various access control conditions, including staff status,
domain management roles, and portfolio-related permissions.
Parameters:
- user: The user requesting access.
- request: The HTTP request object.
- rules: A set of access control rules to evaluate.
- **kwargs: Additional keyword arguments used in specific permission checks.
Returns:
- True if the user satisfies any of the specified rules.
- False otherwise.
"""
# Skip authentication if @login_not_required is applied
if getattr(request, "login_not_required", False):
return True
# Allow everyone if `ALL` is in rules
if ALL in rules:
return True
# Ensure user is authenticated and not restricted
if not user.is_authenticated or user.is_restricted():
return False
# Define permission checks
permission_checks = [
(IS_STAFF, lambda: user.is_staff),
(IS_DOMAIN_MANAGER, lambda: _is_domain_manager(user, **kwargs)),
(IS_STAFF_MANAGING_DOMAIN, lambda: _is_staff_managing_domain(request, **kwargs)),
(IS_PORTFOLIO_MEMBER, lambda: user.is_org_user(request)),
(
HAS_PORTFOLIO_DOMAINS_VIEW_ALL,
lambda: _has_portfolio_view_all_domains(request, kwargs.get("domain_pk")),
),
(
HAS_PORTFOLIO_DOMAINS_ANY_PERM,
lambda: user.is_org_user(request)
and user.has_any_domains_portfolio_permission(request.session.get("portfolio")),
),
(
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER,
lambda: _is_domain_manager(user, **kwargs) and _is_portfolio_member(request),
),
(
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER,
lambda: _is_domain_manager(user, **kwargs) and not _is_portfolio_member(request),
),
(
IS_DOMAIN_REQUEST_CREATOR,
lambda: _is_domain_request_creator(user, kwargs.get("domain_request_pk"))
and not _is_portfolio_member(request),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM,
lambda: user.is_org_user(request)
and user.has_any_requests_portfolio_permission(request.session.get("portfolio")),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL,
lambda: user.is_org_user(request)
and user.has_view_all_domain_requests_portfolio_permission(request.session.get("portfolio")),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT,
lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")),
),
(
HAS_PORTFOLIO_MEMBERS_ANY_PERM,
lambda: user.is_org_user(request)
and (
user.has_view_members_portfolio_permission(request.session.get("portfolio"))
or user.has_edit_members_portfolio_permission(request.session.get("portfolio"))
),
),
(
HAS_PORTFOLIO_MEMBERS_EDIT,
lambda: user.is_org_user(request)
and user.has_edit_members_portfolio_permission(request.session.get("portfolio")),
),
(
HAS_PORTFOLIO_MEMBERS_VIEW,
lambda: user.is_org_user(request)
and user.has_view_members_portfolio_permission(request.session.get("portfolio")),
),
]
# Check conditions iteratively
return any(check() for rule, check in permission_checks if rule in rules)
def _has_portfolio_domain_requests_edit(user, request, domain_request_id):
if domain_request_id and not _is_domain_request_creator(user, domain_request_id):
return False
return user.is_org_user(request) and user.has_edit_request_portfolio_permission(request.session.get("portfolio"))
def _is_domain_manager(user, **kwargs):
"""
Determines if the given user is a domain manager for a specified domain.
- First, it checks if 'domain_pk' is present in the URL parameters.
- If 'domain_pk' exists, it verifies if the user has a domain role for that domain.
- If 'domain_pk' is absent, it checks for 'domain_invitation_pk' to determine if the user
has domain permissions through an invitation.
Returns:
bool: True if the user is a domain manager, False otherwise.
"""
domain_id = kwargs.get("domain_pk")
if domain_id:
return UserDomainRole.objects.filter(user=user, domain_id=domain_id).exists()
domain_invitation_id = kwargs.get("domain_invitation_pk")
if domain_invitation_id:
return DomainInvitation.objects.filter(id=domain_invitation_id, domain__permissions__user=user).exists()
return False
def _is_domain_request_creator(user, domain_request_pk):
"""Checks to see if the user is the creator of a domain request
with domain_request_pk."""
if domain_request_pk:
return DomainRequest.objects.filter(creator=user, id=domain_request_pk).exists()
return True
def _is_portfolio_member(request):
"""Checks to see if the user in the request is a member of the
portfolio in the request's session."""
return request.user.is_org_user(request)
def _is_staff_managing_domain(request, **kwargs):
"""
Determines whether a staff user (analyst or superuser) has permission to manage a domain
that they did not create or were not invited to.
The function enforces:
1. **User Authorization** - The user must have `analyst_access_permission` or `full_access_permission`.
2. **Valid Session Context** - The user must have explicitly selected the domain for management
via an 'analyst action' (e.g., by clicking 'Manage Domain' in the admin interface).
3. **Domain Status Check** - Only domains in specific statuses (e.g., APPROVED, IN_REVIEW, etc.)
can be managed, except in cases where the domain lacks a status due to errors.
Process:
- First, the function retrieves the `domain_pk` from the URL parameters.
- If `domain_pk` is not provided, it attempts to resolve the domain via `domain_invitation_pk`.
- It checks if the user has the required permissions.
- It verifies that the user has an active 'analyst action' session for the domain.
- Finally, it ensures that the domain is in a status that allows management.
Returns:
bool: True if the user is allowed to manage the domain, False otherwise.
"""
domain_id = kwargs.get("domain_pk")
if not domain_id:
domain_invitation_id = kwargs.get("domain_invitation_pk")
domain_invitation = DomainInvitation.objects.filter(id=domain_invitation_id).first()
if domain_invitation:
domain_id = domain_invitation.domain_id
# Check if the request user is permissioned...
user_is_analyst_or_superuser = request.user.has_perm(
"registrar.analyst_access_permission"
) or request.user.has_perm("registrar.full_access_permission")
if not user_is_analyst_or_superuser:
return False
# Check if the user is attempting a valid edit action.
# In other words, if the analyst/admin did not click
# the 'Manage Domain' button in /admin,
# then they cannot access this page.
session = request.session
can_do_action = (
"analyst_action" in session
and "analyst_action_location" in session
and session["analyst_action_location"] == domain_id
)
if not can_do_action:
return False
# Analysts may manage domains, when they are in these statuses:
valid_domain_statuses = [
DomainRequest.DomainRequestStatus.APPROVED,
DomainRequest.DomainRequestStatus.IN_REVIEW,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
# Edge case - some domains do not have
# a status or DomainInformation... aka a status of 'None'.
# It is necessary to access those to correct errors.
None,
]
requested_domain = DomainInformation.objects.filter(domain_id=domain_id).first()
# if no domain information or domain request exist, the user
# should be able to manage the domain; however, if domain information
# and domain request exist, and domain request is not in valid status,
# user should not be able to manage domain
if (
requested_domain
and requested_domain.domain_request
and requested_domain.domain_request.status not in valid_domain_statuses
):
return False
# Valid session keys exist,
# the user is permissioned,
# and it is in a valid status
return True
def _has_portfolio_view_all_domains(request, domain_pk):
"""Returns whether the user in the request can access the domain
via portfolio view all domains permission."""
portfolio = request.session.get("portfolio")
if request.user.has_view_all_domains_portfolio_permission(portfolio):
if Domain.objects.filter(id=domain_pk).exists():
domain = Domain.objects.get(id=domain_pk)
if domain.domain_info.portfolio == portfolio:
return True
return False

View file

@ -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, all"),
(
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)}
@ -338,6 +382,24 @@ class BasePortfolioMemberForm(forms.ModelForm):
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
)
def is_change(self) -> bool:
"""
Determines if the form has changed by comparing the initial data
with the submitted cleaned data.
Returns:
bool: True if the form has changed, False otherwise.
"""
# Compare role values
previous_roles = set(self.initial.get("roles", []))
new_roles = set(self.cleaned_data.get("roles", []))
# Compare additional permissions values
previous_permissions = set(self.initial.get("additional_permissions") or [])
new_permissions = set(self.cleaned_data.get("additional_permissions") or [])
return previous_roles != new_roles or previous_permissions != new_permissions
class PortfolioMemberForm(BasePortfolioMemberForm):
"""

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)
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,7 +1049,8 @@ 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,
@ -1060,30 +1058,110 @@ class Domain(TimeStampedModel, DomainHelper):
oldNameservers,
) = self.getNameserverChanges(hosts=[])
_ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors
# 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)
logger.info(f"retrieved contacts for domain: {contacts}")
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("Deleting domain %s", self.name)
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)
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,6 +9,13 @@ 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,
) # type: ignore
from .utility.time_stamped_model import TimeStampedModel
@ -85,6 +92,90 @@ class PortfolioInvitation(TimeStampedModel):
"""
return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
@property
def role_display(self):
"""
Returns a human-readable display name for the user's role.
Uses the `get_role_display` function to determine if the user is an "Admin",
"Basic" member, or has no role assigned.
Returns:
str: The display name of the user's role.
"""
return get_role_display(self.roles)
@property
def domains_display(self):
"""
Returns a string representation of the user's domain access level.
Uses the `get_domains_display` function to determine whether the user has
"Viewer" access (can view all domains) or "Viewer, limited" access.
Returns:
str: The display name of the user's domain permissions.
"""
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):
"""
Returns a string representation of the user's access to domain requests.
Uses the `get_domain_requests_display` function to determine if the user
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
or has "No access" to domain requests.
Returns:
str: The display name of the user's domain request permissions.
"""
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):
"""
Returns a string representation of the user's access to managing members.
Uses the `get_members_display` function to determine if the user is a
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
to member management.
Returns:
str: The display name of the user's member management permissions.
"""
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

@ -269,7 +269,7 @@ class User(AbstractUser):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
def is_portfolio_admin(self, portfolio):
return "Admin" in self.portfolio_role_summary(portfolio)
return self.has_edit_portfolio_permission(portfolio)
def get_first_portfolio(self):
permission = self.portfolio_permissions.first()
@ -277,49 +277,6 @@ class User(AbstractUser):
return permission.portfolio
return None
def portfolio_role_summary(self, portfolio):
"""Returns a list of roles based on the user's permissions."""
roles = []
# Define the conditions and their corresponding roles
conditions_roles = [
(self.has_edit_portfolio_permission(portfolio), ["Admin"]),
(
self.has_view_all_domains_portfolio_permission(portfolio)
and self.has_any_requests_portfolio_permission(portfolio)
and self.has_edit_request_portfolio_permission(portfolio),
["View-only admin", "Domain requestor"],
),
(
self.has_view_all_domains_portfolio_permission(portfolio)
and self.has_any_requests_portfolio_permission(portfolio),
["View-only admin"],
),
(
self.has_view_portfolio_permission(portfolio)
and self.has_edit_request_portfolio_permission(portfolio)
and self.has_any_domains_portfolio_permission(portfolio),
["Domain requestor", "Domain manager"],
),
(
self.has_view_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
["Domain requestor"],
),
(
self.has_view_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
["Domain manager"],
),
(self.has_view_portfolio_permission(portfolio), ["Member"]),
]
# Evaluate conditions and add roles
for condition, role_list in conditions_roles:
if condition:
roles.extend(role_list)
break
return roles
def get_portfolios(self):
return self.portfolio_permissions.all()

View file

@ -6,6 +6,13 @@ from registrar.models.utility.portfolio_helper import (
DomainRequestPermissionDisplay,
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,
)
from .utility.time_stamped_model import TimeStampedModel
@ -181,6 +188,90 @@ class UserPortfolioPermission(TimeStampedModel):
# This is the same as portfolio_permissions & common_forbidden_perms.
return portfolio_permissions.intersection(common_forbidden_perms)
@property
def role_display(self):
"""
Returns a human-readable display name for the user's role.
Uses the `get_role_display` function to determine if the user is an "Admin",
"Basic" member, or has no role assigned.
Returns:
str: The display name of the user's role.
"""
return get_role_display(self.roles)
@property
def domains_display(self):
"""
Returns a string representation of the user's domain access level.
Uses the `get_domains_display` function to determine whether the user has
"Viewer" access (can view all domains) or "Viewer, limited" access.
Returns:
str: The display name of the user's domain permissions.
"""
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):
"""
Returns a string representation of the user's access to domain requests.
Uses the `get_domain_requests_display` function to determine if the user
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
or has "No access" to domain requests.
Returns:
str: The display name of the user's domain request permissions.
"""
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):
"""
Returns a string representation of the user's access to managing members.
Uses the `get_members_display` function to determine if the user is a
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
to member management.
Returns:
str: The display name of the user's member management permissions.
"""
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

@ -79,6 +79,161 @@ class MemberPermissionDisplay(StrEnum):
NONE = "None"
def get_role_display(roles):
"""
Returns a user-friendly display name for a given list of user roles.
- If the user has the ORGANIZATION_ADMIN role, return "Admin".
- If the user has the ORGANIZATION_MEMBER role, return "Basic".
- If the user has neither role, return "-".
Args:
roles (list): A list of role strings assigned to the user.
Returns:
str: The display name for the highest applicable role.
"""
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles:
return "Admin"
elif UserPortfolioRoleChoices.ORGANIZATION_MEMBER in roles:
return "Basic"
else:
return "-"
def get_domains_display(roles, permissions):
"""
Determines the display name for a user's domain viewing permissions.
- If the user has the VIEW_ALL_DOMAINS permission, return "Viewer".
- Otherwise, return "Viewer, limited".
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.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
return "Viewer"
else:
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.
- If the user has the EDIT_REQUESTS permission, return "Creator".
- If the user has the VIEW_ALL_REQUESTS permission, return "Viewer".
- Otherwise, return "No access".
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.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
return "Creator"
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
return "Viewer"
else:
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.
- If the user has the EDIT_MEMBERS permission, return "Manager".
- If the user has the VIEW_MEMBERS permission, return "Viewer".
- Otherwise, return "No access".
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.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
return "Manager"
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
return "Viewer"
else:
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

@ -3,9 +3,13 @@ Contains middleware used in settings.py
"""
import logging
import re
from urllib.parse import parse_qs
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.urls import resolve
from registrar.models import User
from waffle.decorators import flag_is_active
@ -170,3 +174,51 @@ class CheckPortfolioMiddleware:
request.session["portfolio"] = request.user.get_first_portfolio()
else:
request.session["portfolio"] = request.user.get_first_portfolio()
class RestrictAccessMiddleware:
"""
Middleware that blocks access to all views unless explicitly permitted.
This middleware enforces authentication by default. Views must explicitly allow access
using access control mechanisms such as the `@grant_access` decorator. Exceptions are made
for Django admin views, explicitly ignored paths, and views that opt out of login requirements.
"""
def __init__(self, get_response):
self.get_response = get_response
# Compile regex patterns from settings to identify paths that bypass login requirements
self.ignored_paths = [re.compile(pattern) for pattern in getattr(settings, "LOGIN_REQUIRED_IGNORE_PATHS", [])]
def __call__(self, request):
# Allow requests to Django Debug Toolbar
if request.path.startswith("/__debug__/"):
return self.get_response(request)
# Allow requests matching configured ignored paths
if any(pattern.match(request.path) for pattern in self.ignored_paths):
return self.get_response(request)
# Attempt to resolve the request path to a view function
try:
resolver_match = resolve(request.path_info)
view_func = resolver_match.func
app_name = resolver_match.app_name # Get the app name of the resolved view
except Exception:
# If resolution fails, allow the request to proceed (avoid blocking non-view routes)
return self.get_response(request)
# Automatically allow access to Django's built-in admin views (excluding custom /admin/* views)
if app_name == "admin":
return self.get_response(request)
# Allow access if the view explicitly opts out of login requirements
if getattr(view_func, "login_required", True) is False:
return self.get_response(request)
# Restrict access to views that do not explicitly declare access rules
if not getattr(view_func, "has_explicit_access", False):
raise PermissionDenied # Deny access if the view lacks explicit permission handling
return self.get_response(request)

View file

@ -18,7 +18,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% block content %}
<div id="content-main" class="custom-admin-template">
<div id="content-main" class="custom-admin-template analytics-dashboard">
<div class="grid-row grid-gap-2">
<div class="tablet:grid-col-6 margin-top-2">
@ -95,7 +95,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
<input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" />
</div>
</div>
<ul class="usa-button-group">
<ul class="usa-button-group flex-wrap">
<li class="usa-button-group__item">
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
@ -133,79 +133,126 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
</li>
</ul>
<div class="grid-row grid-gap-2 margin-y-2">
<div class="grid-col">
<canvas id="myChart1" width="400" height="200"
aria-label="Chart: {{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}"
<div class="analytics-dashboard-charts margin-top-2">
{% comment %} Managed/Unmanaged domains {% endcomment %}
<div class="chart-1 grid-col">
<canvas id="managed-domains-chart" width="400" height="200"
aria-label="Chart: {{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.managed_domains_sliced_at_start_date}}"
data-list-two="{{data.managed_domains_sliced_at_end_date}}"
data-list-one="{{ data.managed_domains.start_date_count }}"
data-list-two="{{ data.managed_domains.end_date_count }}"
>
<h2>Chart: Managed domains</h2>
<p>{{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}</p>
<p>{{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="grid-col">
<canvas id="myChart2" width="400" height="200"
aria-label="Chart: {{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}"
<div class="details-1 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for managed domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="managed_domains" %}
</div>
</details>
</div>
<div class="chart-2 grid-col">
<canvas id="unmanaged-domains-chart" width="400" height="200"
aria-label="Chart: {{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.unmanaged_domains_sliced_at_start_date}}"
data-list-two="{{data.unmanaged_domains_sliced_at_end_date}}"
data-list-one="{{ data.unmanaged_domains.start_date_count }}"
data-list-two="{{ data.unmanaged_domains.end_date_count }}"
>
<h2>Chart: Unmanaged domains</h2>
<p>{{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}</p>
<p>{{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="details-2 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for unmanaged domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="unmanaged_domains" %}
</div>
</details>
</div>
<div class="grid-row grid-gap-2 margin-y-2">
<div class="grid-col">
<canvas id="myChart3" width="400" height="200"
aria-label="Chart: {{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}"
{% comment %} Deleted/Ready domains {% endcomment %}
<div class="chart-3 grid-col">
<canvas id="deleted-domains-chart" width="400" height="200"
aria-label="Chart: {{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.deleted_domains_sliced_at_start_date}}"
data-list-two="{{data.deleted_domains_sliced_at_end_date}}"
data-list-one="{{ data.deleted_domains.start_date_count }}"
data-list-two="{{ data.deleted_domains.end_date_count }}"
>
<h2>Chart: Deleted domains</h2>
<p>{{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}</p>
<p>{{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="grid-col">
<canvas id="myChart4" width="400" height="200"
aria-label="Chart: {{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}"
<div class="details-3 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for deleted domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="deleted_domains" %}
</div>
</details>
</div>
<div class="chart-4 grid-col">
<canvas id="ready-domains-chart" width="400" height="200"
aria-label="Chart: {{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}"
role="img"
data-list-one="{{data.ready_domains_sliced_at_start_date}}"
data-list-two="{{data.ready_domains_sliced_at_end_date}}"
data-list-one="{{ data.ready_domains.start_date_count }}"
data-list-two="{{ data.ready_domains.end_date_count }}"
>
<h2>Chart: Ready domains</h2>
<p>{{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}</p>
<p>{{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="details-4 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for ready domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="ready_domains" %}
</div>
</details>
</div>
<div class="grid-row grid-gap-2 margin-y-2">
<div class="grid-col">
<canvas id="myChart5" width="400" height="200"
aria-label="Chart: {{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}"
{% comment %} Requests {% endcomment %}
<div class="chart-5 grid-col">
<canvas id="submitted-requests-chart" width="400" height="200"
aria-label="Chart: {{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}"
role="img"
data-list-one="{{data.submitted_requests_sliced_at_start_date}}"
data-list-two="{{data.submitted_requests_sliced_at_end_date}}"
data-list-one="{{ data.submitted_requests.start_date_count }}"
data-list-two="{{ data.submitted_requests.end_date_count }}"
>
<h2>Chart: Submitted requests</h2>
<p>{{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}</p>
<p>{{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}</p>
</canvas>
</div>
<div class="grid-col">
<canvas id="myChart6" width="400" height="200"
aria-label="Chart: {{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}"
<div class="details-5 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for submitted requests</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="submitted_requests" %}
</div>
</details>
</div>
<div class="chart-6 grid-col">
<canvas id="all-requests-chart" width="400" height="200"
aria-label="Chart: {{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}"
role="img"
data-list-one="{{data.requests_sliced_at_start_date}}"
data-list-two="{{data.requests_sliced_at_end_date}}"
data-list-one="{{ data.requests.start_date_count }}"
data-list-two="{{ data.requests.end_date_count }}"
>
<h2>Chart: All requests</h2>
<p>{{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}</p>
<p>{{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}</p>
</canvas>
</div>
<div class="details-6 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for all requests</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="requests" %}
</div>
</details>
</div>
</div>
</div>

View file

@ -0,0 +1,26 @@
<table class="usa-table usa-table--borderless">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Start date {{ data.start_date }}</th>
<th scope="col">End date {{ data.end_date }} </th>
<tr>
</thead>
<tbody>
{% comment %}
This ugly notation is equivalent to data.property_name.start_date_count.index.
Or represented in the pure python way: data[property_name]["start_date_count"][index]
{% endcomment %}
{% with start_counts=data|get_item:property_name|get_item:"start_date_count" end_counts=data|get_item:property_name|get_item:"end_date_count" %}
{% for org_count_type in data.org_count_types %}
{% with index=forloop.counter %}
<tr>
<th class="padding-left-1" scope="row">{{ org_count_type }}</th>
<td class="padding-left-1">{{ start_counts|slice:index|last }}</td>
<td class="padding-left-1">{{ end_counts|slice:index|last }}</td>
</tr>
{% endwith %}
{% endfor %}
{% endwith %}
</tbody>
</table>

View file

@ -4,15 +4,12 @@
{% 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>
{# .gov override: hide headers #}
{% comment %}
{% if show_changelinks %}
<th colspan="3" class="primary-th" scope="colgroup">
{{ app.name }}
@ -22,6 +19,7 @@
{{ 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

@ -22,7 +22,6 @@
<script src="{% static 'js/uswds.min.js' %}" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script type="application/javascript" src="{% static 'js/getgov-admin.min.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
{% endblock %}
@ -49,6 +48,10 @@
{% endwith %}
{% endif %}
{% if opts.model_name %}
<a class="usa-skipnav" href="#changelist-filter" aria-label="Skip to the filters section">Skip to filters</a>
{% endif %}
{# Djando update: this div will change to header #}
<div id="header">
<div id="branding">

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

@ -1,4 +1,5 @@
{% extends "admin/change_list.html" %}
{% load i18n admin_urls static admin_list %}
{% block content_title %}
<h1>{{ title }}</h1>
@ -37,6 +38,7 @@
for {{ search_query }}
{% endif %}
</h2>
{% endblock %}
{% comment %} Replace the Django ul markup with a div. We'll replace the li with a p in change_list_object_tools {% endcomment %}
@ -47,3 +49,24 @@
{% endblock %}
</div>
{% endblock %}
{% comment %} Replace the Django header markup for clearing all filters with a div. {% endcomment %}
{% block filters %}
{% if cl.has_filters %}
<nav id="changelist-filter" aria-labelledby="changelist-filter-header">
<h2 id="changelist-filter-header">{% translate 'Filter' %}</h2>
{% if cl.is_facets_optional or cl.has_active_filters %}<div id="changelist-filter-extra-actions">
{% if cl.is_facets_optional %}<h3>
{% if cl.add_facets %}<a href="{{ cl.remove_facet_link }}" class="hidelink">{% translate "Hide counts" %}</a>
{% else %}<a href="{{ cl.add_facet_link }}" class="viewlink">{% translate "Show counts" %}</a>{% endif %}
</h3>{% endif %}
{% if cl.has_active_filters %}<div class="margin-2">
<a href="{{ cl.clear_all_filters_qs }}" role="link">&#10006; {% translate "Clear all filters" %}</a>
</div>{% endif %}
</div>{% endif %}
{% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
</nav>
{% endif %}
{% endblock %}

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

@ -10,7 +10,6 @@
<th>Title</th>
<th>Email</th>
<th>Phone</th>
<th>Roles</th>
<th>Action</th>
</tr>
</thead>
@ -28,11 +27,6 @@
{% endif %}
</td>
<td>{{ member.user.phone }}</td>
<td>
{% for role in member.user|portfolio_role_summary:original %}
<span class="usa-tag bg-primary-dark text-semibold">{{ role }}</span>
{% endfor %}
</td>
<td class="padding-left-1 text-size-small">
{% if member.user.email %}
<input aria-hidden="true" class="display-none" value="{{ member.user.email }}" />

View file

@ -1,8 +1,10 @@
{% load i18n %}
{% load static field_helpers url_helpers %}
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
<details data-filter-title="{{ title }}" open="">
<summary aria-label="Show/hide {{ title }} filters" role="button">
{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}
</summary>
<ul class="mulitple-choice">
{% for choice in choices %}
{% if choice.reset %}
@ -11,11 +13,12 @@
</li>
{% endif %}
{% endfor %}
{% for choice in choices %}
{% if not choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}>
<li{% if choice.selected %} class="selected"{% endif %}">
{% if choice.selected and choice.exclude_query_string %}
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
@ -24,7 +27,7 @@
</svg>
</a>
{% elif not choice.selected and choice.include_query_string %}
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
@ -34,3 +37,5 @@
{% endif %}
{% endfor %}
</ul>
</details>
<a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}

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

@ -16,10 +16,10 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-users' pk=domain.id %}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
<a href="{% url 'domain-users' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Add a domain manager</span>
@ -27,7 +27,7 @@
</ol>
</nav>
{% else %}
{% url 'domain-users' pk=domain.id as url %}
{% url 'domain-users' domain_pk=domain.id as url %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain manager breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
@ -42,15 +42,16 @@
{% endblock breadcrumb %}
<h1>Add a domain manager</h1>
{% if has_organization_feature_flag %}
{% if portfolio %}
<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.
Domain managers can be a member of only one .gov organization.
</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.
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>
{% endif %}

View file

@ -48,11 +48,11 @@
<p class="margin-y-0 text-primary-darker">
{% if domain.is_expired and is_domain_manager %}
This domain has expired, but it is still online.
{% url 'domain-renewal' pk=domain.id as url %}
{% url 'domain-renewal' domain_pk=domain.id as url %}
<a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
{% elif domain.is_expiring and is_domain_manager %}
This domain will expire soon.
{% url 'domain-renewal' pk=domain.id as url %}
{% url 'domain-renewal' domain_pk=domain.id as url %}
<a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
{% elif domain.is_expiring and is_portfolio_user %}
This domain will expire soon. Contact one of the listed domain managers to renew the domain.
@ -82,7 +82,7 @@
{% endif %}
{% url 'domain-dns-nameservers' pk=domain.id as url %}
{% url 'domain-dns-nameservers' domain_pk=domain.id as url %}
{% if domain.nameservers|length > 0 %}
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=is_editable %}
{% else %}
@ -95,7 +95,7 @@
{% endif %}
{% endif %}
{% url 'domain-dns-dnssec' pk=domain.id as url %}
{% url 'domain-dns-dnssec' domain_pk=domain.id as url %}
{% if domain.dnssecdata is not None %}
{% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %}
{% else %}
@ -104,26 +104,26 @@
{% if portfolio %}
{% if has_any_domains_portfolio_permission and has_edit_portfolio_permission %}
{% url 'domain-suborganization' pk=domain.id as url %}
{% url 'domain-suborganization' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_portfolio_permission %}
{% elif has_any_domains_portfolio_permission and has_view_portfolio_permission %}
{% url 'domain-suborganization' pk=domain.id as url %}
{% url 'domain-suborganization' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_portfolio_permission view_button=True %}
{% endif %}
{% else %}
{% url 'domain-org-name-address' pk=domain.id as url %}
{% url 'domain-org-name-address' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Organization' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
{% url 'domain-senior-official' pk=domain.id as url %}
{% url 'domain-senior-official' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %}
{% endif %}
{% url 'domain-security-email' pk=domain.id as url %}
{% url 'domain-security-email' domain_pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%}
{% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=is_editable %}
{% else %}
{% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=is_editable %}
{% endif %}
{% url 'domain-users' pk=domain.id as url %}
{% url 'domain-users' domain_pk=domain.id as url %}
{% if portfolio %}
{% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %}
{% else %}

View file

@ -13,7 +13,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DNS</span>
@ -30,14 +30,14 @@
<p>You can enter your name servers, as well as other DNS-related information, in the following sections:</p>
{% url 'domain-dns-nameservers' pk=domain.id as url %}
{% url 'domain-dns-nameservers' domain_pk=domain.id as url %}
<ul class="usa-list">
<li><a href="{{ url }}">Name servers</a></li>
{% url 'domain-dns-dnssec' pk=domain.id as url %}
{% url 'domain-dns-dnssec' domain_pk=domain.id as url %}
<li><a href="{{ url }}">DNSSEC</a></li>
{% if dns_prototype_flag and is_valid_domain %}
<li><a href="{% url 'prototype-domain-dns' pk=domain.id %}">Prototype DNS record creator</a></li>
<li><a href="{% url 'prototype-domain-dns' domain_pk=domain.id %}">Prototype DNS record creator</a></li>
{% endif %}
</ul>

View file

@ -14,10 +14,10 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
<a href="{% url 'domain-dns' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DNSSEC</span>
@ -69,7 +69,7 @@
<p class="margin-y-0">It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.</p>
</div>
</div>
<a href="{% url 'domain-dns-dnssec-dsdata' pk=domain.id %}" class="usa-button">Enable DNSSEC</a>
<a href="{% url 'domain-dns-dnssec-dsdata' domain_pk=domain.id %}" class="usa-button">Enable DNSSEC</a>
</div>
{% endif %}
</form>

View file

@ -18,13 +18,13 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
<a href="{% url 'domain-dns' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns-dnssec' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNSSEC</span></a>
<a href="{% url 'domain-dns-dnssec' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNSSEC</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DS data</span>

View file

@ -19,10 +19,10 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
<a href="{% url 'domain-dns' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DNS name servers</span>

View file

@ -24,7 +24,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{domain.name}}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{domain.name}}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Renewal Form</span>
@ -63,14 +63,14 @@
{% endif %}
{% endif %}
{% url 'domain-security-email' pk=domain.id as url %}
{% url 'domain-security-email' domain_pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%}
{% include "includes/summary_item.html" with title='Security email' value=security_email custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% else %}
{% include "includes/summary_item.html" with title='Security email' value='None provided' custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% endif %}
{% url 'domain-users' pk=domain.id as url %}
{% url 'domain-users' domain_pk=domain.id as url %}
{% if portfolio %}
{% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %}
{% else %}
@ -91,7 +91,7 @@
Acknowledgement of .gov domain requirements </h3>
</legend>
<form method="post" action="{% url 'domain-renewal' pk=domain.id %}">
<form method="post" action="{% url 'domain-renewal' domain_pk=domain.id %}">
{% csrf_token %}
<div class="usa-checkbox">

View file

@ -20,7 +20,7 @@
{% endwith %}
{% endfor %}
<button type="submit" name="submit_button" value="save" class="usa-button usa-button--with-icon usa-button--unstyled">
<button id="submit-domain-request--site-button" type="submit" name="submit_button" value="save" class="usa-button usa-button--with-icon usa-button--unstyled">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another site</span>

View file

@ -36,7 +36,7 @@
</ol>
</nav>
{% elif steps.prev %}
<a href="{% namespaced_url 'domain-request' steps.prev id=domain_request_id %}" class="breadcrumb__back">
<a href="{% namespaced_url 'domain-request' steps.prev domain_request_pk=domain_request_id %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use>
</svg><span class="margin-left-05">Previous step</span>

View file

@ -15,7 +15,7 @@
</svg>
{% endif %}
{% endif %}
<a href="{% namespaced_url 'domain-request' this_step id=domain_request_id %}"
<a href="{% namespaced_url 'domain-request' this_step domain_request_pk=domain_request_id %}"
{% if this_step == steps.current %}
class="usa-current"
{% else %}

View file

@ -17,8 +17,8 @@
<p>If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again. </p>
<p><a href="{% url 'domain-request-withdrawn' DomainRequest.id %}" class="usa-button withdraw">Withdraw request</a>
<a href="{% url 'domain-request-status' DomainRequest.id %}">Cancel</a></p>
<p><a href="{% url 'domain-request-withdrawn' domain_request_pk=DomainRequest.id %}" class="usa-button withdraw">Withdraw request</a>
<a href="{% url 'domain-request-status' domain_request_pk=DomainRequest.id %}">Cancel</a></p>
</div>
</div>

View file

@ -16,7 +16,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Security email</span>

View file

@ -17,14 +17,14 @@
{% endif %}
<li class="usa-sidenav__item">
{% url 'domain-dns' pk=domain.id as url %}
{% url 'domain-dns' domain_pk=domain.id as url %}
<a href="{{ url }}" {% if request.path|startswith:url %}class="usa-current"{% endif %}">
DNS
</a>
{% if request.path|startswith:url %}
<ul class="usa-sidenav__sublist">
<li class="usa-sidenav__item">
{% url 'domain-dns-nameservers' pk=domain.id as url %}
{% url 'domain-dns-nameservers' domain_pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
@ -33,7 +33,7 @@
</li>
<li class="usa-sidenav__item">
{% url 'domain-dns-dnssec' pk=domain.id as url %}
{% url 'domain-dns-dnssec' domain_pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path|startswith:url %}class="usa-current"{% endif %}
>
@ -43,7 +43,7 @@
{% if domain.dnssecdata is not None or request.path|startswith:url and request.path|endswith:'dsdata' %}
<ul class="usa-sidenav__sublist">
<li class="usa-sidenav__item">
{% url 'domain-dns-dnssec-dsdata' pk=domain.id as url %}
{% url 'domain-dns-dnssec-dsdata' domain_pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>

View file

@ -16,7 +16,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Suborganization</span>

View file

@ -13,7 +13,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
<a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Domain managers</span>
@ -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">
@ -93,7 +89,7 @@
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove myself" modal_button_class="usa-button--secondary" %}
{% endwith %}
</div>
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}" >
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" domain_pk=domain.id user_pk=item.permission.user.id %}" >
{% csrf_token %}
</form>
{% else %}
@ -108,7 +104,7 @@
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove domain manager" modal_button_class="usa-button--secondary" %}
{% endwith %}
</div>
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}">
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" domain_pk=domain.id user_pk=item.permission.user.id %}">
{% csrf_token %}
</form>
{% endif %}
@ -123,7 +119,7 @@
></div>
{% endif %}
<a class="usa-button usa-button--unstyled usa-button--with-icon" href="{% url 'domain-users-add' pk=domain.id %}">
<a class="usa-button usa-button--unstyled usa-button--with-icon" href="{% url 'domain-users-add' domain_pk=domain.id %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add a domain manager</span>
@ -154,7 +150,7 @@
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
<td>
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
<form method="POST" action="{% url "invitation-cancel" pk=invitation.domain_invitation.id %}">
<form method="POST" action="{% url "invitation-cancel" domain_invitation_pk=invitation.domain_invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
</form>
{% endif %}

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

@ -0,0 +1,35 @@
{% 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 %}
Your permissions were updated in the .gov registrar.
ORGANIZATION: {{ portfolio.organization_name }}
UPDATED BY: {{ requestor_email }}
UPDATED ON: {{ date }}
YOUR PERMISSIONS: {{ permissions.role_display }}
Domains - {{ permissions.domains_display }}
Domain requests - {{ permissions.domain_requests_display }}
Members - {{ permissions.members_display }}
Your updated permissions are now active in the .gov registrar <https://manage.get.gov>.
----------------------------------------------------------------
SOMETHING WRONG?
If you have questions or concerns, reach out to the person who updated your
permissions, or reply to this email.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov
domain.
----------------------------------------------------------------
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 @@
Your permissions were updated in the .gov registrar

View file

@ -68,10 +68,12 @@ Learn more about:
NEED ASSISTANCE?
If you have questions about this domain request or need help choosing a new domain name, reply to this email.
{% endif %}
{% if reason != domain_request.RejectionReasons.REQUESTOR_NOT_ELIGIBLE and reason != domain_request.RejectionReasons.ORG_NOT_ELIGIBLE %}
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
{% endif %}
----------------------------------------------------------------
The .gov team

View file

@ -1,6 +1,6 @@
<li class="usa-sidenav__item">
{% if url_name %}
{% url url_name pk=domain.id as url %}
{% url url_name domain_pk=domain.id as url %}
{% endif %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}

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

@ -45,7 +45,7 @@
<caption class="sr-only">member domains</caption>
<thead>
<tr>
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105 width-6"><span class="sr-only">Assigned domains</span></th>
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105 width-6 left-align-sort-button"><span class="sr-only">Assigned domains</span></th>
<!-- We override default sort to be name/ascending in the JSON endpoint. We add the correct aria-sort attribute here to reflect that in the UI -->
<th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th>
</tr>
@ -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

@ -1,33 +1,11 @@
<h4 class="margin-bottom-0">Member access</h4>
{% if permissions.roles and 'organization_admin' in permissions.roles %}
<p class="margin-top-0">Admin</p>
{% elif permissions.roles and 'organization_member' in permissions.roles %}
<p class="margin-top-0">Basic</p>
{% else %}
<p class="margin-top-0"></p>
{% endif %}
<p class="margin-top-0">{{ permissions.role_display }}</p>
<h4 class="margin-bottom-0 text-primary">Domains</h4>
{% if member_has_view_all_domains_portfolio_permission %}
<p class="margin-top-0">Viewer, all</p>
{% else %}
<p class="margin-top-0">Viewer, limited</p>
{% endif %}
<p class="margin-top-0">{{ permissions.domains_display }}: {{ permissions.domains_description_display }}</p>
<h4 class="margin-bottom-0 text-primary">Domain requests</h4>
{% if member_has_edit_request_portfolio_permission %}
<p class="margin-top-0">Creator</p>
{% elif member_has_view_all_requests_portfolio_permission %}
<p class="margin-top-0">Viewer</p>
{% else %}
<p class="margin-top-0">No access</p>
{% endif %}
<p class="margin-top-0">{{ permissions.domain_requests_display }}: {{ permissions.domain_requests_description_display }}</p>
<h4 class="margin-bottom-0 text-primary">Members</h4>
{% if member_has_edit_members_portfolio_permission %}
<p class="margin-top-0">Manager</p>
{% elif member_has_view_members_portfolio_permission %}
<p class="margin-top-0">Viewer</p>
{% else %}
<p class="margin-top-0">No access</p>
{% endif %}
<p class="margin-top-0">{{ permissions.members_display }}: {{ permissions.members_description_display }}</p>

View file

@ -4,7 +4,7 @@
{% for step in steps %}
<section class="summary-item margin-top-3">
{% if is_editable %}
{% namespaced_url 'domain-request' step id=domain_request_id as domain_request_url %}
{% namespaced_url 'domain-request' step domain_request_pk=domain_request_id as domain_request_url %}
{% endif %}
{% if step == Step.REQUESTING_ENTITY %}

View file

@ -4,7 +4,7 @@
{% for step in steps %}
<section class="summary-item margin-top-3">
{% if is_editable %}
{% namespaced_url 'domain-request' step id=domain_request_id as domain_request_url %}
{% namespaced_url 'domain-request' step domain_request_pk=domain_request_id as domain_request_url %}
{% endif %}
{% if step == Step.ORGANIZATION_TYPE %}

View file

@ -114,7 +114,7 @@
{% block modify_request %}
{% if DomainRequest.is_withdrawable %}
<p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
<p><a href="{% url 'domain-request-withdraw-confirmation' domain_request_pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a>
</p>
{% endif %}

View file

@ -134,18 +134,29 @@
{% endif %}
</div>
{% if editable and edit_link %}
{% 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 view_button %}visibility{% else %}edit{% endif %}"></use>
<use xlink:href="{% static 'img/sprite.svg' %}#{% if manage_button %}settings{% elif editable %}edit{% else %}visibility{% endif %}"></use>
</svg>
{% if manage_button %}Manage{% elif view_button %}View{% else %}Edit{% endif %}<span class="sr-only"> {{ title }}</span>
{% 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,20 +18,20 @@
{% endblock messages%}
<!-- Navigation breadcrumbs -->
<nav class="usa-breadcrumb padding-top-0 bg-gray-1" aria-label="Domain request breadcrumb">
{% 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>
@ -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
@ -251,15 +255,6 @@ def is_members_subpage(path):
return get_url_name(path) in url_names
@register.filter(name="portfolio_role_summary")
def portfolio_role_summary(user, portfolio):
"""Returns the value of user.portfolio_role_summary"""
if user and portfolio:
return user.portfolio_role_summary(portfolio)
else:
return []
@register.filter(name="display_requesting_entity")
def display_requesting_entity(domain_request):
"""Workaround for a newline issue in .txt files (our emails) as if statements

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
@ -3834,8 +3866,6 @@ class TestTransferUser(WebTest):
self.assertContains(after_submit, "<h1>Change user</h1>")
print(mock_success_message.call_args_list)
mock_success_message.assert_any_call(
ANY,
(

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,
)
@ -224,7 +224,7 @@ class TestDomainAdminAsStaff(MockEppLib):
self.assertEqual(domain.state, Domain.State.DELETED)
# @less_console_noise_decorator
@less_console_noise_decorator
def test_deletion_is_unsuccessful(self):
"""
Scenario: Domain deletion is unsuccessful
@ -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

@ -62,7 +62,7 @@ class GetSeniorOfficialJsonTest(TestCase):
p = "password"
self.client.login(username="testuser", password=p)
response = self.client.get(self.api_url, {"agency_name": "Test Agency"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
def test_get_senior_official_json_not_found(self):
@ -138,7 +138,7 @@ class GetPortfolioJsonTest(TestCase):
"""Test that an unauthenticated user receives a 403 with an error message."""
self.client.force_login(self.user)
response = self.client.get(self.api_url, {"id": self.portfolio.id})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
def test_get_portfolio_json_not_found(self):
@ -181,7 +181,7 @@ class GetFederalPortfolioTypeJsonTest(TestCase):
p = "password"
self.client.login(username="testuser", password=p)
response = self.client.get(self.api_url, {"agency_name": "Test Agency", "organization_type": "federal"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.status_code, 403)
class GetActionNeededEmailForUserJsonTest(TestCase):

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,9 @@ 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 api.tests.common import less_console_noise_decorator
@ -522,7 +526,6 @@ class PortfolioInvitationEmailTests(unittest.TestCase):
"registrar.utility.email_invitations._get_requestor_email",
side_effect=MissingEmailError("Requestor has no email"),
)
@less_console_noise_decorator
def test_send_portfolio_invitation_email_missing_requestor_email(self, mock_get_email):
"""Test when requestor has no email"""
is_admin_invitation = False
@ -888,3 +891,224 @@ class SendPortfolioAdminRemovalEmailsTests(unittest.TestCase):
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
mock_send_removal_emails.assert_called_once_with(self.email, self.requestor.email, self.portfolio)
self.assertFalse(result)
class TestSendPortfolioMemberPermissionUpdateEmail(unittest.TestCase):
"""Unit tests for send_portfolio_member_permission_update_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_update_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_update.txt",
"emails/portfolio_update_subject.txt",
to_address="user@example.com",
context={
"requested_user": permissions.user,
"portfolio": permissions.portfolio,
"requestor_email": "requestor@example.com",
"permissions": permissions,
"date": date.today(),
},
)
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_update_email(requestor, permissions)
# Assertions
mock_logger.warning.assert_called_once_with(
"Could not send email organization member update 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_update_email(requestor, permissions)
# 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

@ -38,7 +38,6 @@ from epplibwrapper import commands, common
from .common import (
MockEppLib,
less_console_noise,
completed_domain_request,
MockSESClient,
MockDbForIndividualTests,
@ -454,6 +453,7 @@ class TestPopulateFirstReady(TestCase):
# Delete domains
Domain.objects.all().delete()
@less_console_noise_decorator
def run_populate_first_ready(self):
"""
This method executes the populate_first_ready command.
@ -461,18 +461,17 @@ class TestPopulateFirstReady(TestCase):
The 'call_command' function from Django's management framework is then used to
execute the populate_first_ready command with the specified arguments.
"""
with less_console_noise():
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
return_value=True,
):
call_command("populate_first_ready")
@less_console_noise_decorator
def test_populate_first_ready_state_ready(self):
"""
Tests that the populate_first_ready works as expected for the state 'ready'
"""
with less_console_noise():
# Set the created at date
self.ready_domain.created_at = self.ready_at_date_tz_aware
self.ready_domain.save()
@ -485,11 +484,11 @@ class TestPopulateFirstReady(TestCase):
first_ready = Domain.objects.filter(name="fakeready.gov").get().first_ready
self.assertEqual(first_ready, self.ready_at_date)
@less_console_noise_decorator
def test_populate_first_ready_state_deleted(self):
"""
Tests that the populate_first_ready works as expected for the state 'deleted'
"""
with less_console_noise():
# Set the created at date
self.deleted_domain.created_at = self.ready_at_date_tz_aware
self.deleted_domain.save()
@ -502,11 +501,11 @@ class TestPopulateFirstReady(TestCase):
first_ready = Domain.objects.filter(name="fakedeleted.gov").get().first_ready
self.assertEqual(first_ready, self.ready_at_date)
@less_console_noise_decorator
def test_populate_first_ready_state_dns_needed(self):
"""
Tests that the populate_first_ready doesn't make changes when a domain's state is 'dns_needed'
"""
with less_console_noise():
# Set the created at date
self.dns_needed_domain.created_at = self.ready_at_date_tz_aware
self.dns_needed_domain.save()
@ -522,11 +521,11 @@ class TestPopulateFirstReady(TestCase):
self.assertNotEqual(first_ready, self.ready_at_date)
self.assertEqual(first_ready, None)
@less_console_noise_decorator
def test_populate_first_ready_state_on_hold(self):
"""
Tests that the populate_first_ready works as expected for the state 'on_hold'
"""
with less_console_noise():
self.hold_domain.created_at = self.ready_at_date_tz_aware
self.hold_domain.save()
desired_domain = copy.deepcopy(self.hold_domain)
@ -539,11 +538,11 @@ class TestPopulateFirstReady(TestCase):
first_ready = Domain.objects.filter(name="fakehold.gov").get().first_ready
self.assertEqual(first_ready, self.ready_at_date)
@less_console_noise_decorator
def test_populate_first_ready_state_unknown(self):
"""
Tests that the populate_first_ready works as expected for the state 'unknown'
"""
with less_console_noise():
# Set the created at date
self.unknown_domain.created_at = self.ready_at_date_tz_aware
self.unknown_domain.save()
@ -578,9 +577,9 @@ class TestPatchAgencyInfo(TestCase):
TransitionDomain.objects.all().delete()
@patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", return_value=True)
@less_console_noise_decorator
def call_patch_federal_agency_info(self, mock_prompt):
"""Calls the patch_federal_agency_info command and mimics a keypress"""
with less_console_noise():
call_command("patch_federal_agency_info", "registrar/tests/data/fake_current_full.csv", debug=True)
@ -637,6 +636,7 @@ class TestExtendExpirationDates(MockEppLib):
User.objects.all().delete()
UserDomainRole.objects.all().delete()
@less_console_noise_decorator
def run_extend_expiration_dates(self):
"""
This method executes the extend_expiration_dates command.
@ -644,18 +644,17 @@ class TestExtendExpirationDates(MockEppLib):
The 'call_command' function from Django's management framework is then used to
execute the extend_expiration_dates command with the specified arguments.
"""
with less_console_noise():
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
return_value=True,
):
call_command("extend_expiration_dates")
@less_console_noise_decorator
def test_extends_expiration_date_correctly(self):
"""
Tests that the extend_expiration_dates method extends dates as expected
"""
with less_console_noise():
desired_domain = Domain.objects.filter(name="waterbutpurple.gov").get()
desired_domain.expiration_date = date(2024, 11, 15)
# Run the expiration date script
@ -665,12 +664,12 @@ class TestExtendExpirationDates(MockEppLib):
# Explicitly test the expiration date
self.assertEqual(current_domain.expiration_date, date(2024, 11, 15))
@less_console_noise_decorator
def test_extends_expiration_date_skips_non_current(self):
"""
Tests that the extend_expiration_dates method correctly skips domains
with an expiration date less than a certain threshold.
"""
with less_console_noise():
desired_domain = Domain.objects.filter(name="fake.gov").get()
desired_domain.expiration_date = date(2022, 5, 25)
# Run the expiration date script
@ -682,12 +681,12 @@ class TestExtendExpirationDates(MockEppLib):
# should not be affected by the change.
self.assertEqual(current_domain.expiration_date, date(2022, 5, 25))
@less_console_noise_decorator
def test_extends_expiration_date_skips_maximum_date(self):
"""
Tests that the extend_expiration_dates method correctly skips domains
with an expiration date more than a certain threshold.
"""
with less_console_noise():
desired_domain = Domain.objects.filter(name="fakemaximum.gov").get()
desired_domain.expiration_date = date(2024, 12, 31)
@ -702,11 +701,11 @@ class TestExtendExpirationDates(MockEppLib):
# should not be affected by the change.
self.assertEqual(current_domain.expiration_date, date(2024, 12, 31))
@less_console_noise_decorator
def test_extends_expiration_date_skips_non_ready(self):
"""
Tests that the extend_expiration_dates method correctly skips domains not in the state "ready"
"""
with less_console_noise():
desired_domain = Domain.objects.filter(name="fakeneeded.gov").get()
desired_domain.expiration_date = date(2023, 11, 15)
@ -721,6 +720,7 @@ class TestExtendExpirationDates(MockEppLib):
# should not be affected by the change.
self.assertEqual(current_domain.expiration_date, date(2023, 11, 15))
@less_console_noise_decorator
def test_extends_expiration_date_idempotent(self):
"""
Tests the idempotency of the extend_expiration_dates command.
@ -728,7 +728,6 @@ class TestExtendExpirationDates(MockEppLib):
Verifies that running the method multiple times does not change the expiration date
of a domain beyond the initial extension.
"""
with less_console_noise():
desired_domain = Domain.objects.filter(name="waterbutpurple.gov").get()
desired_domain.expiration_date = date(2024, 11, 15)
# Run the expiration date script
@ -754,6 +753,7 @@ class TestDiscloseEmails(MockEppLib):
PublicContact.objects.all().delete()
Domain.objects.all().delete()
@less_console_noise_decorator
def run_disclose_security_emails(self):
"""
This method executes the disclose_security_emails command.
@ -761,19 +761,18 @@ class TestDiscloseEmails(MockEppLib):
The 'call_command' function from Django's management framework is then used to
execute the disclose_security_emails command.
"""
with less_console_noise():
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
return_value=True,
):
call_command("disclose_security_emails")
@less_console_noise_decorator
def test_disclose_security_emails(self):
"""
Tests that command disclose_security_emails runs successfully with
appropriate EPP calll to UpdateContact.
"""
with less_console_noise():
domain, _ = Domain.objects.get_or_create(name="testdisclose.gov", state=Domain.State.READY)
expectedSecContact = PublicContact.get_default_security()
expectedSecContact.domain = domain
@ -813,9 +812,9 @@ class TestCleanTables(TestCase):
self.logger_patcher.stop()
@override_settings(IS_PRODUCTION=True)
@less_console_noise_decorator
def test_command_logs_error_in_production(self):
"""Test that the handle method does not process in production"""
with less_console_noise():
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
return_value=True,
@ -824,6 +823,7 @@ class TestCleanTables(TestCase):
self.logger_mock.error.assert_called_with("clean_tables cannot be run in production")
@override_settings(IS_PRODUCTION=False)
@less_console_noise_decorator
def test_command_cleans_tables(self):
"""test that the handle method functions properly to clean tables"""
@ -891,9 +891,9 @@ class TestCleanTables(TestCase):
raise
@override_settings(IS_PRODUCTION=False)
@less_console_noise_decorator
def test_command_handles_nonexistent_model(self):
"""Test that exceptions for non existent models are handled properly within the handle method"""
with less_console_noise():
with patch("django.apps.apps.get_model", side_effect=LookupError):
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
@ -913,9 +913,9 @@ class TestCleanTables(TestCase):
self.logger_mock.error.assert_any_call("Model for table Host not found.")
@override_settings(IS_PRODUCTION=False)
@less_console_noise_decorator
def test_command_logs_other_exceptions(self):
"""Test that generic exceptions are handled properly in the handle method"""
with less_console_noise():
with patch("django.apps.apps.get_model") as get_model_mock:
model_mock = MagicMock()
get_model_mock.return_value = model_mock
@ -1030,9 +1030,9 @@ class TestExportTables(MockEppLib):
self.logger_mock.info.assert_any_call(f"Removed {table_name}_1.csv")
@patch("registrar.management.commands.export_tables.getattr")
@less_console_noise_decorator
def test_export_table_handles_missing_resource_class(self, mock_getattr):
"""Test that missing resource classes are handled properly in the handle method"""
with less_console_noise():
mock_getattr.side_effect = AttributeError
# Import the command to avoid any locale or gettext issues
@ -1045,9 +1045,9 @@ class TestExportTables(MockEppLib):
)
@patch("registrar.management.commands.export_tables.getattr")
@less_console_noise_decorator
def test_export_table_handles_generic_exception(self, mock_getattr):
"""Test that general exceptions in the handle method are handled correctly"""
with less_console_noise():
mock_resource_class = MagicMock()
mock_resource_class().export.side_effect = Exception("Test Exception")
mock_getattr.return_value = mock_resource_class
@ -1073,6 +1073,7 @@ class TestImportTables(TestCase):
@patch("registrar.management.commands.import_tables.getattr")
@patch("django.apps.apps.get_model")
@patch("os.listdir")
@less_console_noise_decorator
def test_handle(
self,
mock_listdir,
@ -1087,7 +1088,6 @@ class TestImportTables(TestCase):
mock_makedirs,
):
"""Test that the handle method properly imports tables"""
with less_console_noise():
# Mock os.makedirs to do nothing
mock_makedirs.return_value = None
@ -1173,9 +1173,9 @@ class TestImportTables(TestCase):
@patch("registrar.management.commands.import_tables.logger")
@patch("registrar.management.commands.import_tables.os.makedirs")
@patch("registrar.management.commands.import_tables.os.path.exists")
@less_console_noise_decorator
def test_handle_zip_file_not_found(self, mock_path_exists, mock_makedirs, mock_logger):
"""Test the handle method when the zip file doesn't exist"""
with less_console_noise():
# Mock os.makedirs to do nothing
mock_makedirs.return_value = None
@ -1255,6 +1255,7 @@ class TestTransferFederalAgencyType(TestCase):
id__in=[self.amtrak.id, self.legislative_branch.id, self.library_of_congress.id, self.gov_admin.id]
).delete()
@less_console_noise_decorator
def run_transfer_federal_agency_type(self):
"""
This method executes the transfer_federal_agency_type command.
@ -1262,7 +1263,6 @@ class TestTransferFederalAgencyType(TestCase):
The 'call_command' function from Django's management framework is then used to
execute the populate_first_ready command with the specified arguments.
"""
with less_console_noise():
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
return_value=True,
@ -1630,6 +1630,7 @@ class TestCreateFederalPortfolio(TestCase):
# Test the senior official
self.assertEqual(portfolio.senior_official, self.senior_official)
@less_console_noise_decorator
def test_create_multiple_portfolios_for_branch_judicial(self):
"""Tests creating all portfolios under a given branch"""
federal_choice = DomainRequest.OrganizationChoices.FEDERAL
@ -1657,6 +1658,7 @@ class TestCreateFederalPortfolio(TestCase):
self.assertTrue(all([creator == User.get_default_user() for creator in creators]))
self.assertTrue(all([note == "Auto-generated record" for note in notes]))
@less_console_noise_decorator
def test_create_multiple_portfolios_for_branch_legislative(self):
"""Tests creating all portfolios under a given branch"""
federal_choice = DomainRequest.OrganizationChoices.FEDERAL
@ -1684,6 +1686,7 @@ class TestCreateFederalPortfolio(TestCase):
self.assertTrue(all([creator == User.get_default_user() for creator in creators]))
self.assertTrue(all([note == "Auto-generated record" for note in notes]))
@less_console_noise_decorator
def test_script_adds_requested_suborganization_information(self):
"""Tests that the script adds the requested suborg fields for domain requests"""
# Create a new domain request with some errant spacing
@ -1712,6 +1715,7 @@ class TestCreateFederalPortfolio(TestCase):
custom_suborg_request.suborganization_state_territory, DomainRequest.StateTerritoryChoices.TEXAS
)
@less_console_noise_decorator
def test_create_multiple_portfolios_for_branch_executive(self):
"""Tests creating all portfolios under a given branch"""
federal_choice = DomainRequest.OrganizationChoices.FEDERAL
@ -1774,6 +1778,7 @@ class TestCreateFederalPortfolio(TestCase):
self.assertEqual(expected_requests.count(), 2)
self.assertEqual(expected_domain_infos.count(), 2)
@less_console_noise_decorator
def test_handle_portfolio_requests(self):
"""Verify portfolio association with domain requests."""
self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_requests=True)
@ -1783,6 +1788,7 @@ class TestCreateFederalPortfolio(TestCase):
self.assertEqual(self.domain_request.portfolio.federal_agency, self.federal_agency)
self.assertEqual(self.domain_request.sub_organization.name, "Testorg")
@less_console_noise_decorator
def test_handle_portfolio_domains(self):
"""Check portfolio association with domain information."""
self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_domains=True)
@ -1792,6 +1798,7 @@ class TestCreateFederalPortfolio(TestCase):
self.assertEqual(self.domain_info.portfolio.federal_agency, self.federal_agency)
self.assertEqual(self.domain_info.sub_organization.name, "Testorg")
@less_console_noise_decorator
def test_handle_parse_both(self):
"""Ensure correct parsing of both requests and domains."""
self.run_create_federal_portfolio(agency_name="Test Federal Agency", parse_requests=True, parse_domains=True)
@ -1802,6 +1809,7 @@ class TestCreateFederalPortfolio(TestCase):
self.assertIsNotNone(self.domain_info.portfolio)
self.assertEqual(self.domain_request.portfolio, self.domain_info.portfolio)
@less_console_noise_decorator
def test_command_error_parse_options(self):
"""Verify error when bad parse options are provided."""
# The command should enforce either --branch or --agency_name
@ -1823,6 +1831,7 @@ class TestCreateFederalPortfolio(TestCase):
):
self.run_create_federal_portfolio(agency_name="test")
@less_console_noise_decorator
def test_command_error_agency_not_found(self):
"""Check error handling for non-existent agency."""
expected_message = (
@ -1832,6 +1841,7 @@ class TestCreateFederalPortfolio(TestCase):
with self.assertRaisesRegex(CommandError, expected_message):
self.run_create_federal_portfolio(agency_name="Non-existent Agency", parse_requests=True)
@less_console_noise_decorator
def test_does_not_update_existing_portfolio(self):
"""Tests that an existing portfolio is not updated when"""
# Create an existing portfolio
@ -2433,6 +2443,7 @@ class TestRemovePortfolios(TestCase):
Portfolio.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator
@patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no")
def test_delete_unlisted_portfolios(self, mock_query_yes_no):
"""Test that portfolios not on the allowed list are deleted."""
@ -2450,6 +2461,7 @@ class TestRemovePortfolios(TestCase):
self.assertFalse(Portfolio.objects.filter(organization_name="Test with suborg").exists())
self.assertTrue(Portfolio.objects.filter(organization_name="Department of Veterans Affairs").exists())
@less_console_noise_decorator
@patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no")
def test_delete_entries_with_related_objects(self, mock_query_yes_no):
"""Test deletion with related objects being handled properly."""
@ -2473,6 +2485,7 @@ class TestRemovePortfolios(TestCase):
# Check that the portfolio was deleted
self.assertFalse(Portfolio.objects.filter(organization_name="Test with orphaned objects").exists())
@less_console_noise_decorator
@patch("registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no")
def test_delete_entries_with_suborganizations(self, mock_query_yes_no):
"""Test that suborganizations and their related objects are deleted along with the portfolio."""

View file

@ -1191,67 +1191,6 @@ class TestUser(TestCase):
User.objects.all().delete()
UserDomainRole.objects.all().delete()
@patch.object(User, "has_edit_portfolio_permission", return_value=True)
def test_portfolio_role_summary_admin(self, mock_edit_org):
# Test if the user is recognized as an Admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
@patch.multiple(
User,
has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
has_any_requests_portfolio_permission=lambda self, portfolio: True,
has_edit_request_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self):
# Test if the user has both 'View-only admin' and 'Domain requestor' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin", "Domain requestor"])
@patch.multiple(
User,
has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
has_any_requests_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_view_only_admin(self):
# Test if the user is recognized as a View-only admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin"])
@patch.multiple(
User,
has_view_portfolio_permission=lambda self, portfolio: True,
has_edit_request_portfolio_permission=lambda self, portfolio: True,
has_any_domains_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_requestor_domain_manager(self):
# Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"])
@patch.multiple(
User,
has_view_portfolio_permission=lambda self, portfolio: True,
has_edit_request_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_requestor(self):
# Test if the user has 'Member' and 'Domain requestor' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor"])
@patch.multiple(
User,
has_view_portfolio_permission=lambda self, portfolio: True,
has_any_domains_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_manager(self):
# Test if the user has 'Member' and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
@patch.multiple(User, has_view_portfolio_permission=lambda self, portfolio: True)
def test_portfolio_role_summary_member(self):
# Test if the user is recognized as a Member
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
def test_portfolio_role_summary_empty(self):
# Test if the user has no roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
@patch("registrar.models.User._has_portfolio_permission")
def test_has_view_portfolio_permission(self, mock_has_permission):
mock_has_permission.return_value = True
@ -2100,7 +2039,7 @@ class TestDomainRequestIncomplete(TestCase):
self.wizard = DomainRequestWizard()
self.wizard._domain_request = self.domain_request
self.wizard.request = Mock(user=self.user, session={})
self.wizard.kwargs = {"id": self.domain_request.id}
self.wizard.kwargs = {"domain_request_pk": self.domain_request.id}
# We use both of these flags in the test. In the normal app these are generated normally.
# The alternative syntax is adding the decorator to each test.

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,9 +98,10 @@ 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
@ -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,8 +1818,7 @@ 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"
@ -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`
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.
This test now asserts only the commands that are actually issued
during the deletion process.
"""
# Put the domain in client hold
# Put the domain in client hold.
self.domain_with_contacts.place_client_hold()
# Delete it
# 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()
# Check that the host and contacts are deleted
# 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

@ -1,4 +1,5 @@
import io
from unittest import skip
from django.test import Client, RequestFactory
from io import StringIO
from registrar.models import (
@ -819,6 +820,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
super().setUp()
self.factory = RequestFactory()
@skip("flaky test that needs to be refactored")
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@less_console_noise_decorator

View file

@ -18,6 +18,9 @@ from .common import less_console_noise
# request on the view.
SAMPLE_KWARGS = {
"app_label": "registrar",
"domain_pk": "1",
"domain_request_pk": "1",
"domain_invitation_pk": "1",
"pk": "1",
"id": "1",
"content_type_id": "2",
@ -82,7 +85,6 @@ def iter_sample_urls(urlconf):
if not viewname:
continue
if viewname == "auth_user_password_change":
print(route)
break
named_groups = route.regex.groupindex.keys()
kwargs = {}

View file

@ -126,7 +126,7 @@ class TestEnvironmentVariablesEffects(TestCase):
with patch.object(DomainNameserversView, "get_initial", side_effect=self.side_effect_raise_value_error):
with self.assertRaises(ValueError):
contact_page_500 = self.client.get(
reverse("domain-dns-nameservers", kwargs={"pk": fake_domain.id}),
reverse("domain-dns-nameservers", kwargs={"domain_pk": fake_domain.id}),
)
# Check that a 500 response is returned
@ -147,7 +147,7 @@ class TestEnvironmentVariablesEffects(TestCase):
with patch.object(DomainNameserversView, "get_initial", side_effect=self.side_effect_raise_value_error):
with self.assertRaises(ValueError):
contact_page_500 = self.client.get(
reverse("domain-dns-nameservers", kwargs={"pk": fake_domain.id}),
reverse("domain-dns-nameservers", kwargs={"domain_pk": fake_domain.id}),
)
# Check that a 500 response is returned
@ -292,7 +292,9 @@ class HomeTests(TestWithUser):
)
# Trigger the delete logic
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True)
response = self.client.post(
reverse("domain-request-delete", kwargs={"domain_request_pk": domain_request.pk}), follow=True
)
self.assertNotContains(response, "igorville.gov")
@ -309,7 +311,9 @@ class HomeTests(TestWithUser):
)
# Trigger the delete logic
response = self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True)
response = self.client.post(
reverse("domain-request-delete", kwargs={"domain_request_pk": domain_request.pk}), follow=True
)
self.assertNotContains(response, "igorville.gov")
@ -335,7 +339,8 @@ class HomeTests(TestWithUser):
# Trigger the delete logic
response = self.client.post(
reverse("domain-request-delete", kwargs={"pk": domain_request.pk}), follow=True
reverse("domain-request-delete", kwargs={"domain_request_pk": domain_request.pk}),
follow=True,
)
# Check for a 403 error - the end user should not be allowed to do this
@ -392,7 +397,7 @@ class HomeTests(TestWithUser):
self.assertTrue(igorville.exists())
# Trigger the delete logic
self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request.pk}))
self.client.post(reverse("domain-request-delete", kwargs={"domain_request_pk": domain_request.pk}))
# igorville is now deleted
igorville = DomainRequest.objects.filter(requested_domain__name="igorville.gov")
@ -462,7 +467,7 @@ class HomeTests(TestWithUser):
self.assertTrue(teaville.exists())
# Trigger the delete logic
self.client.post(reverse("domain-request-delete", kwargs={"pk": domain_request_2.pk}))
self.client.post(reverse("domain-request-delete", kwargs={"domain_request_pk": domain_request_2.pk}))
teaville = DomainRequest.objects.filter(requested_domain__name="teaville.gov")
self.assertFalse(teaville.exists())
@ -935,7 +940,7 @@ class UserProfileTests(TestWithUser, WebTest):
@less_console_noise_decorator
def test_domain_detail_contains_your_profile(self):
"""Tests that the domain detail view contains 'your profile' rather than 'your contact information'"""
response = self.client.get(reverse("domain", args=[self.domain.pk]))
response = self.client.get(reverse("domain", kwargs={"domain_pk": self.domain.pk}))
self.assertContains(response, "Your profile")
self.assertNotContains(response, "Your contact information")

View file

@ -175,7 +175,7 @@ class TestDomainPermissions(TestWithDomainPermissions):
"domain-security-email",
]:
with self.subTest(view_name=view_name):
response = self.client.get(reverse(view_name, kwargs={"pk": self.domain.id}))
response = self.client.get(reverse(view_name, kwargs={"domain_pk": self.domain.id}))
self.assertEqual(response.status_code, 302)
@less_console_noise_decorator
@ -194,7 +194,7 @@ class TestDomainPermissions(TestWithDomainPermissions):
"domain-security-email",
]:
with self.subTest(view_name=view_name):
response = self.client.get(reverse(view_name, kwargs={"pk": self.domain.id}))
response = self.client.get(reverse(view_name, kwargs={"domain_pk": self.domain.id}))
self.assertEqual(response.status_code, 403)
@less_console_noise_decorator
@ -218,7 +218,7 @@ class TestDomainPermissions(TestWithDomainPermissions):
self.domain_deleted,
]:
with self.subTest(view_name=view_name, domain=domain):
response = self.client.get(reverse(view_name, kwargs={"pk": domain.id}))
response = self.client.get(reverse(view_name, kwargs={"domain_pk": domain.id}))
self.assertEqual(response.status_code, 403)
@ -271,20 +271,20 @@ class TestDomainDetail(TestDomainOverview):
with less_console_noise():
self.user.status = User.RESTRICTED
self.user.save()
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
response = self.client.get(reverse("domain", kwargs={"domain_pk": self.domain.id}))
self.assertEqual(response.status_code, 403)
def test_domain_detail_allowed_for_on_hold(self):
"""Test that the domain overview page displays for on hold domain"""
with less_console_noise():
# View domain overview page
detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain_on_hold.id}))
detail_page = self.client.get(reverse("domain", kwargs={"domain_pk": self.domain_on_hold.id}))
self.assertNotContains(detail_page, "Edit")
def test_domain_detail_see_just_nameserver(self):
with less_console_noise():
# View nameserver on Domain Overview page
detail_page = self.app.get(reverse("domain", kwargs={"pk": self.domain_just_nameserver.id}))
detail_page = self.app.get(reverse("domain", kwargs={"domain_pk": self.domain_just_nameserver.id}))
self.assertContains(detail_page, "justnameserver.com")
self.assertContains(detail_page, "ns1.justnameserver.com")
@ -293,7 +293,7 @@ class TestDomainDetail(TestDomainOverview):
def test_domain_detail_see_nameserver_and_ip(self):
with less_console_noise():
# View nameserver on Domain Overview page
detail_page = self.app.get(reverse("domain", kwargs={"pk": self.domain_with_ip.id}))
detail_page = self.app.get(reverse("domain", kwargs={"domain_pk": self.domain_with_ip.id}))
self.assertContains(detail_page, "nameserverwithip.gov")
@ -321,7 +321,7 @@ class TestDomainDetail(TestDomainOverview):
session["analyst_action_location"] = self.domain_no_information.id
session.save()
detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain_no_information.id}))
detail_page = self.client.get(reverse("domain", kwargs={"domain_pk": self.domain_no_information.id}))
self.assertContains(detail_page, "noinformation.gov")
self.assertContains(detail_page, "Domain missing domain information")
@ -341,7 +341,7 @@ class TestDomainDetail(TestDomainOverview):
session["analyst_action_location"] = self.domain.id
session.save()
detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
detail_page = self.client.get(reverse("domain", kwargs={"domain_pk": self.domain.id}))
self.assertNotContains(
detail_page, "If you need to make updates, contact one of the listed domain managers."
@ -486,7 +486,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
):
self.assertEquals(self.domain_to_renew.state, Domain.State.UNKNOWN)
detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.domain_to_renew.id}),
reverse("domain", kwargs={"domain_pk": self.domain_to_renew.id}),
)
self.assertContains(detail_page, "Expiring soon")
@ -528,7 +528,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
Domain, "is_expired", self.custom_is_expired_false
):
detail_page = self.client.get(
reverse("domain", kwargs={"pk": domain_to_renew2.id}),
reverse("domain", kwargs={"domain_pk": domain_to_renew2.id}),
)
self.assertContains(detail_page, "Contact one of the listed domain managers to renew the domain.")
@ -548,7 +548,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
Domain, "is_expired", self.custom_is_expired_false
):
detail_page = self.client.get(
reverse("domain", kwargs={"pk": domain_to_renew3.id}),
reverse("domain", kwargs={"domain_pk": domain_to_renew3.id}),
)
self.assertContains(detail_page, "Renew to maintain access")
@ -561,7 +561,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
):
# Grab the detail page
detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.domain_to_renew.id}),
reverse("domain", kwargs={"domain_pk": self.domain_to_renew.id}),
)
# Make sure we see the link as a domain manager
@ -571,7 +571,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertContains(detail_page, "Renewal form")
# Grab link to the renewal page
renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domain_to_renew.id})
renewal_form_url = reverse("domain-renewal", kwargs={"domain_pk": self.domain_to_renew.id})
self.assertContains(detail_page, f'href="{renewal_form_url}"')
# Simulate clicking the link
@ -590,7 +590,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
):
# Grab the detail page
detail_page = self.client.get(
reverse("domain", kwargs={"pk": self.domain_to_renew.id}),
reverse("domain", kwargs={"domain_pk": self.domain_to_renew.id}),
)
# Make sure we see the link as a domain manager
@ -600,7 +600,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertContains(detail_page, "Renewal form")
# Grab link to the renewal page
renewal_form_url = reverse("domain-renewal", kwargs={"pk": self.domain_to_renew.id})
renewal_form_url = reverse("domain-renewal", kwargs={"domain_pk": self.domain_to_renew.id})
self.assertContains(detail_page, f'href="{renewal_form_url}"')
# Simulate clicking the link
@ -614,7 +614,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
Your Profile portion of the Renewal Form."""
with less_console_noise():
# Start on the Renewal page for the domain
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}))
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"domain_pk": self.domain_with_ip.id}))
# Verify we see "Your contact information" on the renewal form
self.assertContains(renewal_page, "Your contact information")
@ -633,7 +633,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
Security Email portion of the Renewal Form."""
with less_console_noise():
# Start on the Renewal page for the domain
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}))
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"domain_pk": self.domain_with_ip.id}))
# Verify we see "Security email" on the renewal form
self.assertContains(renewal_page, "Security email")
@ -642,7 +642,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
self.assertContains(renewal_page, "We strongly recommend that you provide a security email.")
# Verify that the "Edit" button for Security email is there and links to correct URL
edit_button_url = reverse("domain-security-email", kwargs={"pk": self.domain_with_ip.id})
edit_button_url = reverse("domain-security-email", kwargs={"domain_pk": self.domain_with_ip.id})
self.assertContains(renewal_page, f'href="{edit_button_url}"')
# Simulate clicking on edit button
@ -655,26 +655,26 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
Domain Manager portion of the Renewal Form."""
with less_console_noise():
# Start on the Renewal page for the domain
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id}))
renewal_page = self.app.get(reverse("domain-renewal", kwargs={"domain_pk": self.domain_with_ip.id}))
# Verify we see "Domain managers" on the renewal form
self.assertContains(renewal_page, "Domain managers")
# Verify that the "Edit" button for Domain managers is there and links to correct URL
edit_button_url = reverse("domain-users", kwargs={"pk": self.domain_with_ip.id})
edit_button_url = reverse("domain-users", kwargs={"domain_pk": self.domain_with_ip.id})
self.assertContains(renewal_page, f'href="{edit_button_url}"')
# 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
to access /renewal and that it should receive a 403."""
with less_console_noise():
# Start on the Renewal page for the domain
renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_not_expiring.id}))
renewal_page = self.client.get(reverse("domain-renewal", kwargs={"domain_pk": self.domain_not_expiring.id}))
self.assertEqual(renewal_page.status_code, 403)
def test_domain_renewal_form_does_not_appear_if_not_domain_manager(self):
@ -682,13 +682,15 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
with patch.object(Domain, "is_expired", self.custom_is_expired_true), patch.object(
Domain, "is_expired", self.custom_is_expired_true
):
renewal_page = self.client.get(reverse("domain-renewal", kwargs={"pk": self.domain_no_domain_manager.id}))
renewal_page = self.client.get(
reverse("domain-renewal", kwargs={"domain_pk": self.domain_no_domain_manager.id})
)
self.assertEqual(renewal_page.status_code, 403)
def test_ack_checkbox_not_checked(self):
"""If user don't check the checkbox, user should receive an error message."""
# Grab the renewal URL
renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})
renewal_url = reverse("domain-renewal", kwargs={"domain_pk": self.domain_with_ip.id})
# Test that the checkbox is not checked
response = self.client.post(renewal_url, data={"submit_button": "next"})
@ -701,17 +703,19 @@ class TestDomainDetailDomainRenewal(TestDomainOverview):
user should be redirected Domain Over page with an updated by 1 year expiration date"""
# Grab the renewal URL
with patch.object(Domain, "renew_domain", self.custom_renew_domain):
renewal_url = reverse("domain-renewal", kwargs={"pk": self.domain_with_ip.id})
renewal_url = reverse("domain-renewal", kwargs={"domain_pk": self.domain_with_ip.id})
# Click the check, and submit
response = self.client.post(renewal_url, data={"is_policy_acknowledged": "on", "submit_button": "next"})
# Check that it redirects after a successfully submits
self.assertRedirects(response, reverse("domain", kwargs={"pk": self.domain_with_ip.id}))
self.assertRedirects(response, reverse("domain", kwargs={"domain_pk": self.domain_with_ip.id}))
# Check for the updated expiration
formatted_new_expiration_date = self.expiration_date_one_year_out().strftime("%b. %-d, %Y")
redirect_response = self.client.get(reverse("domain", kwargs={"pk": self.domain_with_ip.id}), follow=True)
redirect_response = self.client.get(
reverse("domain", kwargs={"domain_pk": self.domain_with_ip.id}), follow=True
)
self.assertContains(redirect_response, formatted_new_expiration_date)
@ -754,28 +758,28 @@ class TestDomainManagers(TestDomainOverview):
@less_console_noise_decorator
def test_domain_managers(self):
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
response = self.client.get(reverse("domain-users", kwargs={"domain_pk": self.domain.id}))
self.assertContains(response, "Domain managers")
self.assertContains(response, "Add a domain manager")
# 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)
def test_domain_managers_portfolio_view(self):
response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id}))
response = self.client.get(reverse("domain-users", kwargs={"domain_pk": self.domain.id}))
self.assertContains(response, "Domain managers")
self.assertContains(response, "Add a domain manager")
# 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):
response = self.client.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
response = self.client.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
self.assertContains(response, "Add a domain manager")
@less_console_noise_decorator
@ -784,7 +788,7 @@ class TestDomainManagers(TestDomainOverview):
"""Adding an existing user works."""
get_user_model().objects.get_or_create(email="mayor@igorville.gov")
user = User.objects.filter(email="mayor@igorville.gov").first()
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "mayor@igorville.gov"
@ -804,7 +808,7 @@ class TestDomainManagers(TestDomainOverview):
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
reverse("domain-users", kwargs={"domain_pk": self.domain.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -820,7 +824,7 @@ class TestDomainManagers(TestDomainOverview):
"""Adding an existing user works and sends portfolio invitation when
user is not member of portfolio."""
get_user_model().objects.get_or_create(email="mayor@igorville.gov")
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "mayor@igorville.gov"
@ -832,7 +836,7 @@ class TestDomainManagers(TestDomainOverview):
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
reverse("domain-users", kwargs={"domain_pk": self.domain.id}),
)
# Verify that the invitation emails were sent
@ -877,7 +881,7 @@ class TestDomainManagers(TestDomainOverview):
self, mock_send_domain_email, mock_send_portfolio_email
):
"""Adding an email not associated with a user works and sends portfolio invitation."""
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "notauser@igorville.gov"
@ -889,7 +893,7 @@ class TestDomainManagers(TestDomainOverview):
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
reverse("domain-users", kwargs={"domain_pk": self.domain.id}),
)
# Verify that the invitation emails were sent
@ -928,7 +932,7 @@ class TestDomainManagers(TestDomainOverview):
):
"""Adding an email not associated with a user works and sends portfolio invitation,
and when domain managers email(s) fail to send, assert proper warning displayed."""
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "notauser@igorville.gov"
@ -942,7 +946,7 @@ class TestDomainManagers(TestDomainOverview):
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
reverse("domain-users", kwargs={"domain_pk": self.domain.id}),
)
# Verify that the invitation emails were sent
@ -967,7 +971,7 @@ class TestDomainManagers(TestDomainOverview):
UserPortfolioPermission.objects.get_or_create(
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "mayor@igorville.gov"
@ -979,7 +983,7 @@ class TestDomainManagers(TestDomainOverview):
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
reverse("domain-users", kwargs={"domain_pk": self.domain.id}),
)
# Verify that the invitation emails were sent
@ -1015,7 +1019,7 @@ class TestDomainManagers(TestDomainOverview):
user is not member of portfolio and send raises an error."""
mock_send_portfolio_email.side_effect = EmailSendingError("Failed to send email.")
get_user_model().objects.get_or_create(email="mayor@igorville.gov")
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "mayor@igorville.gov"
@ -1027,7 +1031,7 @@ class TestDomainManagers(TestDomainOverview):
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
reverse("domain-users", kwargs={"domain_pk": self.domain.id}),
)
# Verify that the invitation emails were sent
@ -1058,7 +1062,9 @@ class TestDomainManagers(TestDomainOverview):
"""Removing a domain manager sends notification email to other domain managers."""
self.manager, _ = User.objects.get_or_create(email="mayor@igorville.com", first_name="Hello", last_name="World")
self.manager_domain_permission, _ = UserDomainRole.objects.get_or_create(user=self.manager, domain=self.domain)
self.client.post(reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.manager.id}))
self.client.post(
reverse("domain-user-delete", kwargs={"domain_pk": self.domain.id, "user_pk": self.manager.id})
)
# Verify that the notification emails were sent to domain manager
mock_send_templated_email.assert_called_once_with(
@ -1082,7 +1088,7 @@ class TestDomainManagers(TestDomainOverview):
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1115,7 +1121,7 @@ class TestDomainManagers(TestDomainOverview):
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = caps_email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1147,7 +1153,7 @@ class TestDomainManagers(TestDomainOverview):
mock_client = MagicMock()
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1175,7 +1181,7 @@ class TestDomainManagers(TestDomainOverview):
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1214,7 +1220,7 @@ class TestDomainManagers(TestDomainOverview):
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1258,7 +1264,7 @@ class TestDomainManagers(TestDomainOverview):
mock_client_instance = mock_client.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client):
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1289,7 +1295,7 @@ class TestDomainManagers(TestDomainOverview):
email_address = "mayor"
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1313,7 +1319,7 @@ class TestDomainManagers(TestDomainOverview):
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
with patch("django.contrib.messages.error") as mock_error:
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -1332,7 +1338,7 @@ class TestDomainManagers(TestDomainOverview):
"""Posting to the delete view deletes an invitation."""
email_address = "mayor@igorville.gov"
invitation, _ = DomainInvitation.objects.get_or_create(domain=self.domain, email=email_address)
self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
self.client.post(reverse("invitation-cancel", kwargs={"domain_invitation_pk": invitation.id}))
invitation = DomainInvitation.objects.get(id=invitation.id)
self.assertEqual(invitation.status, DomainInvitation.DomainInvitationStatus.CANCELED)
@ -1343,7 +1349,9 @@ class TestDomainManagers(TestDomainOverview):
invitation, _ = DomainInvitation.objects.get_or_create(
domain=self.domain, email=email_address, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
)
response = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}), follow=True)
response = self.client.post(
reverse("invitation-cancel", kwargs={"domain_invitation_pk": invitation.id}), follow=True
)
# Assert that an error message is displayed to the user
self.assertContains(response, f"Invitation to {email_address} has already been retrieved.")
# Assert that the Cancel link (form) is not displayed
@ -1363,7 +1371,7 @@ class TestDomainManagers(TestDomainOverview):
self.client.force_login(other_user)
mock_client = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
result = self.client.post(reverse("invitation-cancel", kwargs={"pk": invitation.id}))
result = self.client.post(reverse("invitation-cancel", kwargs={"domain_invitation_pk": invitation.id}))
self.assertEqual(result.status_code, 403)
@ -1380,7 +1388,7 @@ class TestDomainManagers(TestDomainOverview):
title = "title"
User.objects.filter(email=email_address).delete()
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
add_page = self.app.get(reverse("domain-users-add", kwargs={"domain_pk": self.domain.id}))
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
@ -1418,7 +1426,7 @@ class TestDomainManagers(TestDomainOverview):
)
UserDomainRole.objects.create(user=new_user_2, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": new_user.id}), follow=True
reverse("domain-user-delete", kwargs={"domain_pk": self.domain.id, "user_pk": new_user.id}), follow=True
)
# Assert that a success message is displayed to the user
self.assertContains(response, f"Removed {email_address} as a manager for this domain.")
@ -1432,7 +1440,7 @@ class TestDomainManagers(TestDomainOverview):
"""Posting to the delete view attempts to delete a user domain role when there is only one manager."""
# self.user is the only domain manager, so attempt to delete it
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}), follow=True
reverse("domain-user-delete", kwargs={"domain_pk": self.domain.id, "user_pk": self.user.id}), follow=True
)
# Assert that an error message is displayed to the user
self.assertContains(response, "Domains must have at least one domain manager.")
@ -1449,7 +1457,7 @@ class TestDomainManagers(TestDomainOverview):
new_user = User.objects.create(email=email_address, username="mayor")
UserDomainRole.objects.create(user=new_user, domain=self.domain, role=UserDomainRole.Roles.MANAGER)
response = self.client.post(
reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}), follow=True
reverse("domain-user-delete", kwargs={"domain_pk": self.domain.id, "user_pk": self.user.id}), follow=True
)
# Assert that a success message is displayed to the user
self.assertContains(response, f"You are no longer managing the domain {self.domain}.")
@ -1461,7 +1469,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
@less_console_noise_decorator
def test_domain_nameservers(self):
"""Can load domain's nameservers page."""
page = self.client.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
page = self.client.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
self.assertContains(page, "DNS name servers")
@less_console_noise_decorator
@ -1471,7 +1479,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
Uses self.app WebTest because we need to interact with forms.
"""
# initial nameservers page has one server with two ips
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# attempt to submit the form with only one nameserver, should error
@ -1494,7 +1502,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
Uses self.app WebTest because we need to interact with forms.
"""
# initial nameservers page has one server with two ips
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# attempt to submit the form without two hosts, both subdomains,
@ -1519,7 +1527,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
Uses self.app WebTest because we need to interact with forms.
"""
# initial nameservers page has one server with two ips
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# attempt to submit the form without two hosts, both subdomains,
@ -1543,7 +1551,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
Uses self.app WebTest because we need to interact with forms.
"""
# initial nameservers page has one server with two ips
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# attempt to submit the form with duplicate host names of fake.host.com
@ -1571,7 +1579,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
valid_ip = "1.1. 1.1"
valid_ip_2 = "2.2. 2.2"
# have to throw an error in order to test that the whitespace has been stripped from ip
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# attempt to submit the form without one host and an ip with whitespace
@ -1585,7 +1593,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}),
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
page = result.follow()
@ -1604,7 +1612,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
nameserver2 = "ns2.igorville.com"
valid_ip = "127.0.0.1"
# initial nameservers page has one server with two ips
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# attempt to submit the form without two hosts, both subdomains,
@ -1632,7 +1640,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
nameserver = "ns2.igorville.gov"
invalid_ip = "123"
# initial nameservers page has one server with two ips
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# attempt to submit the form without two hosts, both subdomains,
@ -1659,7 +1667,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
nameserver = "invalid-nameserver.gov"
valid_ip = "123.2.45.111"
# initial nameservers page has one server with two ips
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# attempt to submit the form without two hosts, both subdomains,
@ -1687,7 +1695,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
nameserver2 = "ns2.igorville.gov"
valid_ip = "127.0.0.1"
valid_ip_2 = "128.0.0.2"
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
nameservers_page.form["form-0-server"] = nameserver1
@ -1699,7 +1707,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}),
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
page = result.follow()
@ -1719,7 +1727,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
valid_ip = ""
valid_ip_2 = "128.0.0.2"
valid_ip_3 = "128.0.0.3"
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
nameservers_page.form["form-0-server"] = nameserver1
@ -1734,7 +1742,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}),
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
nameservers_page = result.follow()
@ -1760,7 +1768,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}),
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
nameservers_page = result.follow()
@ -1785,7 +1793,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
valid_ip_3 = ""
valid_ip_4 = ""
nameservers_page = self.app.get(
reverse("domain-dns-nameservers", kwargs={"pk": self.domain_with_four_nameservers.id})
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_four_nameservers.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
@ -1809,7 +1817,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-nameservers", kwargs={"pk": self.domain_with_four_nameservers.id}),
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_with_four_nameservers.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
nameservers_page = result.follow()
@ -1821,7 +1829,7 @@ class TestDomainNameservers(TestDomainOverview, MockEppLib):
Uses self.app WebTest because we need to interact with forms.
"""
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain.id}))
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# first two nameservers are required, so if we empty one out we should
@ -1843,7 +1851,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
@less_console_noise_decorator
def test_domain_senior_official(self):
"""Can load domain's senior official page."""
page = self.client.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
page = self.client.get(reverse("domain-senior-official", kwargs={"domain_pk": self.domain.id}))
self.assertContains(page, "Senior official", count=4)
@less_console_noise_decorator
@ -1852,7 +1860,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
self.domain_information.senior_official = Contact(first_name="Testy")
self.domain_information.senior_official.save()
self.domain_information.save()
page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
page = self.app.get(reverse("domain-senior-official", kwargs={"domain_pk": self.domain.id}))
self.assertContains(page, "Testy")
@less_console_noise_decorator
@ -1864,7 +1872,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
)
self.domain_information.senior_official.save()
self.domain_information.save()
so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
so_page = self.app.get(reverse("domain-senior-official", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_form = so_page.forms[0]
@ -1922,7 +1930,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
self.domain_information.senior_official.save()
self.domain_information.save()
so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
so_page = self.app.get(reverse("domain-senior-official", kwargs={"domain_pk": self.domain.id}))
self.assertContains(so_page, "Apple Tester")
self.assertContains(so_page, "CIO")
self.assertContains(so_page, "nobody@igorville.gov")
@ -1943,7 +1951,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
self.domain_information.senior_official.save()
self.domain_information.save()
so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
so_page = self.app.get(reverse("domain-senior-official", kwargs={"domain_pk": self.domain.id}))
self.assertContains(so_page, "Apple Tester")
self.assertContains(so_page, "CIO")
self.assertContains(so_page, "nobody@igorville.gov")
@ -1962,7 +1970,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
self.domain_information.other_contacts.add(self.domain_information.senior_official)
self.domain_information.save()
# load the Senior Official in the web form
so_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
so_page = self.app.get(reverse("domain-senior-official", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
so_form = so_page.forms[0]
@ -1990,7 +1998,7 @@ class TestDomainOrganization(TestDomainOverview):
@less_console_noise_decorator
def test_domain_org_name_address(self):
"""Can load domain's org name and mailing address page."""
page = self.client.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
page = self.client.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
# once on the sidebar, once in the page title, once as H1
self.assertContains(page, "/org-name-address")
self.assertContains(page, "Organization name and mailing address")
@ -2001,7 +2009,7 @@ class TestDomainOrganization(TestDomainOverview):
"""Org name and address information appears on the page."""
self.domain_information.organization_name = "Town of Igorville"
self.domain_information.save()
page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
self.assertContains(page, "Town of Igorville")
@less_console_noise_decorator
@ -2009,7 +2017,7 @@ class TestDomainOrganization(TestDomainOverview):
"""Submitting changes works on the org name address page."""
self.domain_information.organization_name = "Town of Igorville"
self.domain_information.save()
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["organization_name"] = "Not igorville"
@ -2041,7 +2049,7 @@ class TestDomainOrganization(TestDomainOverview):
self.assertEqual(self.domain_information.generic_org_type, tribal_org_type)
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
form = org_name_page.forms[0]
# Check the value of the input field
@ -2098,7 +2106,7 @@ class TestDomainOrganization(TestDomainOverview):
self.assertEqual(self.domain_information.generic_org_type, fed_org_type)
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
form = org_name_page.forms[0]
# Check the value of the input field
@ -2160,7 +2168,7 @@ class TestDomainOrganization(TestDomainOverview):
new_value = ("Department of State", "Department of State")
self.client.post(
reverse("domain-org-name-address", kwargs={"pk": self.domain.id}),
reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}),
{
"federal_agency": new_value,
},
@ -2202,7 +2210,7 @@ class TestDomainSuborganization(TestDomainOverview):
self.assertEqual(self.domain_information.sub_organization, suborg)
# Navigate to the suborganization page
page = self.app.get(reverse("domain-suborganization", kwargs={"pk": self.domain.id}))
page = self.app.get(reverse("domain-suborganization", kwargs={"domain_pk": self.domain.id}))
# The page should contain the choices Vanilla and Chocolate
self.assertContains(page, "Vanilla")
@ -2260,7 +2268,7 @@ class TestDomainSuborganization(TestDomainOverview):
self.assertEqual(self.domain_information.sub_organization, suborg)
# Navigate to the suborganization page
page = self.app.get(reverse("domain-suborganization", kwargs={"pk": self.domain.id}))
page = self.app.get(reverse("domain-suborganization", kwargs={"domain_pk": self.domain.id}))
# The page should display the readonly option
self.assertContains(page, "Vanilla")
@ -2299,7 +2307,7 @@ class TestDomainSuborganization(TestDomainOverview):
self.user.refresh_from_db()
# Navigate to the domain overview page
page = self.app.get(reverse("domain", kwargs={"pk": self.domain.id}))
page = self.app.get(reverse("domain", kwargs={"domain_pk": self.domain.id}))
# Test for the title change
self.assertContains(page, "Suborganization")
@ -2328,7 +2336,7 @@ class TestDomainSecurityEmail(TestDomainOverview):
domain_contact, _ = Domain.objects.get_or_create(name="freeman.gov")
# Add current user to this domain
_ = UserDomainRole(user=self.user, domain=domain_contact, role="admin").save()
page = self.client.get(reverse("domain-security-email", kwargs={"pk": domain_contact.id}))
page = self.client.get(reverse("domain-security-email", kwargs={"domain_pk": domain_contact.id}))
# Loads correctly
self.assertContains(page, "Security email")
@ -2343,7 +2351,7 @@ class TestDomainSecurityEmail(TestDomainOverview):
self.mockedSendFunction = self.mockSendPatch.start()
self.mockedSendFunction.side_effect = self.mockSend
page = self.client.get(reverse("domain-security-email", kwargs={"pk": self.domain.id}))
page = self.client.get(reverse("domain-security-email", kwargs={"domain_pk": self.domain.id}))
# Loads correctly
self.assertContains(page, "Security email")
@ -2353,7 +2361,7 @@ class TestDomainSecurityEmail(TestDomainOverview):
def test_domain_security_email(self):
"""Can load domain's security email page."""
with less_console_noise():
page = self.client.get(reverse("domain-security-email", kwargs={"pk": self.domain.id}))
page = self.client.get(reverse("domain-security-email", kwargs={"domain_pk": self.domain.id}))
self.assertContains(page, "Security email")
def test_domain_security_email_form(self):
@ -2361,7 +2369,7 @@ class TestDomainSecurityEmail(TestDomainOverview):
Uses self.app WebTest because we need to interact with forms.
"""
with less_console_noise():
security_email_page = self.app.get(reverse("domain-security-email", kwargs={"pk": self.domain.id}))
security_email_page = self.app.get(reverse("domain-security-email", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
security_email_page.form["security_email"] = "mayor@igorville.gov"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -2372,7 +2380,7 @@ class TestDomainSecurityEmail(TestDomainOverview):
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-security-email", kwargs={"pk": self.domain.id}),
reverse("domain-security-email", kwargs={"domain_pk": self.domain.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -2415,7 +2423,7 @@ class TestDomainSecurityEmail(TestDomainOverview):
]
for test_name, data, expected_message in test_cases:
response = self.client.post(
reverse("domain-security-email", kwargs={"pk": self.domain.id}),
reverse("domain-security-email", kwargs={"domain_pk": self.domain.id}),
data=data,
follow=True,
)
@ -2443,7 +2451,7 @@ class TestDomainSecurityEmail(TestDomainOverview):
management pages share the same permissions class"""
self.user.status = User.RESTRICTED
self.user.save()
response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
response = self.client.get(reverse("domain", kwargs={"domain_pk": self.domain.id}))
self.assertEqual(response.status_code, 403)
@ -2455,7 +2463,7 @@ class TestDomainDNSSEC(TestDomainOverview):
"""DNSSEC overview page loads when domain has no DNSSEC data
and shows a 'Enable DNSSEC' button."""
page = self.client.get(reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id}))
page = self.client.get(reverse("domain-dns-dnssec", kwargs={"domain_pk": self.domain.id}))
self.assertContains(page, "Enable DNSSEC")
@less_console_noise_decorator
@ -2463,7 +2471,7 @@ class TestDomainDNSSEC(TestDomainOverview):
"""DNSSEC overview page loads when domain has DNSSEC data
and the template contains a button to disable DNSSEC."""
page = self.client.get(reverse("domain-dns-dnssec", kwargs={"pk": self.domain_multdsdata.id}))
page = self.client.get(reverse("domain-dns-dnssec", kwargs={"domain_pk": self.domain_multdsdata.id}))
self.assertContains(page, "Disable DNSSEC")
# Prepare the data for the POST request
@ -2471,7 +2479,7 @@ class TestDomainDNSSEC(TestDomainOverview):
"disable_dnssec": "Disable DNSSEC",
}
updated_page = self.client.post(
reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id}),
reverse("domain-dns-dnssec", kwargs={"domain_pk": self.domain.id}),
post_data,
follow=True,
)
@ -2485,7 +2493,7 @@ class TestDomainDNSSEC(TestDomainOverview):
"""DNSSEC Add DS data page loads when there is no
domain DNSSEC data and shows a button to Add new record"""
page = self.client.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dnssec_none.id}))
page = self.client.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dnssec_none.id}))
self.assertContains(page, "You have no DS data added")
self.assertContains(page, "Add new record")
@ -2494,13 +2502,13 @@ class TestDomainDNSSEC(TestDomainOverview):
"""DNSSEC Add DS data page loads when there is
domain DNSSEC DS data and shows the data"""
page = self.client.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}))
page = self.client.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
self.assertContains(page, "DS data record 1")
@less_console_noise_decorator
def test_ds_data_form_modal(self):
"""When user clicks on save, a modal pops up."""
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}))
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
# Assert that a hidden trigger for the modal does not exist.
# This hidden trigger will pop on the page when certain condition are met:
# 1) Initial form contained DS data, 2) All data is deleted and form is
@ -2509,7 +2517,7 @@ class TestDomainDNSSEC(TestDomainOverview):
# Simulate a delete all data
form_data = {}
response = self.client.post(
reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}),
reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}),
data=form_data,
)
self.assertEqual(response.status_code, 200) # Adjust status code as needed
@ -2522,7 +2530,7 @@ class TestDomainDNSSEC(TestDomainOverview):
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}))
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
result = add_data_page.forms[0].submit()
@ -2530,7 +2538,7 @@ class TestDomainDNSSEC(TestDomainOverview):
self.assertEqual(result.status_code, 302)
self.assertEqual(
result["Location"],
reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}),
reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}),
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
page = result.follow()
@ -2542,7 +2550,7 @@ class TestDomainDNSSEC(TestDomainOverview):
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}))
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# all four form fields are required, so will test with each blank
@ -2565,7 +2573,7 @@ class TestDomainDNSSEC(TestDomainOverview):
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}))
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# first two nameservers are required, so if we empty one out we should
@ -2588,7 +2596,7 @@ class TestDomainDNSSEC(TestDomainOverview):
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}))
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# first two nameservers are required, so if we empty one out we should
@ -2611,7 +2619,7 @@ class TestDomainDNSSEC(TestDomainOverview):
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}))
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# first two nameservers are required, so if we empty one out we should
@ -2634,7 +2642,7 @@ class TestDomainDNSSEC(TestDomainOverview):
Uses self.app WebTest because we need to interact with forms.
"""
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain_dsdata.id}))
add_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain_dsdata.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# first two nameservers are required, so if we empty one out we should
@ -2688,7 +2696,7 @@ class TestDomainChangeNotifications(TestDomainOverview):
self.domain_information.zipcode = "62052"
self.domain_information.save()
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["organization_name"] = "Not igorville"
@ -2729,7 +2737,7 @@ class TestDomainChangeNotifications(TestDomainOverview):
self.domain_information.portfolio = portfolio
self.domain_information.save()
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
org_name_page.form["organization_name"] = "Not igorville"
@ -2756,7 +2764,7 @@ class TestDomainChangeNotifications(TestDomainOverview):
self.domain_information.portfolio = portfolio
self.domain_information.save()
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"pk": self.domain.id}))
org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
session = self.app.session
@ -2778,7 +2786,7 @@ class TestDomainChangeNotifications(TestDomainOverview):
def test_notification_on_security_email_change(self):
"""Test that an email is sent when the security email is changed."""
security_email_page = self.app.get(reverse("domain-security-email", kwargs={"pk": self.domain.id}))
security_email_page = self.app.get(reverse("domain-security-email", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
security_email_page.form["security_email"] = "new_security@example.com"
@ -2801,7 +2809,7 @@ class TestDomainChangeNotifications(TestDomainOverview):
def test_notification_on_dnssec_enable(self):
"""Test that an email is sent when DNSSEC is enabled."""
page = self.client.get(reverse("domain-dns-dnssec", kwargs={"pk": self.domain_multdsdata.id}))
page = self.client.get(reverse("domain-dns-dnssec", kwargs={"domain_pk": self.domain_multdsdata.id}))
self.assertContains(page, "Disable DNSSEC")
# Prepare the data for the POST request
@ -2811,7 +2819,7 @@ class TestDomainChangeNotifications(TestDomainOverview):
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
updated_page = self.client.post(
reverse("domain-dns-dnssec", kwargs={"pk": self.domain.id}),
reverse("domain-dns-dnssec", kwargs={"domain_pk": self.domain.id}),
post_data,
follow=True,
)
@ -2834,7 +2842,7 @@ class TestDomainChangeNotifications(TestDomainOverview):
def test_notification_on_ds_data_change(self):
"""Test that an email is sent when DS data is changed."""
ds_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"pk": self.domain.id}))
ds_data_page = self.app.get(reverse("domain-dns-dnssec-dsdata", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
# Add DS data
@ -2868,7 +2876,7 @@ class TestDomainChangeNotifications(TestDomainOverview):
)
self.domain_information.save()
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
senior_official_page.form["first_name"] = "New"
@ -2905,7 +2913,7 @@ class TestDomainChangeNotifications(TestDomainOverview):
self.domain_information.portfolio = portfolio
self.domain_information.save()
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"domain_pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
senior_official_page.form["first_name"] = "New"
@ -2924,7 +2932,9 @@ class TestDomainChangeNotifications(TestDomainOverview):
def test_no_notification_when_dns_needed(self):
"""Test that an email is not sent when nameservers are changed while the state is DNS_NEEDED."""
nameservers_page = self.app.get(reverse("domain-dns-nameservers", kwargs={"pk": self.domain_dns_needed.id}))
nameservers_page = self.app.get(
reverse("domain-dns-nameservers", kwargs={"domain_pk": self.domain_dns_needed.id})
)
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
# add nameservers

View file

@ -104,7 +104,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertEqual(expected_domain.state_display(), state_displays[i])
self.assertEqual(expected_domain.get_state_help_text(), get_state_help_texts[i])
self.assertEqual(reverse("domain", kwargs={"pk": expected_domain.id}), action_urls[i])
self.assertEqual(reverse("domain", kwargs={"domain_pk": expected_domain.id}), action_urls[i])
# Check action_label
action_label_expected = (
@ -185,7 +185,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertEqual(expected_domain.state_display(), state_displays[i])
self.assertEqual(expected_domain.get_state_help_text(), get_state_help_texts[i])
self.assertEqual(reverse("domain", kwargs={"pk": expected_domain.id}), action_urls[i])
self.assertEqual(reverse("domain", kwargs={"domain_pk": expected_domain.id}), action_urls[i])
# Check action_label
user_domain_role_exists = UserDomainRole.objects.filter(
@ -272,7 +272,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertEqual(expected_domain.state_display(), state_displays[i])
self.assertEqual(expected_domain.get_state_help_text(), get_state_help_texts[i])
self.assertEqual(reverse("domain", kwargs={"pk": expected_domain.id}), action_urls[i])
self.assertEqual(reverse("domain", kwargs={"domain_pk": expected_domain.id}), action_urls[i])
# Check action_label
user_domain_role_exists = UserDomainRole.objects.filter(

Some files were not shown because too many files have changed in this diff Show more