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 %} -
+
@@ -95,7 +95,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
-
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 @@ + + + + + + + + + + {% 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 %} + + + + + + {% endwith %} + {% endfor %} + {% endwith %} + +
TypeStart date {{ data.start_date }}End date {{ data.end_date }}
{{ org_count_type }}{{ start_counts|slice:index|last }}{{ end_counts|slice:index|last }}
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)