Merge branch 'main' into nl/3144-add-filter-skip-link

This commit is contained in:
zandercymatics 2025-02-19 10:12:58 -07:00
commit e2ed60a432
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
24 changed files with 1483 additions and 792 deletions

1030
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -22,3 +22,13 @@ export function addOrRemoveSessionBoolean(name, add){
sessionStorage.removeItem(name);
}
}
export function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}

View file

@ -15,6 +15,7 @@ import { initDomainFormTargetBlankButtons } from './domain-form.js';
import { initDynamicPortfolioFields } from './portfolio-form.js';
import { initDynamicDomainInformationFields } from './domain-information-form.js';
import { initDynamicDomainFields } from './domain-form.js';
import { initAnalyticsDashboard } from './analytics.js';
// General
initModals();
@ -41,3 +42,6 @@ initDynamicPortfolioFields();
// Domain information
initDynamicDomainInformationFields();
// Analytics dashboard
initAnalyticsDashboard();

View file

@ -128,7 +128,7 @@ export function initAddNewMemberPageListeners() {
});
} else {
// for admin users, the permissions are always the same
appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer);
appendPermissionInContainer('Domains', 'Viewer', permissionDetailsContainer);
appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer);
appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer);
}

View file

@ -558,13 +558,18 @@ details.dja-detail-table {
background-color: transparent;
}
thead tr {
background-color: var(--darkened-bg);
}
td, th {
padding-left: 12px;
border: none
border: none;
background-color: var(--darkened-bg);
color: var(--body-quiet-color);
}
thead > tr > th {
border-radius: 4px;
border-top: none;
border-bottom: none;
}
@ -946,3 +951,34 @@ ul.add-list-reset {
background-color: transparent !important;
}
}
@media (min-width: 1080px) {
.analytics-dashboard-charts {
// Desktop layout - charts in top row, details in bottom row
display: grid;
gap: 2rem;
// Equal columns each gets 1/2 of the space
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"chart1 chart2"
"details1 details2"
"chart3 chart4"
"details3 details4"
"chart5 chart6"
"details5 details6";
.chart-1 { grid-area: chart1; }
.chart-2 { grid-area: chart2; }
.chart-3 { grid-area: chart3; }
.chart-4 { grid-area: chart4; }
.chart-5 { grid-area: chart5; }
.chart-6 { grid-area: chart6; }
.details-1 { grid-area: details1; }
.details-2 { grid-area: details2; }
.details-3 { grid-area: details3; }
.details-4 { grid-area: details4; }
.details-5 { grid-area: details5; }
.details-6 { grid-area: details6; }
}
}

View file

@ -127,7 +127,7 @@ class BasePortfolioMemberForm(forms.ModelForm):
domain_permissions = forms.ChoiceField(
choices=[
(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"),
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"),
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer"),
],
widget=forms.RadioSelect,
required=False,
@ -338,6 +338,24 @@ class BasePortfolioMemberForm(forms.ModelForm):
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
)
def is_change(self) -> bool:
"""
Determines if the form has changed by comparing the initial data
with the submitted cleaned data.
Returns:
bool: True if the form has changed, False otherwise.
"""
# Compare role values
previous_roles = set(self.initial.get("roles", []))
new_roles = set(self.cleaned_data.get("roles", []))
# Compare additional permissions values
previous_permissions = set(self.initial.get("additional_permissions") or [])
new_permissions = set(self.cleaned_data.get("additional_permissions") or [])
return previous_roles != new_roles or previous_permissions != new_permissions
class PortfolioMemberForm(BasePortfolioMemberForm):
"""

View file

@ -9,6 +9,10 @@ from .utility.portfolio_helper import (
UserPortfolioPermissionChoices,
UserPortfolioRoleChoices,
cleanup_after_portfolio_member_deletion,
get_domain_requests_display,
get_domains_display,
get_members_display,
get_role_display,
validate_portfolio_invitation,
) # type: ignore
from .utility.time_stamped_model import TimeStampedModel
@ -85,6 +89,60 @@ class PortfolioInvitation(TimeStampedModel):
"""
return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
@property
def role_display(self):
"""
Returns a human-readable display name for the user's role.
Uses the `get_role_display` function to determine if the user is an "Admin",
"Basic" member, or has no role assigned.
Returns:
str: The display name of the user's role.
"""
return get_role_display(self.roles)
@property
def domains_display(self):
"""
Returns a string representation of the user's domain access level.
Uses the `get_domains_display` function to determine whether the user has
"Viewer" access (can view all domains) or "Viewer, limited" access.
Returns:
str: The display name of the user's domain permissions.
"""
return get_domains_display(self.roles, self.additional_permissions)
@property
def domain_requests_display(self):
"""
Returns a string representation of the user's access to domain requests.
Uses the `get_domain_requests_display` function to determine if the user
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
or has "No access" to domain requests.
Returns:
str: The display name of the user's domain request permissions.
"""
return get_domain_requests_display(self.roles, self.additional_permissions)
@property
def members_display(self):
"""
Returns a string representation of the user's access to managing members.
Uses the `get_members_display` function to determine if the user is a
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
to member management.
Returns:
str: The display name of the user's member management permissions.
"""
return get_members_display(self.roles, self.additional_permissions)
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission.

View file

@ -6,6 +6,10 @@ from registrar.models.utility.portfolio_helper import (
DomainRequestPermissionDisplay,
MemberPermissionDisplay,
cleanup_after_portfolio_member_deletion,
get_domain_requests_display,
get_domains_display,
get_members_display,
get_role_display,
validate_user_portfolio_permission,
)
from .utility.time_stamped_model import TimeStampedModel
@ -181,6 +185,60 @@ class UserPortfolioPermission(TimeStampedModel):
# This is the same as portfolio_permissions & common_forbidden_perms.
return portfolio_permissions.intersection(common_forbidden_perms)
@property
def role_display(self):
"""
Returns a human-readable display name for the user's role.
Uses the `get_role_display` function to determine if the user is an "Admin",
"Basic" member, or has no role assigned.
Returns:
str: The display name of the user's role.
"""
return get_role_display(self.roles)
@property
def domains_display(self):
"""
Returns a string representation of the user's domain access level.
Uses the `get_domains_display` function to determine whether the user has
"Viewer" access (can view all domains) or "Viewer, limited" access.
Returns:
str: The display name of the user's domain permissions.
"""
return get_domains_display(self.roles, self.additional_permissions)
@property
def domain_requests_display(self):
"""
Returns a string representation of the user's access to domain requests.
Uses the `get_domain_requests_display` function to determine if the user
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
or has "No access" to domain requests.
Returns:
str: The display name of the user's domain request permissions.
"""
return get_domain_requests_display(self.roles, self.additional_permissions)
@property
def members_display(self):
"""
Returns a string representation of the user's access to managing members.
Uses the `get_members_display` function to determine if the user is a
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
to member management.
Returns:
str: The display name of the user's member management permissions.
"""
return get_members_display(self.roles, self.additional_permissions)
def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean()

View file

@ -79,6 +79,100 @@ class MemberPermissionDisplay(StrEnum):
NONE = "None"
def get_role_display(roles):
"""
Returns a user-friendly display name for a given list of user roles.
- If the user has the ORGANIZATION_ADMIN role, return "Admin".
- If the user has the ORGANIZATION_MEMBER role, return "Basic".
- If the user has neither role, return "-".
Args:
roles (list): A list of role strings assigned to the user.
Returns:
str: The display name for the highest applicable role.
"""
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles:
return "Admin"
elif UserPortfolioRoleChoices.ORGANIZATION_MEMBER in roles:
return "Basic"
else:
return "-"
def get_domains_display(roles, permissions):
"""
Determines the display name for a user's domain viewing permissions.
- If the user has the VIEW_ALL_DOMAINS permission, return "Viewer".
- Otherwise, return "Viewer, limited".
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain viewing access.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
return "Viewer"
else:
return "Viewer, limited"
def get_domain_requests_display(roles, permissions):
"""
Determines the display name for a user's domain request permissions.
- If the user has the EDIT_REQUESTS permission, return "Creator".
- If the user has the VIEW_ALL_REQUESTS permission, return "Viewer".
- Otherwise, return "No access".
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain request access level.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
return "Creator"
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
return "Viewer"
else:
return "No access"
def get_members_display(roles, permissions):
"""
Determines the display name for a user's member management permissions.
- If the user has the EDIT_MEMBERS permission, return "Manager".
- If the user has the VIEW_MEMBERS permission, return "Viewer".
- Otherwise, return "No access".
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's member management access level.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
return "Manager"
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
return "Viewer"
else:
return "No access"
def validate_user_portfolio_permission(user_portfolio_permission):
"""
Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports

View file

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

View file

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

View file

@ -22,7 +22,6 @@
<script src="{% static 'js/uswds.min.js' %}" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script type="application/javascript" src="{% static 'js/getgov-admin.min.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
Your permissions were updated in the .gov registrar.
ORGANIZATION: {{ portfolio.organization_name }}
UPDATED BY: {{ requestor_email }}
UPDATED ON: {{ date }}
YOUR PERMISSIONS: {{ permissions.role_display }}
Domains - {{ permissions.domains_display }}
Domain requests - {{ permissions.domain_requests_display }}
Members - {{ permissions.members_display }}
Your updated permissions are now active in the .gov registrar <https://manage.get.gov>.
----------------------------------------------------------------
SOMETHING WRONG?
If you have questions or concerns, reach out to the person who updated your
permissions, or reply to this email.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov
domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
(CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
Your permissions were updated in the .gov registrar

View file

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

View file

@ -16,6 +16,7 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
send_portfolio_member_permission_update_email,
)
from api.tests.common import less_console_noise_decorator
@ -522,7 +523,6 @@ class PortfolioInvitationEmailTests(unittest.TestCase):
"registrar.utility.email_invitations._get_requestor_email",
side_effect=MissingEmailError("Requestor has no email"),
)
@less_console_noise_decorator
def test_send_portfolio_invitation_email_missing_requestor_email(self, mock_get_email):
"""Test when requestor has no email"""
is_admin_invitation = False
@ -888,3 +888,78 @@ class SendPortfolioAdminRemovalEmailsTests(unittest.TestCase):
mock_get_requestor_email.assert_called_once_with(self.requestor, portfolio=self.portfolio)
mock_send_removal_emails.assert_called_once_with(self.email, self.requestor.email, self.portfolio)
self.assertFalse(result)
class TestSendPortfolioMemberPermissionUpdateEmail(unittest.TestCase):
"""Unit tests for send_portfolio_member_permission_update_email function."""
@patch("registrar.utility.email_invitations.send_templated_email")
@patch("registrar.utility.email_invitations._get_requestor_email")
def test_send_email_success(self, mock_get_requestor_email, mock_send_email):
"""Test that the email is sent successfully when there are no errors."""
# Mock data
requestor = MagicMock()
permissions = MagicMock(spec=UserPortfolioPermission)
permissions.user.email = "user@example.com"
permissions.portfolio.organization_name = "Test Portfolio"
mock_get_requestor_email.return_value = "requestor@example.com"
# Call function
result = send_portfolio_member_permission_update_email(requestor, permissions)
# Assertions
mock_get_requestor_email.assert_called_once_with(requestor, portfolio=permissions.portfolio)
mock_send_email.assert_called_once_with(
"emails/portfolio_update.txt",
"emails/portfolio_update_subject.txt",
to_address="user@example.com",
context={
"requested_user": permissions.user,
"portfolio": permissions.portfolio,
"requestor_email": "requestor@example.com",
"permissions": permissions,
"date": date.today(),
},
)
self.assertTrue(result)
@patch("registrar.utility.email_invitations.send_templated_email", side_effect=EmailSendingError("Email failed"))
@patch("registrar.utility.email_invitations._get_requestor_email")
@patch("registrar.utility.email_invitations.logger")
def test_send_email_failure(self, mock_logger, mock_get_requestor_email, mock_send_email):
"""Test that the function returns False and logs an error when email sending fails."""
# Mock data
requestor = MagicMock()
permissions = MagicMock(spec=UserPortfolioPermission)
permissions.user.email = "user@example.com"
permissions.portfolio.organization_name = "Test Portfolio"
mock_get_requestor_email.return_value = "requestor@example.com"
# Call function
result = send_portfolio_member_permission_update_email(requestor, permissions)
# Assertions
mock_logger.warning.assert_called_once_with(
"Could not send email organization member update notification to %s for portfolio: %s",
permissions.user.email,
permissions.portfolio.organization_name,
exc_info=True,
)
self.assertFalse(result)
@patch("registrar.utility.email_invitations._get_requestor_email", side_effect=Exception("Unexpected error"))
@patch("registrar.utility.email_invitations.logger")
def test_requestor_email_retrieval_failure(self, mock_logger, mock_get_requestor_email):
"""Test that an exception in retrieving requestor email is logged."""
# Mock data
requestor = MagicMock()
permissions = MagicMock(spec=UserPortfolioPermission)
# Call function
with self.assertRaises(Exception):
send_portfolio_member_permission_update_email(requestor, permissions)
# Assertions
mock_logger.warning.assert_not_called() # Function should fail before logging email failure

View file

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

View file

@ -1063,7 +1063,7 @@ class TestPortfolio(WebTest):
self.assertContains(response, "Invited")
self.assertContains(response, portfolio_invitation.email)
self.assertContains(response, "Admin")
self.assertContains(response, "Viewer, all")
self.assertContains(response, "Viewer")
self.assertContains(response, "Creator")
self.assertContains(response, "Manager")
self.assertContains(
@ -3970,7 +3970,10 @@ class TestPortfolioMemberEditView(WebTest):
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_edit_member_permissions_basic_to_admin(self, mock_send_removal_emails, mock_send_addition_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
def test_edit_member_permissions_basic_to_admin(
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails
):
"""Tests converting a basic member to admin with full permissions."""
self.client.force_login(self.user)
@ -3985,6 +3988,7 @@ class TestPortfolioMemberEditView(WebTest):
# return indicator that notification emails sent successfully
mock_send_addition_emails.return_value = True
mock_send_update_email.return_value = True
response = self.client.post(
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
@ -4004,6 +4008,8 @@ class TestPortfolioMemberEditView(WebTest):
mock_send_addition_emails.assert_called_once()
# assert removal emails are not sent
mock_send_removal_emails.assert_not_called()
# assert update email sent
mock_send_update_email.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_addition_emails.call_args
@ -4013,14 +4019,22 @@ class TestPortfolioMemberEditView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Get the arguments passed to send_portfolio_member_permission_update_email
_, called_kwargs = mock_send_update_email.call_args
# Assert the update notification email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"], basic_permission)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("django.contrib.messages.warning")
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
def test_edit_member_permissions_basic_to_admin_notification_fails(
self, mock_send_removal_emails, mock_send_addition_emails, mock_messages_warning
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails, mock_messages_warning
):
"""Tests converting a basic member to admin with full permissions.
Handle when notification emails fail to send."""
@ -4037,6 +4051,7 @@ class TestPortfolioMemberEditView(WebTest):
# At least one notification email failed to send
mock_send_addition_emails.return_value = False
mock_send_update_email.return_value = False
response = self.client.post(
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
@ -4056,6 +4071,8 @@ class TestPortfolioMemberEditView(WebTest):
mock_send_addition_emails.assert_called_once()
# assert no removal emails are sent
mock_send_removal_emails.assert_not_called()
# assert update email sent
mock_send_update_email.assert_called_once()
# Get the arguments passed to send_portfolio_admin_addition_emails
_, called_kwargs = mock_send_addition_emails.call_args
@ -4065,18 +4082,32 @@ class TestPortfolioMemberEditView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Assert warning message is called correctly
mock_messages_warning.assert_called_once()
warning_args, _ = mock_messages_warning.call_args
self.assertIsInstance(warning_args[0], WSGIRequest)
self.assertEqual(warning_args[1], "Could not send email notification to existing organization admins.")
# Get the arguments passed to send_portfolio_member_permission_update_email
_, called_kwargs = mock_send_update_email.call_args
# Assert the update notification email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"], basic_permission)
# Assert that messages.warning is called twice
self.assertEqual(mock_messages_warning.call_count, 2)
# Extract the actual messages sent
warning_messages = [call_args[0][1] for call_args in mock_messages_warning.call_args_list]
# Check for the expected messages
self.assertIn("Could not send email notification to existing organization admins.", warning_messages)
self.assertIn(f"Could not send email notification to {basic_member.email}.", warning_messages)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_edit_member_permissions_admin_to_admin(self, mock_send_removal_emails, mock_send_addition_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
def test_edit_member_permissions_admin_to_admin(
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails
):
"""Tests updating an admin without changing permissions."""
self.client.force_login(self.user)
@ -4086,6 +4117,7 @@ class TestPortfolioMemberEditView(WebTest):
user=admin_member,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[],
)
response = self.client.post(
@ -4098,16 +4130,20 @@ class TestPortfolioMemberEditView(WebTest):
# Verify redirect and success message
self.assertEqual(response.status_code, 302)
# assert addition and removal emails are not sent to portfolio admins
# assert update, addition and removal emails are not sent to portfolio admins
mock_send_addition_emails.assert_not_called()
mock_send_removal_emails.assert_not_called()
mock_send_update_email.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_edit_member_permissions_basic_to_basic(self, mock_send_removal_emails, mock_send_addition_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
def test_edit_member_permissions_basic_to_basic(
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails
):
"""Tests updating an admin without changing permissions."""
self.client.force_login(self.user)
@ -4120,6 +4156,8 @@ class TestPortfolioMemberEditView(WebTest):
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
mock_send_update_email.return_value = True
response = self.client.post(
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
{
@ -4136,13 +4174,25 @@ class TestPortfolioMemberEditView(WebTest):
# assert addition and removal emails are not sent to portfolio admins
mock_send_addition_emails.assert_not_called()
mock_send_removal_emails.assert_not_called()
# assert update email is sent to updated member
mock_send_update_email.assert_called_once()
# Get the arguments passed to send_portfolio_member_permission_update_email
_, called_kwargs = mock_send_update_email.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"], basic_permission)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
def test_edit_member_permissions_admin_to_basic(self, mock_send_removal_emails, mock_send_addition_emails):
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
def test_edit_member_permissions_admin_to_basic(
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails
):
"""Tests converting an admin to basic member."""
self.client.force_login(self.user)
@ -4153,8 +4203,9 @@ class TestPortfolioMemberEditView(WebTest):
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
print(admin_permission)
mock_send_removal_emails.return_value = True
mock_send_update_email.return_value = True
response = self.client.post(
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
@ -4173,7 +4224,8 @@ class TestPortfolioMemberEditView(WebTest):
admin_permission.refresh_from_db()
self.assertEqual(admin_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_MEMBER])
# assert removal emails are sent to portfolio admins
# assert removal emails and update email are sent to portfolio admins
mock_send_update_email.assert_called_once()
mock_send_addition_emails.assert_not_called()
mock_send_removal_emails.assert_called_once()
@ -4185,14 +4237,22 @@ class TestPortfolioMemberEditView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Get the arguments passed to send_portfolio_member_permission_update_email
_, called_kwargs = mock_send_update_email.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"], admin_permission)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("django.contrib.messages.warning")
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
@patch("registrar.views.portfolios.send_portfolio_admin_removal_emails")
@patch("registrar.views.portfolios.send_portfolio_member_permission_update_email")
def test_edit_member_permissions_admin_to_basic_notification_fails(
self, mock_send_removal_emails, mock_send_addition_emails, mock_messages_warning
self, mock_send_update_email, mock_send_removal_emails, mock_send_addition_emails, mock_messages_warning
):
"""Tests converting an admin to basic member."""
self.client.force_login(self.user)
@ -4208,6 +4268,7 @@ class TestPortfolioMemberEditView(WebTest):
# False return indicates that at least one notification email failed to send
mock_send_removal_emails.return_value = False
mock_send_update_email.return_value = False
response = self.client.post(
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
@ -4226,9 +4287,10 @@ class TestPortfolioMemberEditView(WebTest):
admin_permission.refresh_from_db()
self.assertEqual(admin_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_MEMBER])
# assert removal emails are sent to portfolio admins
# assert update email and removal emails are sent to portfolio admins
mock_send_addition_emails.assert_not_called()
mock_send_removal_emails.assert_called_once()
mock_send_update_email.assert_called_once()
# Get the arguments passed to send_portfolio_admin_removal_emails
_, called_kwargs = mock_send_removal_emails.call_args
@ -4238,11 +4300,22 @@ class TestPortfolioMemberEditView(WebTest):
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Assert warning message is called correctly
mock_messages_warning.assert_called_once()
warning_args, _ = mock_messages_warning.call_args
self.assertIsInstance(warning_args[0], WSGIRequest)
self.assertEqual(warning_args[1], "Could not send email notification to existing organization admins.")
# Get the arguments passed to send_portfolio_member_permission_update_email
_, called_kwargs = mock_send_update_email.call_args
# Assert the email content
self.assertEqual(called_kwargs["requestor"], self.user)
self.assertEqual(called_kwargs["permissions"], admin_permission)
# Assert that messages.warning is called twice
self.assertEqual(mock_messages_warning.call_count, 2)
# Extract the actual messages sent
warning_messages = [call_args[0][1] for call_args in mock_messages_warning.call_args_list]
# Check for the expected messages
self.assertIn("Could not send email notification to existing organization admins.", warning_messages)
self.assertIn(f"Could not send email notification to {admin_member.email}.", warning_messages)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)

View file

@ -226,6 +226,49 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_i
return all_admin_emails_sent
def send_portfolio_member_permission_update_email(requestor, permissions: UserPortfolioPermission):
"""
Sends an email notification to a portfolio member when their permissions are updated.
This function retrieves the requestor's email and sends a templated email to the affected user,
notifying them of changes to their portfolio permissions.
Args:
requestor (User): The user initiating the permission update.
permissions (UserPortfolioPermission): The updated permissions object containing the affected user
and the portfolio details.
Returns:
bool: True if the email was sent successfully, False if an EmailSendingError occurred.
Raises:
MissingEmailError: If the requestor has no email associated with their account.
"""
requestor_email = _get_requestor_email(requestor, portfolio=permissions.portfolio)
try:
send_templated_email(
"emails/portfolio_update.txt",
"emails/portfolio_update_subject.txt",
to_address=permissions.user.email,
context={
"requested_user": permissions.user,
"portfolio": permissions.portfolio,
"requestor_email": requestor_email,
"permissions": permissions,
"date": date.today(),
},
)
except EmailSendingError:
logger.warning(
"Could not send email organization member update notification to %s " "for portfolio: %s",
permissions.user.email,
permissions.portfolio.organization_name,
exc_info=True,
)
return False
return True
def send_portfolio_admin_addition_emails(email: str, requestor, portfolio: Portfolio):
"""
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin

View file

@ -20,6 +20,7 @@ from registrar.utility.email_invitations import (
send_portfolio_admin_addition_emails,
send_portfolio_admin_removal_emails,
send_portfolio_invitation_email,
send_portfolio_member_permission_update_email,
)
from registrar.utility.errors import MissingEmailError
from registrar.utility.enums import DefaultUserValues
@ -212,6 +213,11 @@ class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
removing_admin_role_on_self = False
if form.is_valid():
try:
if form.is_change():
if not send_portfolio_member_permission_update_email(
requestor=request.user, permissions=form.instance
):
messages.warning(self.request, f"Could not send email notification to {user.email}.")
if form.is_change_from_member_to_admin():
if not send_portfolio_admin_addition_emails(
email=portfolio_permission.user.email,

View file

@ -126,28 +126,54 @@ class AnalyticsView(View):
# include it in the larger context dictionary so it's available in the template rendering context.
# This ensures that the admin interface styling and behavior are consistent with other admin pages.
**admin.site.each_context(request),
data=dict(
user_count=models.User.objects.all().count(),
domain_count=models.Domain.objects.all().count(),
ready_domain_count=models.Domain.objects.filter(state=models.Domain.State.READY).count(),
last_30_days_applications=last_30_days_applications.count(),
last_30_days_approved_applications=last_30_days_approved_applications.count(),
average_application_approval_time_last_30_days=avg_approval_time_display,
managed_domains_sliced_at_start_date=managed_domains_sliced_at_start_date,
unmanaged_domains_sliced_at_start_date=unmanaged_domains_sliced_at_start_date,
managed_domains_sliced_at_end_date=managed_domains_sliced_at_end_date,
unmanaged_domains_sliced_at_end_date=unmanaged_domains_sliced_at_end_date,
ready_domains_sliced_at_start_date=ready_domains_sliced_at_start_date,
deleted_domains_sliced_at_start_date=deleted_domains_sliced_at_start_date,
ready_domains_sliced_at_end_date=ready_domains_sliced_at_end_date,
deleted_domains_sliced_at_end_date=deleted_domains_sliced_at_end_date,
requests_sliced_at_start_date=requests_sliced_at_start_date,
submitted_requests_sliced_at_start_date=submitted_requests_sliced_at_start_date,
requests_sliced_at_end_date=requests_sliced_at_end_date,
submitted_requests_sliced_at_end_date=submitted_requests_sliced_at_end_date,
start_date=start_date,
end_date=end_date,
),
data={
# Tracks what kind of orgs we are keeping count of.
# Used for the details table beneath the graph.
"org_count_types": [
"Total",
"Federal",
"Interstate",
"State/Territory",
"Tribal",
"County",
"City",
"Special District",
"School District",
"Election Board",
],
"user_count": models.User.objects.all().count(),
"domain_count": models.Domain.objects.all().count(),
"ready_domain_count": models.Domain.objects.filter(state=models.Domain.State.READY).count(),
"last_30_days_applications": last_30_days_applications.count(),
"last_30_days_approved_applications": last_30_days_approved_applications.count(),
"average_application_approval_time_last_30_days": avg_approval_time_display,
"managed_domains": {
"start_date_count": managed_domains_sliced_at_start_date,
"end_date_count": managed_domains_sliced_at_end_date,
},
"unmanaged_domains": {
"start_date_count": unmanaged_domains_sliced_at_start_date,
"end_date_count": unmanaged_domains_sliced_at_end_date,
},
"ready_domains": {
"start_date_count": ready_domains_sliced_at_start_date,
"end_date_count": ready_domains_sliced_at_end_date,
},
"deleted_domains": {
"start_date_count": deleted_domains_sliced_at_start_date,
"end_date_count": deleted_domains_sliced_at_end_date,
},
"requests": {
"start_date_count": requests_sliced_at_start_date,
"end_date_count": requests_sliced_at_end_date,
},
"submitted_requests": {
"start_date_count": submitted_requests_sliced_at_start_date,
"end_date_count": submitted_requests_sliced_at_end_date,
},
"start_date": start_date,
"end_date": end_date,
},
)
return render(request, "admin/analytics.html", context)