diff --git a/src/registrar/assets/js/get-gov-reports.js b/src/registrar/assets/js/get-gov-reports.js
deleted file mode 100644
index b82a5574f..000000000
--- a/src/registrar/assets/js/get-gov-reports.js
+++ /dev/null
@@ -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();
-})();
diff --git a/src/registrar/assets/src/js/getgov-admin/analytics.js b/src/registrar/assets/src/js/getgov-admin/analytics.js
new file mode 100644
index 000000000..47bc81388
--- /dev/null
+++ b/src/registrar/assets/src/js/getgov-admin/analytics.js
@@ -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));
+ });
+ }
+};
diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-admin.js b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js
index ff618a67d..8055e29d3 100644
--- a/src/registrar/assets/src/js/getgov-admin/helpers-admin.js
+++ b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js
@@ -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, ' '));
+}
diff --git a/src/registrar/assets/src/js/getgov-admin/main.js b/src/registrar/assets/src/js/getgov-admin/main.js
index 64be572b2..5c6de20ab 100644
--- a/src/registrar/assets/src/js/getgov-admin/main.js
+++ b/src/registrar/assets/src/js/getgov-admin/main.js
@@ -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();
diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js
index c96677ebc..95723fc7e 100644
--- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js
+++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js
@@ -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);
}
diff --git a/src/registrar/assets/src/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss
index 322e94bf0..a15d1eabe 100644
--- a/src/registrar/assets/src/sass/_theme/_admin.scss
+++ b/src/registrar/assets/src/sass/_theme/_admin.scss
@@ -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; }
+ }
+
+}
diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py
index 2725224f1..9824ed68a 100644
--- a/src/registrar/forms/portfolio.py
+++ b/src/registrar/forms/portfolio.py
@@ -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):
"""
diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py
index 8feeb0794..99febc92e 100644
--- a/src/registrar/models/portfolio_invitation.py
+++ b/src/registrar/models/portfolio_invitation.py
@@ -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.
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index 5378dc185..e077daa57 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -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()
diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py
index 5feae1cc1..03733237e 100644
--- a/src/registrar/models/utility/portfolio_helper.py
+++ b/src/registrar/models/utility/portfolio_helper.py
@@ -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
diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html
index ccfd54d05..fdebff22c 100644
--- a/src/registrar/templates/admin/analytics.html
+++ b/src/registrar/templates/admin/analytics.html
@@ -18,7 +18,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% block content %}
-
diff --git a/src/registrar/templates/admin/analytics_graph_table.html b/src/registrar/templates/admin/analytics_graph_table.html
new file mode 100644
index 000000000..5f10da93a
--- /dev/null
+++ b/src/registrar/templates/admin/analytics_graph_table.html
@@ -0,0 +1,26 @@
+
+
+
+ Type |
+ Start date {{ data.start_date }} |
+ End date {{ data.end_date }} |
+
+
+
+ {% 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 %}
+
+ {{ org_count_type }} |
+ {{ start_counts|slice:index|last }} |
+ {{ end_counts|slice:index|last }} |
+
+ {% endwith %}
+ {% endfor %}
+ {% endwith %}
+
+
diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html
index b80917bb2..e5b3604b4 100644
--- a/src/registrar/templates/admin/base_site.html
+++ b/src/registrar/templates/admin/base_site.html
@@ -22,7 +22,6 @@
-
{% endblock %}
diff --git a/src/registrar/templates/emails/portfolio_update.txt b/src/registrar/templates/emails/portfolio_update.txt
new file mode 100644
index 000000000..aa13a9fb9
--- /dev/null
+++ b/src/registrar/templates/emails/portfolio_update.txt
@@ -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 .
+
+----------------------------------------------------------------
+
+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:
+Learn about .gov
+
+The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
+(CISA)
+{% endautoescape %}
diff --git a/src/registrar/templates/emails/portfolio_update_subject.txt b/src/registrar/templates/emails/portfolio_update_subject.txt
new file mode 100644
index 000000000..2cd806a73
--- /dev/null
+++ b/src/registrar/templates/emails/portfolio_update_subject.txt
@@ -0,0 +1 @@
+Your permissions were updated in the .gov registrar
\ No newline at end of file
diff --git a/src/registrar/templates/includes/member_permissions_summary.html b/src/registrar/templates/includes/member_permissions_summary.html
index 3a91d16f6..95eca0a7e 100644
--- a/src/registrar/templates/includes/member_permissions_summary.html
+++ b/src/registrar/templates/includes/member_permissions_summary.html
@@ -1,33 +1,11 @@
Member access
-{% if permissions.roles and 'organization_admin' in permissions.roles %}
- Admin
-{% elif permissions.roles and 'organization_member' in permissions.roles %}
- Basic
-{% else %}
- ⎯
-{% endif %}
+ {{ permissions.role_display }}
Domains
-{% if member_has_view_all_domains_portfolio_permission %}
- Viewer, all
-{% else %}
- Viewer, limited
-{% endif %}
+ {{ permissions.domains_display }}
Domain requests
-{% if member_has_edit_request_portfolio_permission %}
- Creator
-{% elif member_has_view_all_requests_portfolio_permission %}
- Viewer
-{% else %}
- No access
-{% endif %}
+ {{ permissions.domain_requests_display }}
Members
-{% if member_has_edit_members_portfolio_permission %}
- Manager
-{% elif member_has_view_members_portfolio_permission %}
- Viewer
-{% else %}
- No access
-{% endif %}
\ No newline at end of file
+ {{ permissions.members_display }}
diff --git a/src/registrar/tests/test_email_invitations.py b/src/registrar/tests/test_email_invitations.py
index 77a8c402f..981bca6dd 100644
--- a/src/registrar/tests/test_email_invitations.py
+++ b/src/registrar/tests/test_email_invitations.py
@@ -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
diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py
index bab4f327b..18c98807d 100644
--- a/src/registrar/tests/test_reports.py
+++ b/src/registrar/tests/test_reports.py
@@ -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
diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py
index 097aa1879..b9643f771 100644
--- a/src/registrar/tests/test_views_portfolio.py
+++ b/src/registrar/tests/test_views_portfolio.py
@@ -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)
diff --git a/src/registrar/utility/email_invitations.py b/src/registrar/utility/email_invitations.py
index de21b2a61..7ddab65f1 100644
--- a/src/registrar/utility/email_invitations.py
+++ b/src/registrar/utility/email_invitations.py
@@ -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
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index 0f93ec8e1..c3f0f0152 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -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,
diff --git a/src/registrar/views/report_views.py b/src/registrar/views/report_views.py
index 1b9198c79..596e69a5c 100644
--- a/src/registrar/views/report_views.py
+++ b/src/registrar/views/report_views.py
@@ -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)