mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-02 07:52:15 +02:00
merge main, fix permissions in tests, fix returned permissions in serialize_members
This commit is contained in:
commit
85f2958e36
31 changed files with 2407 additions and 1270 deletions
2009
src/Pipfile.lock
generated
2009
src/Pipfile.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -25,7 +25,7 @@ services:
|
|||
# Run Django in debug mode on local
|
||||
- DJANGO_DEBUG=True
|
||||
# Set DJANGO_LOG_LEVEL in env
|
||||
- DJANGO_LOG_LEVEL
|
||||
- DJANGO_LOG_LEVEL=DEBUG
|
||||
# Run Django without production flags
|
||||
- IS_PRODUCTION=False
|
||||
# Tell Django where it is being hosted
|
||||
|
|
|
@ -1611,8 +1611,9 @@ class DomainRequestsTable extends LoadTableBase {
|
|||
const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
|
||||
|
||||
// The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page)
|
||||
// Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed
|
||||
let modalTrigger = '';
|
||||
// If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user
|
||||
let modalTrigger = `
|
||||
<span class="usa-sr-only">Domain request cannot be deleted now. Edit the request for more information.</span>`;
|
||||
|
||||
let markupCreatorRow = '';
|
||||
|
||||
|
@ -1624,8 +1625,8 @@ class DomainRequestsTable extends LoadTableBase {
|
|||
`
|
||||
}
|
||||
|
||||
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
|
||||
if (request.is_deletable) {
|
||||
// If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
|
||||
let modalHeading = '';
|
||||
let modalDescription = '';
|
||||
|
||||
|
@ -1872,6 +1873,197 @@ class MembersTable extends LoadTableBase {
|
|||
constructor() {
|
||||
super('members');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes "Show More" buttons on the page, enabling toggle functionality to show or hide content.
|
||||
*
|
||||
* The function finds elements with "Show More" buttons and sets up a click event listener to toggle the visibility
|
||||
* of a corresponding content div. When clicked, the button updates its visual state (e.g., text/icon change),
|
||||
* and the associated content is shown or hidden based on its current visibility status.
|
||||
*
|
||||
* @function initShowMoreButtons
|
||||
*/
|
||||
initShowMoreButtons() {
|
||||
/**
|
||||
* Toggles the visibility of a content section when the "Show More" button is clicked.
|
||||
* Updates the button text/icon based on whether the content is shown or hidden.
|
||||
*
|
||||
* @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) {
|
||||
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.setAttribute('aria-label', 'Close additional information');
|
||||
} else {
|
||||
hideElement(contentDiv);
|
||||
spanElement.textContent = 'Expand';
|
||||
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
|
||||
buttonParentRow.classList.remove('hide-td-borders');
|
||||
toggleButton.setAttribute('aria-label', 'Expand for additional information');
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
toggleButton.addEventListener('click', function() {
|
||||
toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow);
|
||||
});
|
||||
} else {
|
||||
console.warn('Found a toggle button with no associated toggleable content or parent row');
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a given `last_active` value into a display value and a numeric sort value.
|
||||
* The input can be a UTC date, the strings "Invited", "Invalid date", or null/undefined.
|
||||
*
|
||||
* @param {string} last_active - UTC date string or special status like "Invited" or "Invalid date".
|
||||
* @returns {Object} - An object containing `display_value` (formatted date or status string)
|
||||
* and `sort_value` (numeric value for sorting).
|
||||
*/
|
||||
handleLastActive(last_active) {
|
||||
const invited = 'Invited';
|
||||
const invalid_date = 'Invalid date';
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' }; // Date display format
|
||||
|
||||
let display_value = invalid_date; // Default display value for invalid or null dates
|
||||
let sort_value = -1; // Default sort value for invalid or null dates
|
||||
|
||||
if (last_active === invited) {
|
||||
// Handle "Invited" status: special case with 0 sort value
|
||||
display_value = invited;
|
||||
sort_value = 0;
|
||||
} else if (last_active && last_active !== invalid_date) {
|
||||
// Parse and format valid UTC date strings
|
||||
const parsedDate = new Date(last_active);
|
||||
|
||||
if (!isNaN(parsedDate.getTime())) {
|
||||
// Valid date
|
||||
display_value = parsedDate.toLocaleDateString('en-US', options);
|
||||
sort_value = parsedDate.getTime(); // Use timestamp for sorting
|
||||
} else {
|
||||
console.error(`Error: Invalid date string provided: ${last_active}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { display_value, sort_value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates HTML for the list of domains assigned to a member.
|
||||
*
|
||||
* @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.
|
||||
* @returns {string} - A string of HTML displaying the domains assigned to the member.
|
||||
*/
|
||||
generateDomainsHTML(num_domains, domain_names, domain_urls) {
|
||||
// Initialize an empty string for the HTML
|
||||
let domainsHTML = '';
|
||||
|
||||
// Only generate HTML if the member has one or more assigned domains
|
||||
if (num_domains > 0) {
|
||||
domainsHTML += "<div class='desktop:grid-col-5 margin-bottom-2 desktop:margin-bottom-0'>";
|
||||
domainsHTML += "<h4 class='margin-y-0 text-primary'>Domains assigned</h4>";
|
||||
domainsHTML += `<p class='margin-y-0'>This member is assigned to ${num_domains} domains:</p>`;
|
||||
domainsHTML += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
|
||||
|
||||
// Display up to 6 domains with their URLs
|
||||
for (let i = 0; i < num_domains && i < 6; i++) {
|
||||
domainsHTML += `<li><a href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
|
||||
}
|
||||
|
||||
domainsHTML += "</ul>";
|
||||
|
||||
// If there are more than 6 domains, display a "View assigned domains" link
|
||||
if (num_domains >= 6) {
|
||||
domainsHTML += "<p><a href='#'>View assigned domains</a></p>";
|
||||
}
|
||||
|
||||
domainsHTML += "</div>";
|
||||
}
|
||||
|
||||
return domainsHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an HTML string summarizing a user's additional permissions within a portfolio,
|
||||
* based on the user's permissions and predefined permission choices.
|
||||
*
|
||||
* @param {Array} member_permissions - An array of permission strings that the member has.
|
||||
* @param {Object} UserPortfolioPermissionChoices - An object containing predefined permission choice constants.
|
||||
* Expected keys include:
|
||||
* - VIEW_ALL_DOMAINS
|
||||
* - VIEW_MANAGED_DOMAINS
|
||||
* - EDIT_REQUESTS
|
||||
* - VIEW_ALL_REQUESTS
|
||||
* - EDIT_MEMBERS
|
||||
* - VIEW_MEMBERS
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* Behavior:
|
||||
* - The function checks the user's permissions (`member_permissions`) and generates
|
||||
* corresponding HTML sections based on the permission choices defined in `UserPortfolioPermissionChoices`.
|
||||
* - Permissions are categorized into domains, requests, and members:
|
||||
* - Domains: Determines whether the user can view or manage all or assigned domains.
|
||||
* - Requests: Differentiates between users who can edit requests, view all requests, or have no request privileges.
|
||||
* - Members: Distinguishes between members who can manage or only view other members.
|
||||
* - 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 = '';
|
||||
|
||||
// Check domain-related permissions
|
||||
if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><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>";
|
||||
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domains:</strong> Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>";
|
||||
}
|
||||
|
||||
// Check request-related permissions
|
||||
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Domain requests:</strong> Can view all organization domain requests. Can create domain requests and modify their own requests.</p>";
|
||||
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><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>";
|
||||
}
|
||||
|
||||
// Check member-related permissions
|
||||
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members:</strong> Can manage members including inviting new members, removing current members, and assigning domains to members.</p>";
|
||||
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>";
|
||||
}
|
||||
|
||||
// If no specific permissions are assigned, display a message indicating no additional permissions
|
||||
if (!permissionsHTML) {
|
||||
permissionsHTML += "<p class='margin-top-1 p--blockquote'><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='margin-y-0 text-primary'>Additional permissions for this member</h4>" + permissionsHTML + "</div>";
|
||||
|
||||
return permissionsHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads rows in the members list, as well as updates pagination around the members list
|
||||
* based on the supplied attributes.
|
||||
|
@ -1925,39 +2117,20 @@ class MembersTable extends LoadTableBase {
|
|||
const memberList = document.querySelector('#members tbody');
|
||||
memberList.innerHTML = '';
|
||||
|
||||
const UserPortfolioPermissionChoices = data.UserPortfolioPermissionChoices;
|
||||
const invited = 'Invited';
|
||||
const invalid_date = 'Invalid date';
|
||||
|
||||
data.members.forEach(member => {
|
||||
const member_id = member.source + member.id;
|
||||
const member_name = member.name;
|
||||
const member_display = member.member_display;
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
const member_permissions = member.permissions;
|
||||
const domain_urls = member.domain_urls;
|
||||
const domain_names = member.domain_names;
|
||||
const num_domains = domain_urls.length;
|
||||
|
||||
// Handle last_active values
|
||||
let last_active = member.last_active;
|
||||
let last_active_formatted = '';
|
||||
let last_active_sort_value = '';
|
||||
|
||||
// Handle 'Invited' or null/empty values differently from valid dates
|
||||
if (last_active && last_active !== invited) {
|
||||
try {
|
||||
// Try to parse the last_active as a valid date
|
||||
last_active = new Date(last_active);
|
||||
if (!isNaN(last_active)) {
|
||||
last_active_formatted = last_active.toLocaleDateString('en-US', options);
|
||||
last_active_sort_value = last_active.getTime(); // For sorting purposes
|
||||
} else {
|
||||
last_active_formatted='Invalid date'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error parsing date: ${last_active}. Error: ${e}`);
|
||||
last_active_formatted='Invalid date'
|
||||
}
|
||||
} else {
|
||||
// Handle 'Invited' or null
|
||||
last_active = invited;
|
||||
last_active_formatted = invited;
|
||||
last_active_sort_value = invited; // Keep 'Invited' as a sortable string
|
||||
}
|
||||
const last_active = this.handleLastActive(member.last_active);
|
||||
|
||||
const action_url = member.action_url;
|
||||
const action_label = member.action_label;
|
||||
|
@ -1969,14 +2142,42 @@ class MembersTable extends LoadTableBase {
|
|||
if (member.is_admin)
|
||||
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary">Admin</span>`
|
||||
|
||||
// generate html blocks for domains and permissions for the member
|
||||
let domainsHTML = this.generateDomainsHTML(num_domains, domain_names, domain_urls);
|
||||
let permissionsHTML = this.generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices);
|
||||
|
||||
// domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand
|
||||
let showMoreButton = '';
|
||||
const showMoreRow = document.createElement('tr');
|
||||
if (domainsHTML || permissionsHTML) {
|
||||
showMoreButton = `
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button--show-more-button usa-button usa-button--unstyled display-block margin-top-1"
|
||||
data-for=${member_id}
|
||||
aria-label="Expand for additional information"
|
||||
>
|
||||
<span>Expand</span>
|
||||
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
showMoreRow.innerHTML = `<td colspan='3' headers="header-member row-header-${member_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.id = member_id;
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<th scope="row" role="rowheader" data-label="member email">
|
||||
${member_display} ${admin_tagHTML}
|
||||
<th role="rowheader" headers="header-member" data-label="member email" id='row-header-${member_id}'>
|
||||
${member_display} ${admin_tagHTML} ${showMoreButton}
|
||||
</th>
|
||||
<td data-sort-value="${last_active_sort_value}" data-label="last_active">
|
||||
${last_active_formatted}
|
||||
<td headers="header-last-active row-header-${member_id}" data-sort-value="${last_active.sort_value}" data-label="last_active">
|
||||
${last_active.display_value}
|
||||
</td>
|
||||
<td>
|
||||
<td headers="header-action row-header-${member_id}">
|
||||
<a href="${action_url}">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#${svg_icon}"></use>
|
||||
|
@ -1986,8 +2187,13 @@ class MembersTable extends LoadTableBase {
|
|||
</td>
|
||||
`;
|
||||
memberList.appendChild(row);
|
||||
if (domainsHTML || permissionsHTML) {
|
||||
memberList.appendChild(showMoreRow);
|
||||
}
|
||||
});
|
||||
|
||||
this.initShowMoreButtons();
|
||||
|
||||
// Do not scroll on first page load
|
||||
if (scroll)
|
||||
ScrollToElement('class', 'members');
|
||||
|
|
|
@ -5508,6 +5508,8 @@ const SORT_BUTTON = `.${SORT_BUTTON_CLASS}`;
|
|||
const SORTABLE_HEADER = `th[data-sortable]`;
|
||||
const ANNOUNCEMENT_REGION = `.${PREFIX}-table__announcement-region[aria-live="polite"]`;
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
|
||||
/** Gets the data-sort-value attribute value, if provided — otherwise, gets
|
||||
* the innerText or textContent — of the child element (HTMLTableCellElement)
|
||||
* at the specified index of the given table row
|
||||
|
@ -5516,7 +5518,19 @@ const ANNOUNCEMENT_REGION = `.${PREFIX}-table__announcement-region[aria-live="po
|
|||
* @param {array<HTMLTableRowElement>} tr
|
||||
* @return {boolean}
|
||||
*/
|
||||
const getCellValue = (tr, index) => tr.children[index].getAttribute(SORT_OVERRIDE) || tr.children[index].innerText || tr.children[index].textContent;
|
||||
const getCellValue = (tr, index) => {
|
||||
if (tr.children[index])
|
||||
return tr.children[index].getAttribute(SORT_OVERRIDE) || tr.children[index].innerText || tr.children[index].textContent;
|
||||
return "";
|
||||
}
|
||||
|
||||
// const getCellValue = (tr, index) => tr.children[index].getAttribute(SORT_OVERRIDE) || tr.children[index].innerText || tr.children[index].textContent;
|
||||
// DOTGOV: added check for tr.children[index] to protect from absent cells
|
||||
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Compares the values of two row array items at the given index, then sorts by the given direction
|
||||
|
@ -5528,7 +5542,6 @@ const compareFunction = (index, isAscending) => (thisRow, nextRow) => {
|
|||
// get values to compare from data attribute or cell content
|
||||
const value1 = getCellValue(isAscending ? thisRow : nextRow, index);
|
||||
const value2 = getCellValue(isAscending ? nextRow : thisRow, index);
|
||||
|
||||
// if neither value is empty, and if both values are already numbers, compare numerically
|
||||
if (value1 && value2 && !Number.isNaN(Number(value1)) && !Number.isNaN(Number(value2))) {
|
||||
return value1 - value2;
|
||||
|
@ -5603,7 +5616,16 @@ const sortRows = (header, isAscending) => {
|
|||
const thisHeaderIndex = allHeaders.indexOf(header);
|
||||
allRows.sort(compareFunction(thisHeaderIndex, !isAscending)).forEach(tr => {
|
||||
[].slice.call(tr.children).forEach(td => td.removeAttribute("data-sort-active"));
|
||||
tr.children[thisHeaderIndex].setAttribute("data-sort-active", true);
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
|
||||
// tr.children[thisHeaderIndex].setAttribute("data-sort-active", true);
|
||||
if (tr.children[thisHeaderIndex])
|
||||
tr.children[thisHeaderIndex].setAttribute("data-sort-active", true);
|
||||
// DOTGOV added conditional to protect from tr.children[thisHeaderIndex] being absent
|
||||
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
return true;
|
||||
|
|
|
@ -898,3 +898,10 @@ ul.add-list-reset {
|
|||
font-weight: 600;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.change-form .usa-table {
|
||||
td {
|
||||
color: inherit !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -258,3 +258,11 @@ a.text-secondary,
|
|||
a.text-secondary:hover {
|
||||
color: $theme-color-error;
|
||||
}
|
||||
|
||||
.usa-button--show-more-button {
|
||||
font-size: size('ui', 'xs');
|
||||
text-decoration: none;
|
||||
.usa-icon {
|
||||
top: 6px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,8 +56,10 @@ th {
|
|||
border: none;
|
||||
}
|
||||
|
||||
td, th {
|
||||
border-bottom: 1px solid color('base-lighter');
|
||||
tr:not(.hide-td-borders) {
|
||||
td, th {
|
||||
border-bottom: 1px solid color('base-lighter');
|
||||
}
|
||||
}
|
||||
|
||||
thead th {
|
||||
|
|
|
@ -28,3 +28,8 @@ h2 {
|
|||
.usa-form fieldset {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.p--blockquote {
|
||||
padding-left: units(1);
|
||||
border-left: 2px solid color('base-lighter');
|
||||
}
|
||||
|
|
|
@ -476,8 +476,10 @@ class JsonServerFormatter(ServerFormatter):
|
|||
|
||||
def format(self, record):
|
||||
formatted_record = super().format(record)
|
||||
|
||||
if not hasattr(record, "server_time"):
|
||||
record.server_time = self.formatTime(record, self.datefmt)
|
||||
|
||||
log_entry = {"server_time": record.server_time, "level": record.levelname, "message": formatted_record}
|
||||
return json.dumps(log_entry)
|
||||
|
||||
|
|
|
@ -137,6 +137,20 @@ class UserFixture:
|
|||
"email": "annagingle@truss.works",
|
||||
"title": "Sweetwater sailor",
|
||||
},
|
||||
{
|
||||
"username": "63688d43-82c6-480c-8e49-8a1bfdd33b9f",
|
||||
"first_name": "Elizabeth",
|
||||
"last_name": "Liao",
|
||||
"email": "elizabeth.liao@cisa.dhs.gov",
|
||||
"title": "Software Engineer",
|
||||
},
|
||||
{
|
||||
"username": "c9c64cd5-bc76-45ef-85cd-4f6eefa9e998",
|
||||
"first_name": "Samiyah",
|
||||
"last_name": "Key",
|
||||
"email": "skey@truss.works",
|
||||
"title": "Designer",
|
||||
},
|
||||
]
|
||||
|
||||
STAFF = [
|
||||
|
@ -231,6 +245,18 @@ class UserFixture:
|
|||
"last_name": "Gingle-Analyst",
|
||||
"email": "annagingle+analyst@truss.works",
|
||||
},
|
||||
{
|
||||
"username": "0c27b05d-0aa3-45fa-91bd-83ee307708df",
|
||||
"first_name": "Elizabeth-Analyst",
|
||||
"last_name": "Liao-Analyst",
|
||||
"email": "elizabeth.liao@gwe.cisa.dhs.gov",
|
||||
},
|
||||
{
|
||||
"username": "ee1e68da-41a5-47f7-949b-d8a4e9e2b9d2",
|
||||
"first_name": "Samiyah-Analyst",
|
||||
"last_name": "Key-Analyst",
|
||||
"email": "skey+1@truss.works",
|
||||
},
|
||||
]
|
||||
|
||||
# Additional emails to add to the AllowedEmail whitelist.
|
||||
|
|
|
@ -34,6 +34,7 @@ class OrganizationTypeForm(RegistrarForm):
|
|||
choices=DomainRequest.OrganizationChoicesVerbose.choices,
|
||||
widget=forms.RadioSelect,
|
||||
error_messages={"required": "Select the type of organization you represent."},
|
||||
label="What kind of U.S.-based government organization do you represent?",
|
||||
)
|
||||
|
||||
|
||||
|
@ -77,6 +78,7 @@ class OrganizationFederalForm(RegistrarForm):
|
|||
federal_type = forms.ChoiceField(
|
||||
choices=BranchChoices.choices,
|
||||
widget=forms.RadioSelect,
|
||||
label="Which federal branch is your organization in?",
|
||||
error_messages={"required": ("Select the part of the federal government your organization is in.")},
|
||||
)
|
||||
|
||||
|
@ -88,7 +90,8 @@ class OrganizationElectionForm(RegistrarForm):
|
|||
(True, "Yes"),
|
||||
(False, "No"),
|
||||
],
|
||||
)
|
||||
),
|
||||
label="Is your organization an election office?",
|
||||
)
|
||||
|
||||
def clean_is_election_board(self):
|
||||
|
@ -450,6 +453,7 @@ class OtherContactsForm(RegistrarForm):
|
|||
message="Response must be less than 320 characters.",
|
||||
)
|
||||
],
|
||||
help_text="Enter an email address in the required format, like name@example.com.",
|
||||
)
|
||||
phone = PhoneNumberField(
|
||||
label="Phone",
|
||||
|
|
|
@ -831,7 +831,6 @@ class DomainRequest(TimeStampedModel):
|
|||
|
||||
if custom_email_content:
|
||||
context["custom_email_content"] = custom_email_content
|
||||
|
||||
send_templated_email(
|
||||
email_template,
|
||||
email_template_subject,
|
||||
|
@ -877,7 +876,6 @@ class DomainRequest(TimeStampedModel):
|
|||
DraftDomain = apps.get_model("registrar.DraftDomain")
|
||||
if not DraftDomain.string_could_be_domain(self.requested_domain.name):
|
||||
raise ValueError("Requested domain is not a valid domain name.")
|
||||
|
||||
# if the domain has not been submitted before this must be the first time
|
||||
if not self.first_submitted_date:
|
||||
self.first_submitted_date = timezone.now().date()
|
||||
|
|
|
@ -79,19 +79,8 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
def get_portfolio_permissions(self):
|
||||
"""
|
||||
Retrieve the permissions for the user's portfolio roles from the invite.
|
||||
This is similar logic to _get_portfolio_permissions in user_portfolio_permission
|
||||
"""
|
||||
# Use a set to avoid duplicate permissions
|
||||
portfolio_permissions = set()
|
||||
|
||||
if self.roles:
|
||||
for role in self.roles:
|
||||
portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
if self.additional_permissions:
|
||||
portfolio_permissions.update(self.additional_permissions)
|
||||
|
||||
return list(portfolio_permissions)
|
||||
return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
|
||||
|
||||
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
|
||||
def retrieve(self):
|
||||
|
|
|
@ -92,32 +92,24 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
"""
|
||||
Retrieve the permissions for the user's portfolio roles.
|
||||
"""
|
||||
return self.get_portfolio_permissions(self.roles, self.additional_permissions)
|
||||
|
||||
@classmethod
|
||||
def get_portfolio_permissions(cls, roles, additional_permissions):
|
||||
"""Class method to return a list of permissions based on roles and addtl permissions"""
|
||||
# Use a set to avoid duplicate permissions
|
||||
portfolio_permissions = set()
|
||||
|
||||
if self.roles:
|
||||
for role in self.roles:
|
||||
portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
if self.additional_permissions:
|
||||
portfolio_permissions.update(self.additional_permissions)
|
||||
|
||||
if roles:
|
||||
for role in roles:
|
||||
portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||
if additional_permissions:
|
||||
portfolio_permissions.update(additional_permissions)
|
||||
return list(portfolio_permissions)
|
||||
|
||||
def clean(self):
|
||||
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
|
||||
super().clean()
|
||||
|
||||
# Check if a user is set without accessing the related object.
|
||||
has_user = bool(self.user_id)
|
||||
if self.pk is None and has_user:
|
||||
existing_permissions = UserPortfolioPermission.objects.filter(user=self.user)
|
||||
if not flag_is_active_for_user(self.user, "multiple_portfolios") and existing_permissions.exists():
|
||||
raise ValidationError(
|
||||
"This user is already assigned to a portfolio. "
|
||||
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
|
||||
)
|
||||
|
||||
# Check if portfolio is set without accessing the related object.
|
||||
has_portfolio = bool(self.portfolio_id)
|
||||
if not has_portfolio and self._get_portfolio_permissions():
|
||||
|
@ -125,3 +117,19 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
|
||||
if has_portfolio and not self._get_portfolio_permissions():
|
||||
raise ValidationError("When portfolio is assigned, portfolio roles or additional permissions are required.")
|
||||
|
||||
# Check if a user is set without accessing the related object.
|
||||
has_user = bool(self.user_id)
|
||||
if has_user:
|
||||
existing_permission_pks = UserPortfolioPermission.objects.filter(user=self.user).values_list(
|
||||
"pk", flat=True
|
||||
)
|
||||
if (
|
||||
not flag_is_active_for_user(self.user, "multiple_portfolios")
|
||||
and existing_permission_pks.exists()
|
||||
and self.pk not in existing_permission_pks
|
||||
):
|
||||
raise ValidationError(
|
||||
"This user is already assigned to a portfolio. "
|
||||
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
|
||||
)
|
||||
|
|
|
@ -36,3 +36,7 @@ class UserPortfolioPermissionChoices(models.TextChoices):
|
|||
@classmethod
|
||||
def get_user_portfolio_permission_label(cls, user_portfolio_permission):
|
||||
return cls(user_portfolio_permission).label if user_portfolio_permission else None
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls):
|
||||
return {key: value.value for key, value in cls.__members__.items()}
|
||||
|
|
|
@ -2,23 +2,21 @@
|
|||
{% load static url_helpers %}
|
||||
|
||||
{% block detail_content %}
|
||||
<table>
|
||||
<table class="usa-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<tr>
|
||||
<th data-sortable scope="col" role="columnheader">Name</th>
|
||||
<th data-sortable scope="col" role="columnheader">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for domain_request in domain_requests %}
|
||||
{% url 'admin:registrar_domainrequest_change' domain_request.pk as url %}
|
||||
<tr>
|
||||
<td><a href={{url}}>{{ domain_request }}</a></td>
|
||||
{% if domain_request.get_status_display %}
|
||||
<td>{{ domain_request.get_status_display }}</td>
|
||||
{% else %}
|
||||
<td>None</td>
|
||||
{% endif %}
|
||||
<td data-sort-value="{{ domain_request }}"> <a href={{url}}>{{ domain_request }}</a></td>
|
||||
<td data-sort-value="{{ domain_request.get_status_display}}"> {{ domain_request.get_status_display|default:"None" }} </td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
{% load static url_helpers %}
|
||||
|
||||
{% block detail_content %}
|
||||
<table>
|
||||
<table class="usa-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>State</th>
|
||||
<th data-sortable scope="col" role="columnheader">Name</th>
|
||||
<th data-sortable scope="col" role="columnheader">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -15,11 +15,11 @@
|
|||
{% with domain=domain_info.domain %}
|
||||
{% url 'admin:registrar_domain_change' domain.pk as url %}
|
||||
<tr>
|
||||
<td><a href={{url}}>{{ domain }}</a></td>
|
||||
<td data-sort-value="{{ domain }}"> <a href={{url}}>{{ domain }}</a></td>
|
||||
{% if domain and domain.get_state_display %}
|
||||
<td>{{ domain.get_state_display }}</td>
|
||||
<td data-sort-value="{{ domain.get_state_display }}"> {{ domain.get_state_display }} </td>
|
||||
{% else %}
|
||||
<td>None</td>
|
||||
<td data-sort-value="None"> None</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endwith %}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% load field_helpers %}
|
||||
|
||||
{% block form_instructions %}
|
||||
<h2 class="margin-bottom-05">
|
||||
<h2 id="id_domain_request_federal_org_header" class="margin-bottom-05">
|
||||
Which federal branch is your organization in?
|
||||
</h2>
|
||||
{% endblock %}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
<p>
|
||||
Domain managers can update all information related to a domain within the
|
||||
.gov registrar, including including security email and DNS name servers.
|
||||
.gov registrar, including security email and DNS name servers.
|
||||
</p>
|
||||
|
||||
<ul class="usa-list">
|
||||
|
|
31
src/registrar/templates/emails/update_to_approved_domain.txt
Normal file
31
src/registrar/templates/emails/update_to_approved_domain.txt
Normal file
|
@ -0,0 +1,31 @@
|
|||
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||
|
||||
Hi,
|
||||
An update was made to a domain you manage.
|
||||
|
||||
DOMAIN: {{domain}}
|
||||
UPDATED BY: {{user}}
|
||||
UPDATED ON: {{date}}
|
||||
INFORMATION UPDATED: {{changes}}
|
||||
|
||||
You can view this update in the .gov registrar <https://manage.get.gov/>.
|
||||
|
||||
Get help with managing your .gov domain <https://get.gov/help/domain-management/>.
|
||||
|
||||
----------------------------------------------------------------
|
||||
|
||||
WHY DID YOU RECEIVE THIS EMAIL?
|
||||
You’re listed as a domain manager for {{domain}}, so you’ll receive a notification whenever changes are made to that domain.
|
||||
If you have questions or concerns, reach out to the person who made the change 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 %}
|
|
@ -0,0 +1 @@
|
|||
An update was made to {{domain}}
|
|
@ -39,16 +39,16 @@
|
|||
</div>
|
||||
|
||||
<!-- ---------- MAIN TABLE ---------- -->
|
||||
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="members__table-wrapper">
|
||||
<div class="display-none margin-top-0" id="members__table-wrapper">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
|
||||
<caption class="sr-only">Your registered members</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sortable="member" scope="col" role="columnheader">Member</th>
|
||||
<th data-sortable="last_active" scope="col" role="columnheader">Last Active</th>
|
||||
<th data-sortable="member" role="columnheader" id="header-member">Member</th>
|
||||
<th data-sortable="last_active" role="columnheader" id="header-last-active">Last Active</th>
|
||||
<th
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
role="columnheader"
|
||||
id="header-action"
|
||||
>
|
||||
<span class="usa-sr-only">Action</span>
|
||||
</th>
|
||||
|
|
|
@ -61,11 +61,58 @@ class TestEmails(TestCase):
|
|||
# Assert that an email wasn't sent
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
||||
@boto3_mocking.patching
|
||||
def test_email_with_cc(self):
|
||||
"""Test sending email with cc works"""
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
send_templated_email(
|
||||
"emails/update_to_approved_domain.txt",
|
||||
"emails/update_to_approved_domain_subject.txt",
|
||||
"doesnotexist@igorville.com",
|
||||
context={"domain": "test", "user": "test", "date": 1, "changes": "test"},
|
||||
bcc_address=None,
|
||||
cc_addresses=["testy2@town.com", "mayor@igorville.gov"],
|
||||
)
|
||||
|
||||
# check that an email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# check the call sequence for the email
|
||||
args, kwargs = self.mock_client.send_email.call_args
|
||||
self.assertIn("Destination", kwargs)
|
||||
self.assertIn("CcAddresses", kwargs["Destination"])
|
||||
|
||||
self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
|
||||
|
||||
@boto3_mocking.patching
|
||||
@override_settings(IS_PRODUCTION=True)
|
||||
def test_email_with_cc_in_prod(self):
|
||||
"""Test sending email with cc works in prod"""
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
send_templated_email(
|
||||
"emails/update_to_approved_domain.txt",
|
||||
"emails/update_to_approved_domain_subject.txt",
|
||||
"doesnotexist@igorville.com",
|
||||
context={"domain": "test", "user": "test", "date": 1, "changes": "test"},
|
||||
bcc_address=None,
|
||||
cc_addresses=["testy2@town.com", "mayor@igorville.gov"],
|
||||
)
|
||||
|
||||
# check that an email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# check the call sequence for the email
|
||||
args, kwargs = self.mock_client.send_email.call_args
|
||||
self.assertIn("Destination", kwargs)
|
||||
self.assertIn("CcAddresses", kwargs["Destination"])
|
||||
|
||||
self.assertEqual(["testy2@town.com", "mayor@igorville.gov"], kwargs["Destination"]["CcAddresses"])
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_submission_confirmation(self):
|
||||
"""Submission confirmation email works."""
|
||||
domain_request = completed_domain_request()
|
||||
domain_request = completed_domain_request(user=User.objects.create(username="test", email="testy@town.com"))
|
||||
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
|
@ -102,7 +149,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_current_website_spacing(self):
|
||||
"""Test line spacing without current_website."""
|
||||
domain_request = completed_domain_request(has_current_website=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_current_website=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -115,7 +164,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_current_website_spacing(self):
|
||||
"""Test line spacing with current_website."""
|
||||
domain_request = completed_domain_request(has_current_website=True)
|
||||
domain_request = completed_domain_request(
|
||||
has_current_website=True, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -132,7 +183,11 @@ class TestEmails(TestCase):
|
|||
|
||||
# Create fake creator
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward", first_name="Meoward", last_name="Jones", phone="(888) 888 8888"
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
phone="(888) 888 8888",
|
||||
email="testy@town.com",
|
||||
)
|
||||
|
||||
# Create a fake domain request
|
||||
|
@ -149,7 +204,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_other_contacts_spacing(self):
|
||||
"""Test line spacing without other contacts."""
|
||||
domain_request = completed_domain_request(has_other_contacts=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_other_contacts=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -161,7 +218,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_alternative_govdomain_spacing(self):
|
||||
"""Test line spacing with alternative .gov domain."""
|
||||
domain_request = completed_domain_request(has_alternative_gov_domain=True)
|
||||
domain_request = completed_domain_request(
|
||||
has_alternative_gov_domain=True, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -174,7 +233,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_alternative_govdomain_spacing(self):
|
||||
"""Test line spacing without alternative .gov domain."""
|
||||
domain_request = completed_domain_request(has_alternative_gov_domain=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_alternative_gov_domain=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -187,7 +248,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_about_your_organization_spacing(self):
|
||||
"""Test line spacing with about your organization."""
|
||||
domain_request = completed_domain_request(has_about_your_organization=True)
|
||||
domain_request = completed_domain_request(
|
||||
has_about_your_organization=True, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -200,7 +263,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_about_your_organization_spacing(self):
|
||||
"""Test line spacing without about your organization."""
|
||||
domain_request = completed_domain_request(has_about_your_organization=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_about_your_organization=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -213,7 +278,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_anything_else_spacing(self):
|
||||
"""Test line spacing with anything else."""
|
||||
domain_request = completed_domain_request(has_anything_else=True)
|
||||
domain_request = completed_domain_request(
|
||||
has_anything_else=True, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
@ -225,7 +292,9 @@ class TestEmails(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submission_confirmation_no_anything_else_spacing(self):
|
||||
"""Test line spacing without anything else."""
|
||||
domain_request = completed_domain_request(has_anything_else=False)
|
||||
domain_request = completed_domain_request(
|
||||
has_anything_else=False, user=User.objects.create(username="test", email="testy@town.com")
|
||||
)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
domain_request.submit()
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
|
|
|
@ -299,6 +299,7 @@ class TestUserPortfolioPermission(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def setUp(self):
|
||||
self.user, _ = User.objects.get_or_create(email="mayor@igorville.gov")
|
||||
self.user2, _ = User.objects.get_or_create(email="user2@igorville.gov", username="user2")
|
||||
super().setUp()
|
||||
|
||||
def tearDown(self):
|
||||
|
@ -336,16 +337,15 @@ class TestUserPortfolioPermission(TestCase):
|
|||
@override_flag("multiple_portfolios", active=False)
|
||||
def test_clean_on_creates_multiple_portfolios(self):
|
||||
"""Ensures that a user cannot create multiple portfolio permission objects when the flag is disabled"""
|
||||
# Create an instance of User with a portfolio but no roles or additional permissions
|
||||
# Create an instance of User with a single portfolio
|
||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Motel California")
|
||||
portfolio_permission_2 = UserPortfolioPermission(
|
||||
portfolio=portfolio_2, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
# This should work as intended
|
||||
portfolio_permission.clean()
|
||||
|
||||
|
@ -353,7 +353,37 @@ class TestUserPortfolioPermission(TestCase):
|
|||
with self.assertRaises(ValidationError) as cm:
|
||||
portfolio_permission_2.clean()
|
||||
|
||||
portfolio_permission_2, _ = UserPortfolioPermission.objects.get_or_create(portfolio=portfolio, user=self.user)
|
||||
self.assertEqual(
|
||||
cm.exception.message,
|
||||
(
|
||||
"This user is already assigned to a portfolio. "
|
||||
"Based on current waffle flag settings, users cannot be assigned to multiple portfolios."
|
||||
),
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("multiple_portfolios", active=False)
|
||||
def test_multiple_portfolio_reassignment(self):
|
||||
"""Ensures that a user cannot be assigned to multiple portfolios based on reassignment"""
|
||||
# Create an instance of two users with separate portfolios
|
||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
portfolio_2, _ = Portfolio.objects.get_or_create(creator=self.user2, organization_name="Motel California")
|
||||
portfolio_permission_2 = UserPortfolioPermission(
|
||||
portfolio=portfolio_2, user=self.user2, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
# This should work as intended
|
||||
portfolio_permission.clean()
|
||||
portfolio_permission_2.clean()
|
||||
|
||||
# Reassign the portfolio of "user2" to "user" (this should throw an error
|
||||
# preventing "user" from having multiple portfolios)
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
portfolio_permission_2.user = self.user
|
||||
portfolio_permission_2.clean()
|
||||
|
||||
self.assertEqual(
|
||||
cm.exception.message,
|
||||
|
|
|
@ -305,7 +305,7 @@ class TestDomainRequest(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_submit_from_withdrawn_sends_email(self):
|
||||
msg = "Create a withdrawn domain request and submit it and see if email was sent."
|
||||
user, _ = User.objects.get_or_create(username="testy")
|
||||
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=user)
|
||||
self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hi", expected_email=user.email)
|
||||
|
||||
|
@ -324,14 +324,14 @@ class TestDomainRequest(TestCase):
|
|||
@less_console_noise_decorator
|
||||
def test_approve_sends_email(self):
|
||||
msg = "Create a domain request and approve it and see if email was sent."
|
||||
user, _ = User.objects.get_or_create(username="testy")
|
||||
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
|
||||
self.check_email_sent(domain_request, msg, "approve", 1, expected_content="approved", expected_email=user.email)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_withdraw_sends_email(self):
|
||||
msg = "Create a domain request and withdraw it and see if email was sent."
|
||||
user, _ = User.objects.get_or_create(username="testy")
|
||||
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=user)
|
||||
self.check_email_sent(
|
||||
domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email
|
||||
|
@ -339,7 +339,7 @@ class TestDomainRequest(TestCase):
|
|||
|
||||
def test_reject_sends_email(self):
|
||||
"Create a domain request and reject it and see if email was sent."
|
||||
user, _ = User.objects.get_or_create(username="testy")
|
||||
user, _ = User.objects.get_or_create(username="testy", email="testy@town.com")
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user)
|
||||
expected_email = user.email
|
||||
email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)
|
||||
|
|
|
@ -65,6 +65,10 @@ class TestWithDomainPermissions(TestWithUser):
|
|||
datetime.combine(date.today() + timedelta(days=1), datetime.min.time())
|
||||
),
|
||||
)
|
||||
self.domain_dns_needed, _ = Domain.objects.get_or_create(
|
||||
name="dns-needed.gov",
|
||||
state=Domain.State.DNS_NEEDED,
|
||||
)
|
||||
self.domain_deleted, _ = Domain.objects.get_or_create(
|
||||
name="deleted.gov",
|
||||
state=Domain.State.DELETED,
|
||||
|
@ -91,6 +95,7 @@ class TestWithDomainPermissions(TestWithUser):
|
|||
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_just_nameserver)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_on_hold)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_deleted)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain_dns_needed)
|
||||
|
||||
self.role, _ = UserDomainRole.objects.get_or_create(
|
||||
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
|
||||
|
@ -99,6 +104,9 @@ class TestWithDomainPermissions(TestWithUser):
|
|||
UserDomainRole.objects.get_or_create(
|
||||
user=self.user, domain=self.domain_dsdata, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
UserDomainRole.objects.get_or_create(
|
||||
user=self.user, domain=self.domain_dns_needed, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
UserDomainRole.objects.get_or_create(
|
||||
user=self.user,
|
||||
domain=self.domain_multdsdata,
|
||||
|
@ -236,6 +244,7 @@ class TestDomainDetail(TestDomainOverview):
|
|||
# At the time of this test's writing, there are 6 UNKNOWN domains inherited
|
||||
# from constructors. Let's reset.
|
||||
with less_console_noise():
|
||||
PublicContact.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
|
@ -1967,3 +1976,292 @@ class TestDomainDNSSEC(TestDomainOverview):
|
|||
self.assertContains(
|
||||
result, str(DsDataError(code=DsDataErrorCodes.INVALID_DIGEST_SHA256)), count=2, status_code=200
|
||||
)
|
||||
|
||||
|
||||
class TestDomainChangeNotifications(TestDomainOverview):
|
||||
"""Test email notifications on updates to domain information"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
allowed_emails = [
|
||||
AllowedEmail(email="info@example.com"),
|
||||
AllowedEmail(email="doesnotexist@igorville.com"),
|
||||
]
|
||||
AllowedEmail.objects.bulk_create(allowed_emails)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.mock_client_class = MagicMock()
|
||||
self.mock_client = self.mock_client_class.return_value
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
super().tearDownClass()
|
||||
AllowedEmail.objects.all().delete()
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_notification_on_org_name_change(self):
|
||||
"""Test that an email is sent when the organization name is changed."""
|
||||
# We may end up sending emails on org name changes later, but it will be addressed
|
||||
# in the portfolio itself, rather than the individual domain.
|
||||
|
||||
self.domain_information.organization_name = "Town of Igorville"
|
||||
self.domain_information.address_line1 = "123 Main St"
|
||||
self.domain_information.city = "Igorville"
|
||||
self.domain_information.state_territory = "IL"
|
||||
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}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
org_name_page.form["organization_name"] = "Not igorville"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
org_name_page.form.submit()
|
||||
|
||||
# Check that an email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# Check email content
|
||||
# check the call sequence for the email
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
self.assertIn("Content", kwargs)
|
||||
self.assertIn("Simple", kwargs["Content"])
|
||||
self.assertIn("Subject", kwargs["Content"]["Simple"])
|
||||
self.assertIn("Body", kwargs["Content"]["Simple"])
|
||||
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: Organization details", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_no_notification_on_org_name_change_with_portfolio(self):
|
||||
"""Test that an email is not sent on org name change when the domain is in a portfolio"""
|
||||
|
||||
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
|
||||
|
||||
self.domain_information.organization_name = "Town of Igorville"
|
||||
self.domain_information.address_line1 = "123 Main St"
|
||||
self.domain_information.city = "Igorville"
|
||||
self.domain_information.state_territory = "IL"
|
||||
self.domain_information.zipcode = "62052"
|
||||
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}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
org_name_page.form["organization_name"] = "Not igorville"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
org_name_page.form.submit()
|
||||
|
||||
# Check that an email was not sent
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_no_notification_on_change_by_analyst(self):
|
||||
"""Test that an email is not sent on org name change when the domain is in a portfolio"""
|
||||
|
||||
portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user)
|
||||
|
||||
self.domain_information.organization_name = "Town of Igorville"
|
||||
self.domain_information.address_line1 = "123 Main St"
|
||||
self.domain_information.city = "Igorville"
|
||||
self.domain_information.state_territory = "IL"
|
||||
self.domain_information.zipcode = "62052"
|
||||
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}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
session = self.app.session
|
||||
session["analyst_action"] = "foo"
|
||||
session["analyst_action_location"] = self.domain.id
|
||||
session.save()
|
||||
|
||||
org_name_page.form["organization_name"] = "Not igorville"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
org_name_page.form.submit()
|
||||
|
||||
# Check that an email was not sent
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
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}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
security_email_page.form["security_email"] = "new_security@example.com"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
security_email_page.form.submit()
|
||||
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: Security email", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
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}))
|
||||
self.assertContains(page, "Disable DNSSEC")
|
||||
|
||||
# Prepare the data for the POST request
|
||||
post_data = {
|
||||
"disable_dnssec": "Disable DNSSEC",
|
||||
}
|
||||
|
||||
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}),
|
||||
post_data,
|
||||
follow=True,
|
||||
)
|
||||
|
||||
self.assertEqual(updated_page.status_code, 200)
|
||||
|
||||
self.assertContains(updated_page, "Enable DNSSEC")
|
||||
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: DNSSEC / DS Data", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
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}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
# Add DS data
|
||||
ds_data_page.forms[0]["form-0-key_tag"] = "12345"
|
||||
ds_data_page.forms[0]["form-0-algorithm"] = "13"
|
||||
ds_data_page.forms[0]["form-0-digest_type"] = "2"
|
||||
ds_data_page.forms[0]["form-0-digest"] = "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
ds_data_page.forms[0].submit()
|
||||
|
||||
# check that the email was sent
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
# check some stuff about the email
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: DNSSEC / DS Data", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_notification_on_senior_official_change(self):
|
||||
"""Test that an email is sent when the senior official information is changed."""
|
||||
|
||||
self.domain_information.senior_official = Contact.objects.create(
|
||||
first_name="Old", last_name="Official", title="Manager", email="old_official@example.com"
|
||||
)
|
||||
self.domain_information.save()
|
||||
|
||||
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
senior_official_page.form["first_name"] = "New"
|
||||
senior_official_page.form["last_name"] = "Official"
|
||||
senior_official_page.form["title"] = "Director"
|
||||
senior_official_page.form["email"] = "new_official@example.com"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
senior_official_page.form.submit()
|
||||
|
||||
self.assertTrue(self.mock_client.send_email.called)
|
||||
|
||||
_, kwargs = self.mock_client.send_email.call_args
|
||||
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
|
||||
self.assertIn("DOMAIN: igorville.gov", body)
|
||||
self.assertIn("UPDATED BY: First Last info@example.com", body)
|
||||
self.assertIn("INFORMATION UPDATED: Senior official", body)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
def test_no_notification_on_senior_official_when_portfolio(self):
|
||||
"""Test that an email is not sent when the senior official information is changed
|
||||
and the domain is in a portfolio."""
|
||||
|
||||
self.domain_information.senior_official = Contact.objects.create(
|
||||
first_name="Old", last_name="Official", title="Manager", email="old_official@example.com"
|
||||
)
|
||||
portfolio, _ = Portfolio.objects.get_or_create(
|
||||
organization_name="portfolio",
|
||||
creator=self.user,
|
||||
)
|
||||
self.domain_information.portfolio = portfolio
|
||||
self.domain_information.save()
|
||||
|
||||
senior_official_page = self.app.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
senior_official_page.form["first_name"] = "New"
|
||||
senior_official_page.form["last_name"] = "Official"
|
||||
senior_official_page.form["title"] = "Director"
|
||||
senior_official_page.form["email"] = "new_official@example.com"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
senior_official_page.form.submit()
|
||||
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
||||
@boto3_mocking.patching
|
||||
@less_console_noise_decorator
|
||||
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}))
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
|
||||
# add nameservers
|
||||
nameservers_page.form["form-0-server"] = "ns1-new.dns-needed.gov"
|
||||
nameservers_page.form["form-0-ip"] = "192.168.1.1"
|
||||
nameservers_page.form["form-1-server"] = "ns2-new.dns-needed.gov"
|
||||
nameservers_page.form["form-1-ip"] = "192.168.1.2"
|
||||
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
|
||||
nameservers_page.form.submit()
|
||||
|
||||
# Check that an email was not sent
|
||||
self.assertFalse(self.mock_client.send_email.called)
|
||||
|
|
|
@ -1,23 +1,27 @@
|
|||
from django.urls import reverse
|
||||
|
||||
from api.tests.common import less_console_noise_decorator
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.domain_information import DomainInformation
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
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
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from waffle.testutils import override_flag
|
||||
from .test_views import TestWithUser
|
||||
from registrar.tests.common import MockEppLib, create_test_user
|
||||
from django_webtest import WebTest # type: ignore
|
||||
|
||||
|
||||
class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = create_test_user()
|
||||
|
||||
# Create additional users
|
||||
cls.user2 = User.objects.create(
|
||||
self.user2 = User.objects.create(
|
||||
username="test_user2",
|
||||
first_name="Second",
|
||||
last_name="User",
|
||||
|
@ -25,7 +29,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
phone="8003112345",
|
||||
title="Member",
|
||||
)
|
||||
cls.user3 = User.objects.create(
|
||||
self.user3 = User.objects.create(
|
||||
username="test_user3",
|
||||
first_name="Third",
|
||||
last_name="User",
|
||||
|
@ -33,7 +37,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
phone="8003113456",
|
||||
title="Member",
|
||||
)
|
||||
cls.user4 = User.objects.create(
|
||||
self.user4 = User.objects.create(
|
||||
username="test_user4",
|
||||
first_name="Fourth",
|
||||
last_name="User",
|
||||
|
@ -41,63 +45,69 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
phone="8003114567",
|
||||
title="Admin",
|
||||
)
|
||||
cls.email5 = "fifth@example.com"
|
||||
self.user5 = User.objects.create(
|
||||
username="test_user5",
|
||||
first_name="Fifth",
|
||||
last_name="User",
|
||||
email="fifth@example.com",
|
||||
phone="8003114568",
|
||||
title="Admin",
|
||||
)
|
||||
self.email6 = "fifth@example.com"
|
||||
|
||||
# Create Portfolio
|
||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Test Portfolio")
|
||||
|
||||
# Assign permissions
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user2,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user3,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=cls.user4,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
PortfolioInvitation.objects.create(
|
||||
email=cls.email5,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
self.app.set_user(self.user.username)
|
||||
|
||||
def tearDown(self):
|
||||
UserDomainRole.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app.set_user(self.user.username)
|
||||
super().tearDown()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_members_json_authenticated(self):
|
||||
"""Test that portfolio members are returned properly for an authenticated user."""
|
||||
"""Also tests that reposnse is 200 when no domains"""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user2,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user3,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user4,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user5,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
@ -120,35 +130,257 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.user3.email,
|
||||
self.user4.email,
|
||||
self.user4.email,
|
||||
self.email5,
|
||||
self.user5.email,
|
||||
}
|
||||
actual_emails = {member["email"] for member in data["members"]}
|
||||
self.assertEqual(expected_emails, actual_emails)
|
||||
|
||||
expected_roles = {
|
||||
UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
}
|
||||
# Convert each member's roles list to a frozenset
|
||||
actual_roles = {role for member in data["members"] for role in member["roles"]}
|
||||
self.assertEqual(expected_roles, actual_roles)
|
||||
|
||||
# Assert that the expected additional permissions are in the actual entire permissions list
|
||||
expected_additional_permissions = {
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
}
|
||||
# actual_permissions includes additional permissions as well as permissions from roles
|
||||
actual_permissions = {permission for member in data["members"] for permission in member["permissions"]}
|
||||
self.assertTrue(expected_additional_permissions.issubset(actual_permissions))
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_members_json_unauthenticated(self):
|
||||
"""Test that an unauthenticated user is redirected or denied access."""
|
||||
# Log out the user by setting the user to None
|
||||
self.app.set_user(None)
|
||||
|
||||
# Try to access the portfolio members without being authenticated
|
||||
response = self.app.get(
|
||||
reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id}, expect_errors=True
|
||||
def test_get_portfolio_invited_json_authenticated(self):
|
||||
"""Test that portfolio invitees are returned properly for an authenticated user."""
|
||||
"""Also tests that reposnse is 200 when no domains"""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Assert that the response is a redirect to the login page
|
||||
self.assertEqual(response.status_code, 302) # Redirect to openid login
|
||||
self.assertIn("/openid/login", response.location)
|
||||
PortfolioInvitation.objects.create(
|
||||
email=self.email6,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
||||
# Check pagination info
|
||||
self.assertEqual(data["page"], 1)
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 2)
|
||||
self.assertEqual(data["unfiltered_total"], 2)
|
||||
|
||||
# Check the number of members
|
||||
self.assertEqual(len(data["members"]), 2)
|
||||
|
||||
# Check member fields
|
||||
expected_emails = {self.user.email, self.email6}
|
||||
actual_emails = {member["email"] for member in data["members"]}
|
||||
self.assertEqual(expected_emails, actual_emails)
|
||||
|
||||
expected_roles = {
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
}
|
||||
# Convert each member's roles list to a frozenset
|
||||
actual_roles = {role for member in data["members"] for role in member["roles"]}
|
||||
self.assertEqual(expected_roles, actual_roles)
|
||||
|
||||
expected_additional_permissions = {
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
}
|
||||
actual_additional_permissions = {
|
||||
permission for member in data["members"] for permission in member["permissions"]
|
||||
}
|
||||
self.assertTrue(expected_additional_permissions.issubset(actual_additional_permissions))
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_members_json_with_domains(self):
|
||||
"""Test that portfolio members are returned properly for an authenticated user and the response includes
|
||||
the domains that the member manages.."""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user2,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user3,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user4,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
# create domain for which user is manager and domain in portfolio
|
||||
domain = Domain.objects.create(
|
||||
name="somedomain1.com",
|
||||
)
|
||||
DomainInformation.objects.create(
|
||||
creator=self.user,
|
||||
domain=domain,
|
||||
portfolio=self.portfolio,
|
||||
)
|
||||
UserDomainRole.objects.create(
|
||||
user=self.user,
|
||||
domain=domain,
|
||||
role=UserDomainRole.Roles.MANAGER,
|
||||
)
|
||||
|
||||
# create domain for which user is manager and domain not in portfolio
|
||||
domain2 = Domain.objects.create(
|
||||
name="somedomain2.com",
|
||||
)
|
||||
DomainInformation.objects.create(
|
||||
creator=self.user,
|
||||
domain=domain2,
|
||||
)
|
||||
UserDomainRole.objects.create(
|
||||
user=self.user,
|
||||
domain=domain2,
|
||||
role=UserDomainRole.Roles.MANAGER,
|
||||
)
|
||||
|
||||
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
||||
# Check if the domain appears in the response JSON and that domain2 does not
|
||||
domain_names = [domain_name for member in data["members"] for domain_name in member.get("domain_names", [])]
|
||||
self.assertIn("somedomain1.com", domain_names)
|
||||
self.assertNotIn("somedomain2.com", domain_names)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_get_portfolio_invited_json_with_domains(self):
|
||||
"""Test that portfolio invited members are returned properly for an authenticated user and the response includes
|
||||
the domains that the member manages.."""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
|
||||
)
|
||||
|
||||
PortfolioInvitation.objects.create(
|
||||
email=self.email6,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# create a domain in the portfolio
|
||||
domain = Domain.objects.create(
|
||||
name="somedomain1.com",
|
||||
)
|
||||
DomainInformation.objects.create(
|
||||
creator=self.user,
|
||||
domain=domain,
|
||||
portfolio=self.portfolio,
|
||||
)
|
||||
DomainInvitation.objects.create(
|
||||
email=self.email6,
|
||||
domain=domain,
|
||||
)
|
||||
|
||||
# create a domain not in the portfolio
|
||||
domain2 = Domain.objects.create(
|
||||
name="somedomain2.com",
|
||||
)
|
||||
DomainInformation.objects.create(
|
||||
creator=self.user,
|
||||
domain=domain2,
|
||||
)
|
||||
DomainInvitation.objects.create(
|
||||
email=self.email6,
|
||||
domain=domain2,
|
||||
)
|
||||
|
||||
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json
|
||||
|
||||
# Check if the domain appears in the response JSON and domain2 does not
|
||||
domain_names = [domain_name for member in data["members"] for domain_name in member.get("domain_names", [])]
|
||||
self.assertIn("somedomain1.com", domain_names)
|
||||
self.assertNotIn("somedomain2.com", domain_names)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_pagination(self):
|
||||
"""Test that pagination works properly when there are more members than page size."""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user2,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user3,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user4,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
PortfolioInvitation.objects.create(
|
||||
email=self.email6,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Create additional members to exceed page size of 10
|
||||
for i in range(5, 15):
|
||||
for i in range(6, 16):
|
||||
user, _ = User.objects.get_or_create(
|
||||
username=f"test_user{i}",
|
||||
first_name=f"User{i}",
|
||||
|
@ -200,6 +432,40 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
@override_flag("organization_members", active=True)
|
||||
def test_search(self):
|
||||
"""Test search functionality for portfolio members."""
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user2,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user3,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user4,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
PortfolioInvitation.objects.create(
|
||||
email=self.email6,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Search by name
|
||||
response = self.app.get(
|
||||
reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id, "search_term": "Second"}
|
||||
|
|
|
@ -22,30 +22,47 @@ class EmailSendingError(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
def send_templated_email(
|
||||
def send_templated_email( # noqa
|
||||
template_name: str,
|
||||
subject_template_name: str,
|
||||
to_address: str,
|
||||
bcc_address="",
|
||||
to_address: str = "",
|
||||
bcc_address: str = "",
|
||||
context={},
|
||||
attachment_file=None,
|
||||
wrap_email=False,
|
||||
cc_addresses: list[str] = [],
|
||||
):
|
||||
"""Send an email built from a template to one email address.
|
||||
"""Send an email built from a template.
|
||||
|
||||
to_address and bcc_address currently only support single addresses.
|
||||
|
||||
cc_address is a list and can contain many addresses. Emails not in the
|
||||
whitelist (if applicable) will be filtered out before sending.
|
||||
|
||||
template_name and subject_template_name are relative to the same template
|
||||
context as Django's HTML templates. context gives additional information
|
||||
that the template may use.
|
||||
|
||||
Raises EmailSendingError if SES client could not be accessed
|
||||
Raises EmailSendingError if:
|
||||
SES client could not be accessed
|
||||
No valid recipient addresses are provided
|
||||
"""
|
||||
|
||||
# by default assume we can send to all addresses (prod has no whitelist)
|
||||
sendable_cc_addresses = cc_addresses
|
||||
|
||||
if not settings.IS_PRODUCTION: # type: ignore
|
||||
# Split into a function: C901 'send_templated_email' is too complex.
|
||||
# Raises an error if we cannot send an email (due to restrictions).
|
||||
# Does nothing otherwise.
|
||||
_can_send_email(to_address, bcc_address)
|
||||
|
||||
# if we're not in prod, we need to check the whitelist for CC'ed addresses
|
||||
sendable_cc_addresses, blocked_cc_addresses = get_sendable_addresses(cc_addresses)
|
||||
|
||||
if blocked_cc_addresses:
|
||||
logger.warning("Some CC'ed addresses were removed: %s.", blocked_cc_addresses)
|
||||
|
||||
template = get_template(template_name)
|
||||
email_body = template.render(context=context)
|
||||
|
||||
|
@ -64,14 +81,23 @@ def send_templated_email(
|
|||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=settings.BOTO_CONFIG,
|
||||
)
|
||||
logger.info(f"An email was sent! Template name: {template_name} to {to_address}")
|
||||
logger.info(f"Connected to SES client! Template name: {template_name} to {to_address}")
|
||||
except Exception as exc:
|
||||
logger.debug("E-mail unable to send! Could not access the SES client.")
|
||||
raise EmailSendingError("Could not access the SES client.") from exc
|
||||
|
||||
destination = {"ToAddresses": [to_address]}
|
||||
destination = {}
|
||||
if to_address:
|
||||
destination["ToAddresses"] = [to_address]
|
||||
if bcc_address:
|
||||
destination["BccAddresses"] = [bcc_address]
|
||||
if cc_addresses:
|
||||
destination["CcAddresses"] = sendable_cc_addresses
|
||||
|
||||
# make sure we don't try and send an email to nowhere
|
||||
if not destination:
|
||||
message = "Email unable to send, no valid recipients provided."
|
||||
raise EmailSendingError(message)
|
||||
|
||||
try:
|
||||
if not attachment_file:
|
||||
|
@ -90,6 +116,7 @@ def send_templated_email(
|
|||
},
|
||||
},
|
||||
)
|
||||
logger.info("Email sent to [%s], bcc [%s], cc %s", to_address, bcc_address, sendable_cc_addresses)
|
||||
else:
|
||||
ses_client = boto3.client(
|
||||
"ses",
|
||||
|
@ -101,6 +128,10 @@ def send_templated_email(
|
|||
send_email_with_attachment(
|
||||
settings.DEFAULT_FROM_EMAIL, to_address, subject, email_body, attachment_file, ses_client
|
||||
)
|
||||
logger.info(
|
||||
"Email with attachment sent to [%s], bcc [%s], cc %s", to_address, bcc_address, sendable_cc_addresses
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
raise EmailSendingError("Could not send SES email.") from exc
|
||||
|
||||
|
@ -125,6 +156,33 @@ def _can_send_email(to_address, bcc_address):
|
|||
raise EmailSendingError(message.format(bcc_address))
|
||||
|
||||
|
||||
def get_sendable_addresses(addresses: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Checks whether a list of addresses can be sent to.
|
||||
|
||||
Returns: a lists of all provided addresses that are ok to send to and a list of addresses that were blocked.
|
||||
|
||||
Paramaters:
|
||||
|
||||
addresses: a list of strings representing all addresses to be checked.
|
||||
"""
|
||||
|
||||
if flag_is_active(None, "disable_email_sending"): # type: ignore
|
||||
message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'."
|
||||
logger.warning(message)
|
||||
return ([], [])
|
||||
else:
|
||||
AllowedEmail = apps.get_model("registrar", "AllowedEmail")
|
||||
allowed_emails = []
|
||||
blocked_emails = []
|
||||
for address in addresses:
|
||||
if AllowedEmail.is_allowed_email(address):
|
||||
allowed_emails.append(address)
|
||||
else:
|
||||
blocked_emails.append(address)
|
||||
|
||||
return allowed_emails, blocked_emails
|
||||
|
||||
|
||||
def wrap_text_and_preserve_paragraphs(text, width):
|
||||
"""
|
||||
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.
|
||||
|
|
|
@ -5,6 +5,7 @@ authorized users can see information on a domain, every view here should
|
|||
inherit from `DomainPermissionView` (or DomainInvitationPermissionDeleteView).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
import logging
|
||||
|
||||
from django.contrib import messages
|
||||
|
@ -152,6 +153,103 @@ class DomainFormBaseView(DomainBaseView, FormMixin):
|
|||
|
||||
return current_domain_info
|
||||
|
||||
def send_update_notification(self, form, force_send=False):
|
||||
"""Send a notification to all domain managers that an update has occured
|
||||
for a single domain. Uses update_to_approved_domain.txt template.
|
||||
|
||||
If there are no changes to the form, emails will NOT be sent unless force_send
|
||||
is set to True.
|
||||
"""
|
||||
|
||||
# send notification email for changes to any of these forms
|
||||
form_label_dict = {
|
||||
DomainSecurityEmailForm: "Security email",
|
||||
DomainDnssecForm: "DNSSEC / DS Data",
|
||||
DomainDsdataFormset: "DNSSEC / DS Data",
|
||||
DomainOrgNameAddressForm: "Organization details",
|
||||
SeniorOfficialContactForm: "Senior official",
|
||||
NameserverFormset: "Name servers",
|
||||
}
|
||||
|
||||
# forms of these types should not send notifications if they're part of a portfolio/Organization
|
||||
check_for_portfolio = {
|
||||
DomainOrgNameAddressForm,
|
||||
SeniorOfficialContactForm,
|
||||
}
|
||||
|
||||
is_analyst_action = "analyst_action" in self.session and "analyst_action_location" in self.session
|
||||
|
||||
should_notify = False
|
||||
|
||||
if form.__class__ in form_label_dict:
|
||||
if is_analyst_action:
|
||||
logger.debug("No notification sent: Action was conducted by an analyst")
|
||||
else:
|
||||
# these types of forms can cause notifications
|
||||
should_notify = True
|
||||
if form.__class__ in check_for_portfolio:
|
||||
# some forms shouldn't cause notifications if they are in a portfolio
|
||||
info = self.get_domain_info_from_domain()
|
||||
if not info or info.portfolio:
|
||||
logger.debug("No notification sent: Domain is part of a portfolio")
|
||||
should_notify = False
|
||||
else:
|
||||
# don't notify for any other types of forms
|
||||
should_notify = False
|
||||
if should_notify and (form.has_changed() or force_send):
|
||||
context = {
|
||||
"domain": self.object.name,
|
||||
"user": self.request.user,
|
||||
"date": date.today(),
|
||||
"changes": form_label_dict[form.__class__],
|
||||
}
|
||||
self.email_domain_managers(
|
||||
self.object,
|
||||
"emails/update_to_approved_domain.txt",
|
||||
"emails/update_to_approved_domain_subject.txt",
|
||||
context,
|
||||
)
|
||||
else:
|
||||
logger.info(f"No notification sent for {form.__class__}.")
|
||||
|
||||
def email_domain_managers(self, domain: Domain, template: str, subject_template: str, context={}):
|
||||
"""Send a single email built from a template to all managers for a given domain.
|
||||
|
||||
template_name and subject_template_name are relative to the same template
|
||||
context as Django's HTML templates. context gives additional information
|
||||
that the template may use.
|
||||
|
||||
context is a dictionary containing any information needed to fill in values
|
||||
in the provided template, exactly the same as with send_templated_email.
|
||||
|
||||
Will log a warning if the email fails to send for any reason, but will not raise an error.
|
||||
"""
|
||||
manager_pks = UserDomainRole.objects.filter(domain=domain.pk, role=UserDomainRole.Roles.MANAGER).values_list(
|
||||
"user", flat=True
|
||||
)
|
||||
emails = list(User.objects.filter(pk__in=manager_pks).values_list("email", flat=True))
|
||||
try:
|
||||
# Remove the current user so they aren't CC'ed, since they will be the "to_address"
|
||||
emails.remove(self.request.user.email) # type: ignore
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
send_templated_email(
|
||||
template,
|
||||
subject_template,
|
||||
to_address=self.request.user.email, # type: ignore
|
||||
context=context,
|
||||
cc_addresses=emails,
|
||||
)
|
||||
except EmailSendingError:
|
||||
logger.warning(
|
||||
"Could not sent notification email to %s for domain %s",
|
||||
emails,
|
||||
domain.name,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
class DomainView(DomainBaseView):
|
||||
"""Domain detail overview page."""
|
||||
|
@ -227,6 +325,8 @@ class DomainOrgNameAddressView(DomainFormBaseView):
|
|||
|
||||
def form_valid(self, form):
|
||||
"""The form is valid, save the organization name and mailing address."""
|
||||
self.send_update_notification(form)
|
||||
|
||||
form.save()
|
||||
|
||||
messages.success(self.request, "The organization information for this domain has been updated.")
|
||||
|
@ -330,6 +430,8 @@ class DomainSeniorOfficialView(DomainFormBaseView):
|
|||
form.set_domain_info(self.object.domain_info)
|
||||
form.save()
|
||||
|
||||
self.send_update_notification(form)
|
||||
|
||||
messages.success(self.request, "The senior official for this domain has been updated.")
|
||||
|
||||
# superclass has the redirect
|
||||
|
@ -408,19 +510,25 @@ class DomainNameserversView(DomainFormBaseView):
|
|||
self._get_domain(request)
|
||||
formset = self.get_form()
|
||||
|
||||
logger.debug("got formet")
|
||||
|
||||
if "btn-cancel-click" in request.POST:
|
||||
url = self.get_success_url()
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if formset.is_valid():
|
||||
logger.debug("formset is valid")
|
||||
return self.form_valid(formset)
|
||||
else:
|
||||
logger.debug("formset is invalid")
|
||||
logger.debug(formset.errors)
|
||||
return self.form_invalid(formset)
|
||||
|
||||
def form_valid(self, formset):
|
||||
"""The formset is valid, perform something with it."""
|
||||
|
||||
self.request.session["nameservers_form_domain"] = self.object
|
||||
initial_state = self.object.state
|
||||
|
||||
# Set the nameservers from the formset
|
||||
nameservers = []
|
||||
|
@ -442,7 +550,6 @@ class DomainNameserversView(DomainFormBaseView):
|
|||
except KeyError:
|
||||
# no server information in this field, skip it
|
||||
pass
|
||||
|
||||
try:
|
||||
self.object.nameservers = nameservers
|
||||
except NameserverError as Err:
|
||||
|
@ -462,6 +569,8 @@ class DomainNameserversView(DomainFormBaseView):
|
|||
messages.error(self.request, NameserverError(code=nsErrorCodes.BAD_DATA))
|
||||
logger.error(f"Registry error: {Err}")
|
||||
else:
|
||||
if initial_state == Domain.State.READY:
|
||||
self.send_update_notification(formset)
|
||||
messages.success(
|
||||
self.request,
|
||||
"The name servers for this domain have been updated. "
|
||||
|
@ -514,7 +623,8 @@ class DomainDNSSECView(DomainFormBaseView):
|
|||
errmsg = "Error removing existing DNSSEC record(s)."
|
||||
logger.error(errmsg + ": " + err)
|
||||
messages.error(self.request, errmsg)
|
||||
|
||||
else:
|
||||
self.send_update_notification(form, force_send=True)
|
||||
return self.form_valid(form)
|
||||
|
||||
|
||||
|
@ -638,6 +748,8 @@ class DomainDsDataView(DomainFormBaseView):
|
|||
logger.error(f"Registry error: {err}")
|
||||
return self.form_invalid(formset)
|
||||
else:
|
||||
self.send_update_notification(formset)
|
||||
|
||||
messages.success(self.request, "The DS data records for this domain have been updated.")
|
||||
# superclass has the redirect
|
||||
return super().form_valid(formset)
|
||||
|
@ -704,8 +816,12 @@ class DomainSecurityEmailView(DomainFormBaseView):
|
|||
messages.error(self.request, SecurityEmailError(code=SecurityEmailErrorCodes.BAD_DATA))
|
||||
logger.error(f"Generic registry error: {Err}")
|
||||
else:
|
||||
self.send_update_notification(form)
|
||||
messages.success(self.request, "The security email for this domain has been updated.")
|
||||
|
||||
# superclass has the redirect
|
||||
return super().form_valid(form)
|
||||
|
||||
# superclass has the redirect
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
from django.http import JsonResponse
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Value, F, CharField, TextField, Q, Case, When
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
from django.db.models import Value, F, CharField, TextField, Q, Case, When, OuterRef, Subquery
|
||||
from django.db.models.expressions import Func
|
||||
from django.db.models.functions import Cast, Coalesce, Concat
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.urls import reverse
|
||||
from django.db.models.functions import Cast
|
||||
from django.views import View
|
||||
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.views.utility.mixins import PortfolioMembersPermission
|
||||
|
||||
|
||||
|
@ -42,6 +44,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
return JsonResponse(
|
||||
{
|
||||
"members": members,
|
||||
"UserPortfolioPermissionChoices": UserPortfolioPermissionChoices.to_dict(),
|
||||
"page": page_obj.number,
|
||||
"num_pages": paginator.num_pages,
|
||||
"has_previous": page_obj.has_previous(),
|
||||
|
@ -60,7 +63,11 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
first_name=F("user__first_name"),
|
||||
last_name=F("user__last_name"),
|
||||
email_display=F("user__email"),
|
||||
last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
|
||||
last_active=Coalesce(
|
||||
Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
|
||||
Value("Invalid date"),
|
||||
output_field=TextField(),
|
||||
),
|
||||
additional_permissions_display=F("additional_permissions"),
|
||||
member_display=Case(
|
||||
# If email is present and not blank, use email
|
||||
|
@ -78,6 +85,21 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
default=Value(""),
|
||||
output_field=CharField(),
|
||||
),
|
||||
domain_info=ArrayAgg(
|
||||
# an array of domains, with id and name, colon separated
|
||||
Concat(
|
||||
F("user__permissions__domain_id"),
|
||||
Value(":"),
|
||||
F("user__permissions__domain__name"),
|
||||
# specify the output_field to ensure union has same column types
|
||||
output_field=CharField(),
|
||||
),
|
||||
distinct=True,
|
||||
filter=Q(user__permissions__domain__isnull=False) # filter out null values
|
||||
& Q(
|
||||
user__permissions__domain__domain_info__portfolio=portfolio
|
||||
), # only include domains in portfolio
|
||||
),
|
||||
source=Value("permission", output_field=CharField()),
|
||||
)
|
||||
.values(
|
||||
|
@ -89,13 +111,20 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
"roles",
|
||||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"domain_info",
|
||||
"source",
|
||||
)
|
||||
)
|
||||
return permissions
|
||||
|
||||
def initial_invitations_search(self, portfolio):
|
||||
"""Perform initial invitations search before applying any filters."""
|
||||
"""Perform initial invitations search and get related DomainInvitation data based on the email."""
|
||||
# Get DomainInvitation query for matching email and for the portfolio
|
||||
domain_invitations = DomainInvitation.objects.filter(
|
||||
email=OuterRef("email"), # Check if email matches the OuterRef("email")
|
||||
domain__domain_info__portfolio=portfolio, # Check if the domain's portfolio matches the given portfolio
|
||||
).annotate(domain_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField()))
|
||||
# PortfolioInvitation query
|
||||
invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
|
||||
invitations = invitations.annotate(
|
||||
first_name=Value(None, output_field=CharField()),
|
||||
|
@ -104,6 +133,13 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
last_active=Value("Invited", output_field=TextField()),
|
||||
additional_permissions_display=F("additional_permissions"),
|
||||
member_display=F("email"),
|
||||
# Use ArrayRemove to return an empty list when no domain invitations are found
|
||||
domain_info=ArrayRemove(
|
||||
ArrayAgg(
|
||||
Subquery(domain_invitations.values("domain_info")),
|
||||
distinct=True,
|
||||
)
|
||||
),
|
||||
source=Value("invitation", output_field=CharField()),
|
||||
).values(
|
||||
"id",
|
||||
|
@ -114,6 +150,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
"roles",
|
||||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"domain_info",
|
||||
"source",
|
||||
)
|
||||
return invitations
|
||||
|
@ -156,13 +193,29 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
|
|||
# Serialize member data
|
||||
member_json = {
|
||||
"id": item.get("id", ""),
|
||||
"source": item.get("source", ""),
|
||||
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||
"email": item.get("email_display", ""),
|
||||
"member_display": item.get("member_display", ""),
|
||||
"roles": (item.get("roles") or []),
|
||||
"permissions": UserPortfolioPermission.get_portfolio_permissions(
|
||||
item.get("roles"), item.get("additional_permissions_display")
|
||||
),
|
||||
# split domain_info array values into ids to form urls, and names
|
||||
"domain_urls": [
|
||||
reverse("domain", kwargs={"pk": domain_info.split(":")[0]}) for domain_info in item.get("domain_info")
|
||||
],
|
||||
"domain_names": [domain_info.split(":")[1] for domain_info in item.get("domain_info")],
|
||||
"is_admin": is_admin,
|
||||
"last_active": item.get("last_active", ""),
|
||||
"last_active": item.get("last_active"),
|
||||
"action_url": action_url,
|
||||
"action_label": ("View" if view_only else "Manage"),
|
||||
"svg_icon": ("visibility" if view_only else "settings"),
|
||||
}
|
||||
return member_json
|
||||
|
||||
|
||||
# Custom Func to use array_remove to remove null values
|
||||
class ArrayRemove(Func):
|
||||
function = "array_remove"
|
||||
template = "%(function)s(%(expressions)s, NULL)"
|
||||
|
|
|
@ -1,75 +1,68 @@
|
|||
-i https://pypi.python.org/simple
|
||||
annotated-types==0.6.0; python_version >= '3.8'
|
||||
annotated-types==0.7.0; python_version >= '3.8'
|
||||
asgiref==3.8.1; python_version >= '3.8'
|
||||
boto3==1.34.95; python_version >= '3.8'
|
||||
botocore==1.34.95; python_version >= '3.8'
|
||||
cachetools==5.3.3; python_version >= '3.7'
|
||||
certifi==2024.2.2; python_version >= '3.6'
|
||||
boto3==1.35.41; python_version >= '3.8'
|
||||
botocore==1.35.41; python_version >= '3.8'
|
||||
cachetools==5.5.0; python_version >= '3.7'
|
||||
certifi==2024.8.30; python_version >= '3.6'
|
||||
cfenv==0.5.3
|
||||
cffi==1.16.0; platform_python_implementation != 'PyPy'
|
||||
charset-normalizer==3.3.2; python_full_version >= '3.7.0'
|
||||
cryptography==42.0.5; python_version >= '3.7'
|
||||
cffi==1.17.1; platform_python_implementation != 'PyPy'
|
||||
charset-normalizer==3.4.0; python_full_version >= '3.7.0'
|
||||
cryptography==43.0.1; python_version >= '3.7'
|
||||
defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
|
||||
diff-match-patch==20230430; python_version >= '3.7'
|
||||
dj-database-url==2.1.0
|
||||
dj-database-url==2.2.0
|
||||
dj-email-url==1.0.6
|
||||
django==4.2.10; python_version >= '3.8'
|
||||
django-admin-multiple-choice-list-filter==0.1.1
|
||||
django-allow-cidr==0.7.1
|
||||
django-auditlog==3.0.0; python_version >= '3.8'
|
||||
django-cache-url==3.4.5
|
||||
django-cors-headers==4.3.1; python_version >= '3.8'
|
||||
django-cors-headers==4.5.0; python_version >= '3.9'
|
||||
django-csp==3.8
|
||||
django-fsm==2.8.1
|
||||
django-import-export==3.3.8; python_version >= '3.8'
|
||||
django-import-export==4.1.1; python_version >= '3.8'
|
||||
django-login-required-middleware==0.9.0
|
||||
django-phonenumber-field[phonenumberslite]==7.3.0; python_version >= '3.8'
|
||||
django-phonenumber-field[phonenumberslite]==8.0.0; python_version >= '3.8'
|
||||
django-waffle==4.1.0; python_version >= '3.8'
|
||||
django-widget-tweaks==1.5.0; python_version >= '3.8'
|
||||
environs[django]==11.0.0; python_version >= '3.8'
|
||||
et-xmlfile==1.1.0; python_version >= '3.6'
|
||||
faker==25.0.0; python_version >= '3.8'
|
||||
faker==30.3.0; python_version >= '3.8'
|
||||
fred-epplib@ git+https://github.com/cisagov/epplib.git@d56d183f1664f34c40ca9716a3a9a345f0ef561c
|
||||
furl==2.1.3
|
||||
future==1.0.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
|
||||
gevent==24.2.1; python_version >= '3.8'
|
||||
greenlet==3.0.3; python_version >= '3.7'
|
||||
gunicorn==22.0.0; python_version >= '3.7'
|
||||
idna==3.7; python_version >= '3.5'
|
||||
gevent==24.10.2; python_version >= '3.9'
|
||||
greenlet==3.1.1; python_version >= '3.7'
|
||||
gunicorn==23.0.0; python_version >= '3.7'
|
||||
idna==3.10; python_version >= '3.6'
|
||||
jmespath==1.0.1; python_version >= '3.7'
|
||||
lxml==5.2.1; python_version >= '3.6'
|
||||
mako==1.3.3; python_version >= '3.8'
|
||||
markuppy==1.14
|
||||
markupsafe==2.1.5; python_version >= '3.7'
|
||||
marshmallow==3.21.1; python_version >= '3.8'
|
||||
odfpy==1.4.1
|
||||
lxml==5.3.0; python_version >= '3.6'
|
||||
mako==1.3.5; python_version >= '3.8'
|
||||
markupsafe==3.0.1; python_version >= '3.9'
|
||||
marshmallow==3.22.0; python_version >= '3.8'
|
||||
oic==1.7.0; python_version ~= '3.8'
|
||||
openpyxl==3.1.2
|
||||
orderedmultidict==1.0.1
|
||||
packaging==24.0; python_version >= '3.7'
|
||||
phonenumberslite==8.13.35
|
||||
packaging==24.1; python_version >= '3.8'
|
||||
phonenumberslite==8.13.47
|
||||
psycopg2-binary==2.9.9; python_version >= '3.7'
|
||||
pycparser==2.22; python_version >= '3.8'
|
||||
pycryptodomex==3.20.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
|
||||
pydantic==2.7.1; python_version >= '3.8'
|
||||
pydantic-core==2.18.2; python_version >= '3.8'
|
||||
pydantic-settings==2.2.1; python_version >= '3.8'
|
||||
pycryptodomex==3.21.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
|
||||
pydantic==2.9.2; python_version >= '3.8'
|
||||
pydantic-core==2.23.4; python_version >= '3.8'
|
||||
pydantic-settings==2.5.2; python_version >= '3.8'
|
||||
pyjwkest==1.4.2
|
||||
python-dateutil==2.9.0.post0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
|
||||
python-dotenv==1.0.1; python_version >= '3.8'
|
||||
pyyaml==6.0.1
|
||||
pyzipper==0.3.6; python_version >= '3.4'
|
||||
requests==2.31.0; python_version >= '3.7'
|
||||
s3transfer==0.10.1; python_version >= '3.8'
|
||||
setuptools==69.5.1; python_version >= '3.8'
|
||||
requests==2.32.3; python_version >= '3.8'
|
||||
s3transfer==0.10.3; python_version >= '3.8'
|
||||
setuptools==75.1.0; python_version >= '3.8'
|
||||
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
|
||||
sqlparse==0.5.0; python_version >= '3.8'
|
||||
sqlparse==0.5.1; python_version >= '3.8'
|
||||
tablib[html,ods,xls,xlsx,yaml]==3.5.0; python_version >= '3.8'
|
||||
tblib==3.0.0; python_version >= '3.8'
|
||||
typing-extensions==4.11.0; python_version >= '3.8'
|
||||
urllib3==2.2.1; python_version >= '3.8'
|
||||
whitenoise==6.6.0; python_version >= '3.8'
|
||||
xlrd==2.0.1
|
||||
xlwt==1.3.0
|
||||
typing-extensions==4.12.2; python_version >= '3.8'
|
||||
urllib3==2.2.3; python_version >= '3.8'
|
||||
whitenoise==6.7.0; python_version >= '3.8'
|
||||
zope.event==5.0; python_version >= '3.7'
|
||||
zope.interface==6.3; python_version >= '3.7'
|
||||
zope.interface==7.1.0; python_version >= '3.8'
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue