merge main, fix permissions in tests, fix returned permissions in serialize_members

This commit is contained in:
Rachid Mrad 2024-10-24 13:58:28 -04:00
commit 85f2958e36
No known key found for this signature in database
31 changed files with 2407 additions and 1270 deletions

2009
src/Pipfile.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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');

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -28,3 +28,8 @@ h2 {
.usa-form fieldset {
font-size: 1rem;
}
.p--blockquote {
padding-left: units(1);
border-left: 2px solid color('base-lighter');
}

View file

@ -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)

View file

@ -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.

View file

@ -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",

View file

@ -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()

View file

@ -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):

View file

@ -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."
)

View file

@ -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()}

View file

@ -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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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">

View 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?
Youre listed as a domain manager for {{domain}}, so youll 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 %}

View file

@ -0,0 +1 @@
An update was made to {{domain}}

View file

@ -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>

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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"}

View file

@ -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'.

View file

@ -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())

View file

@ -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)"

View file

@ -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'