mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-20 08:24:05 +02:00
Merge branch 'main' into dk/3440-permissions
This commit is contained in:
commit
ac4ae386e3
31 changed files with 1549 additions and 948 deletions
1030
src/package-lock.json
generated
1030
src/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -11,7 +11,7 @@
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uswds/uswds": "3.8.1",
|
"@uswds/uswds": "3.8.1",
|
||||||
"pa11y-ci": "^3.0.1",
|
"pa11y-ci": "^3.1.0",
|
||||||
"sass": "^1.54.8"
|
"sass": "^1.54.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -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();
|
|
||||||
})();
|
|
177
src/registrar/assets/src/js/getgov-admin/analytics.js
Normal file
177
src/registrar/assets/src/js/getgov-admin/analytics.js
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
import { debounce } from '../getgov/helpers.js';
|
||||||
|
import { getParameterByName } from './helpers-admin.js';
|
||||||
|
|
||||||
|
/** This function also sets the start and end dates to match the url params if they exist
|
||||||
|
*/
|
||||||
|
function initAnalyticsExportButtons() {
|
||||||
|
// Get the current date in the format YYYY-MM-DD
|
||||||
|
let currentDate = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Default the value of the start date input field to the current date
|
||||||
|
let startDateInput = document.getElementById('start');
|
||||||
|
|
||||||
|
// Default the value of the end date input field to the current date
|
||||||
|
let endDateInput = document.getElementById('end');
|
||||||
|
|
||||||
|
let exportButtons = document.querySelectorAll('.exportLink');
|
||||||
|
|
||||||
|
if (exportButtons.length > 0) {
|
||||||
|
// Check if start and end dates are present in the URL
|
||||||
|
let urlStartDate = getParameterByName('start_date');
|
||||||
|
let urlEndDate = getParameterByName('end_date');
|
||||||
|
|
||||||
|
// Set input values based on URL parameters or current date
|
||||||
|
startDateInput.value = urlStartDate || currentDate;
|
||||||
|
endDateInput.value = urlEndDate || currentDate;
|
||||||
|
|
||||||
|
exportButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
// Get the selected start and end dates
|
||||||
|
let startDate = startDateInput.value;
|
||||||
|
let endDate = endDateInput.value;
|
||||||
|
let exportUrl = btn.dataset.exportUrl;
|
||||||
|
|
||||||
|
// Build the URL with parameters
|
||||||
|
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
|
||||||
|
|
||||||
|
// Redirect to the export URL
|
||||||
|
window.location.href = exportUrl;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a diagonal stripe pattern for chart.js
|
||||||
|
* Inspired by https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
|
||||||
|
* and https://github.com/ashiguruma/patternomaly
|
||||||
|
* @param {string} backgroundColor - Background color of the pattern
|
||||||
|
* @param {string} [lineColor="white"] - Color of the diagonal lines
|
||||||
|
* @param {boolean} [rightToLeft=false] - Direction of the diagonal lines
|
||||||
|
* @param {number} [lineGap=1] - Gap between lines
|
||||||
|
* @returns {CanvasPattern} A canvas pattern object for use with backgroundColor
|
||||||
|
*/
|
||||||
|
function createDiagonalPattern(backgroundColor, lineColor, rightToLeft=false, lineGap=1) {
|
||||||
|
// Define the canvas and the 2d context so we can draw on it
|
||||||
|
let shape = document.createElement("canvas");
|
||||||
|
shape.width = 20;
|
||||||
|
shape.height = 20;
|
||||||
|
let context = shape.getContext("2d");
|
||||||
|
|
||||||
|
// Fill with specified background color
|
||||||
|
context.fillStyle = backgroundColor;
|
||||||
|
context.fillRect(0, 0, shape.width, shape.height);
|
||||||
|
|
||||||
|
// Set stroke properties
|
||||||
|
context.strokeStyle = lineColor;
|
||||||
|
context.lineWidth = 2;
|
||||||
|
|
||||||
|
// Rotate canvas for a right-to-left pattern
|
||||||
|
if (rightToLeft) {
|
||||||
|
context.translate(shape.width, 0);
|
||||||
|
context.rotate(90 * Math.PI / 180);
|
||||||
|
};
|
||||||
|
|
||||||
|
// First diagonal line
|
||||||
|
let halfSize = shape.width / 2;
|
||||||
|
context.moveTo(halfSize - lineGap, -lineGap);
|
||||||
|
context.lineTo(shape.width + lineGap, halfSize + lineGap);
|
||||||
|
|
||||||
|
// Second diagonal line (x,y are swapped)
|
||||||
|
context.moveTo(-lineGap, halfSize - lineGap);
|
||||||
|
context.lineTo(halfSize + lineGap, shape.width + lineGap);
|
||||||
|
|
||||||
|
context.stroke();
|
||||||
|
return context.createPattern(shape, "repeat");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createComparativeColumnChart(id, title, labelOne, labelTwo) {
|
||||||
|
var canvas = document.getElementById(id);
|
||||||
|
if (!canvas) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctx = canvas.getContext("2d");
|
||||||
|
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
|
||||||
|
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
|
||||||
|
|
||||||
|
var data = {
|
||||||
|
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: labelOne,
|
||||||
|
backgroundColor: "rgba(255, 99, 132, 0.3)",
|
||||||
|
borderColor: "rgba(255, 99, 132, 1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
data: listOne,
|
||||||
|
// Set this line style to be rightToLeft for visual distinction
|
||||||
|
backgroundColor: createDiagonalPattern('rgba(255, 99, 132, 0.3)', 'white', true)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: labelTwo,
|
||||||
|
backgroundColor: "rgba(75, 192, 192, 0.3)",
|
||||||
|
borderColor: "rgba(75, 192, 192, 1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
data: listTwo,
|
||||||
|
backgroundColor: createDiagonalPattern('rgba(75, 192, 192, 0.3)', 'white')
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: title
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: data,
|
||||||
|
options: options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An IIFE to initialize the analytics page
|
||||||
|
*/
|
||||||
|
export function initAnalyticsDashboard() {
|
||||||
|
const analyticsPageContainer = document.querySelector('.analytics-dashboard-charts');
|
||||||
|
if (analyticsPageContainer) {
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
initAnalyticsExportButtons();
|
||||||
|
|
||||||
|
// Create charts and store each instance of it
|
||||||
|
const chartInstances = new Map();
|
||||||
|
const charts = [
|
||||||
|
{ id: "managed-domains-chart", title: "Managed domains" },
|
||||||
|
{ id: "unmanaged-domains-chart", title: "Unmanaged domains" },
|
||||||
|
{ id: "deleted-domains-chart", title: "Deleted domains" },
|
||||||
|
{ id: "ready-domains-chart", title: "Ready domains" },
|
||||||
|
{ id: "submitted-requests-chart", title: "Submitted requests" },
|
||||||
|
{ id: "all-requests-chart", title: "All requests" }
|
||||||
|
];
|
||||||
|
charts.forEach(chart => {
|
||||||
|
if (chartInstances.has(chart.id)) chartInstances.get(chart.id).destroy();
|
||||||
|
chartInstances.set(chart.id, createComparativeColumnChart(chart.id, chart.title, "Start Date", "End Date"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add resize listener to each chart
|
||||||
|
window.addEventListener("resize", debounce(() => {
|
||||||
|
chartInstances.forEach((chart) => {
|
||||||
|
if (chart?.canvas) chart.resize();
|
||||||
|
});
|
||||||
|
}, 200));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -22,3 +22,13 @@ export function addOrRemoveSessionBoolean(name, add){
|
||||||
sessionStorage.removeItem(name);
|
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, ' '));
|
||||||
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { initDomainFormTargetBlankButtons } from './domain-form.js';
|
||||||
import { initDynamicPortfolioFields } from './portfolio-form.js';
|
import { initDynamicPortfolioFields } from './portfolio-form.js';
|
||||||
import { initDynamicDomainInformationFields } from './domain-information-form.js';
|
import { initDynamicDomainInformationFields } from './domain-information-form.js';
|
||||||
import { initDynamicDomainFields } from './domain-form.js';
|
import { initDynamicDomainFields } from './domain-form.js';
|
||||||
|
import { initAnalyticsDashboard } from './analytics.js';
|
||||||
|
|
||||||
// General
|
// General
|
||||||
initModals();
|
initModals();
|
||||||
|
@ -41,3 +42,6 @@ initDynamicPortfolioFields();
|
||||||
|
|
||||||
// Domain information
|
// Domain information
|
||||||
initDynamicDomainInformationFields();
|
initDynamicDomainInformationFields();
|
||||||
|
|
||||||
|
// Analytics dashboard
|
||||||
|
initAnalyticsDashboard();
|
||||||
|
|
|
@ -128,7 +128,7 @@ export function initAddNewMemberPageListeners() {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// for admin users, the permissions are always the same
|
// for admin users, the permissions are always the same
|
||||||
appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer);
|
appendPermissionInContainer('Domains', 'Viewer', permissionDetailsContainer);
|
||||||
appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer);
|
appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer);
|
||||||
appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer);
|
appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer);
|
||||||
}
|
}
|
||||||
|
|
|
@ -558,13 +558,18 @@ details.dja-detail-table {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thead tr {
|
||||||
|
background-color: var(--darkened-bg);
|
||||||
|
}
|
||||||
|
|
||||||
td, th {
|
td, th {
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
border: none
|
border: none;
|
||||||
|
background-color: var(--darkened-bg);
|
||||||
|
color: var(--body-quiet-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
thead > tr > th {
|
thead > tr > th {
|
||||||
border-radius: 4px;
|
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
@ -946,3 +951,34 @@ ul.add-list-reset {
|
||||||
background-color: transparent !important;
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -127,7 +127,7 @@ class BasePortfolioMemberForm(forms.ModelForm):
|
||||||
domain_permissions = forms.ChoiceField(
|
domain_permissions = forms.ChoiceField(
|
||||||
choices=[
|
choices=[
|
||||||
(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"),
|
(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"),
|
||||||
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"),
|
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer"),
|
||||||
],
|
],
|
||||||
widget=forms.RadioSelect,
|
widget=forms.RadioSelect,
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -338,6 +338,24 @@ class BasePortfolioMemberForm(forms.ModelForm):
|
||||||
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
|
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):
|
class PortfolioMemberForm(BasePortfolioMemberForm):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -9,6 +9,10 @@ from .utility.portfolio_helper import (
|
||||||
UserPortfolioPermissionChoices,
|
UserPortfolioPermissionChoices,
|
||||||
UserPortfolioRoleChoices,
|
UserPortfolioRoleChoices,
|
||||||
cleanup_after_portfolio_member_deletion,
|
cleanup_after_portfolio_member_deletion,
|
||||||
|
get_domain_requests_display,
|
||||||
|
get_domains_display,
|
||||||
|
get_members_display,
|
||||||
|
get_role_display,
|
||||||
validate_portfolio_invitation,
|
validate_portfolio_invitation,
|
||||||
) # type: ignore
|
) # type: ignore
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
@ -85,6 +89,60 @@ class PortfolioInvitation(TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
|
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)
|
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
|
||||||
def retrieve(self):
|
def retrieve(self):
|
||||||
"""When an invitation is retrieved, create the corresponding permission.
|
"""When an invitation is retrieved, create the corresponding permission.
|
||||||
|
|
|
@ -269,7 +269,7 @@ class User(AbstractUser):
|
||||||
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
|
||||||
|
|
||||||
def is_portfolio_admin(self, portfolio):
|
def is_portfolio_admin(self, portfolio):
|
||||||
return "Admin" in self.portfolio_role_summary(portfolio)
|
return self.has_edit_portfolio_permission(portfolio)
|
||||||
|
|
||||||
def get_first_portfolio(self):
|
def get_first_portfolio(self):
|
||||||
permission = self.portfolio_permissions.first()
|
permission = self.portfolio_permissions.first()
|
||||||
|
@ -277,49 +277,6 @@ class User(AbstractUser):
|
||||||
return permission.portfolio
|
return permission.portfolio
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def portfolio_role_summary(self, portfolio):
|
|
||||||
"""Returns a list of roles based on the user's permissions."""
|
|
||||||
roles = []
|
|
||||||
|
|
||||||
# Define the conditions and their corresponding roles
|
|
||||||
conditions_roles = [
|
|
||||||
(self.has_edit_portfolio_permission(portfolio), ["Admin"]),
|
|
||||||
(
|
|
||||||
self.has_view_all_domains_portfolio_permission(portfolio)
|
|
||||||
and self.has_any_requests_portfolio_permission(portfolio)
|
|
||||||
and self.has_edit_request_portfolio_permission(portfolio),
|
|
||||||
["View-only admin", "Domain requestor"],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
self.has_view_all_domains_portfolio_permission(portfolio)
|
|
||||||
and self.has_any_requests_portfolio_permission(portfolio),
|
|
||||||
["View-only admin"],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
self.has_view_portfolio_permission(portfolio)
|
|
||||||
and self.has_edit_request_portfolio_permission(portfolio)
|
|
||||||
and self.has_any_domains_portfolio_permission(portfolio),
|
|
||||||
["Domain requestor", "Domain manager"],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
self.has_view_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
|
|
||||||
["Domain requestor"],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
self.has_view_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
|
|
||||||
["Domain manager"],
|
|
||||||
),
|
|
||||||
(self.has_view_portfolio_permission(portfolio), ["Member"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Evaluate conditions and add roles
|
|
||||||
for condition, role_list in conditions_roles:
|
|
||||||
if condition:
|
|
||||||
roles.extend(role_list)
|
|
||||||
break
|
|
||||||
|
|
||||||
return roles
|
|
||||||
|
|
||||||
def get_portfolios(self):
|
def get_portfolios(self):
|
||||||
return self.portfolio_permissions.all()
|
return self.portfolio_permissions.all()
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,10 @@ from registrar.models.utility.portfolio_helper import (
|
||||||
DomainRequestPermissionDisplay,
|
DomainRequestPermissionDisplay,
|
||||||
MemberPermissionDisplay,
|
MemberPermissionDisplay,
|
||||||
cleanup_after_portfolio_member_deletion,
|
cleanup_after_portfolio_member_deletion,
|
||||||
|
get_domain_requests_display,
|
||||||
|
get_domains_display,
|
||||||
|
get_members_display,
|
||||||
|
get_role_display,
|
||||||
validate_user_portfolio_permission,
|
validate_user_portfolio_permission,
|
||||||
)
|
)
|
||||||
from .utility.time_stamped_model import TimeStampedModel
|
from .utility.time_stamped_model import TimeStampedModel
|
||||||
|
@ -181,6 +185,60 @@ class UserPortfolioPermission(TimeStampedModel):
|
||||||
# This is the same as portfolio_permissions & common_forbidden_perms.
|
# This is the same as portfolio_permissions & common_forbidden_perms.
|
||||||
return portfolio_permissions.intersection(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):
|
def clean(self):
|
||||||
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
|
"""Extends clean method to perform additional validation, which can raise errors in django admin."""
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
|
@ -79,6 +79,100 @@ class MemberPermissionDisplay(StrEnum):
|
||||||
NONE = "None"
|
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):
|
def validate_user_portfolio_permission(user_portfolio_permission):
|
||||||
"""
|
"""
|
||||||
Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports
|
Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports
|
||||||
|
|
|
@ -18,7 +18,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div id="content-main" class="custom-admin-template">
|
<div id="content-main" class="custom-admin-template analytics-dashboard">
|
||||||
|
|
||||||
<div class="grid-row grid-gap-2">
|
<div class="grid-row grid-gap-2">
|
||||||
<div class="tablet:grid-col-6 margin-top-2">
|
<div class="tablet:grid-col-6 margin-top-2">
|
||||||
|
@ -95,7 +95,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
|
||||||
<input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" />
|
<input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="usa-button-group">
|
<ul class="usa-button-group flex-wrap">
|
||||||
<li class="usa-button-group__item">
|
<li class="usa-button-group__item">
|
||||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
|
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
|
||||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
|
@ -133,79 +133,126 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="grid-row grid-gap-2 margin-y-2">
|
<div class="analytics-dashboard-charts margin-top-2">
|
||||||
<div class="grid-col">
|
{% comment %} Managed/Unmanaged domains {% endcomment %}
|
||||||
<canvas id="myChart1" width="400" height="200"
|
<div class="chart-1 grid-col">
|
||||||
aria-label="Chart: {{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}"
|
<canvas id="managed-domains-chart" width="400" height="200"
|
||||||
|
aria-label="Chart: {{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}"
|
||||||
role="img"
|
role="img"
|
||||||
data-list-one="{{data.managed_domains_sliced_at_start_date}}"
|
data-list-one="{{ data.managed_domains.start_date_count }}"
|
||||||
data-list-two="{{data.managed_domains_sliced_at_end_date}}"
|
data-list-two="{{ data.managed_domains.end_date_count }}"
|
||||||
>
|
>
|
||||||
<h2>Chart: Managed domains</h2>
|
<h2>Chart: Managed domains</h2>
|
||||||
<p>{{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}</p>
|
<p>{{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}</p>
|
||||||
</canvas>
|
</canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-col">
|
<div class="details-1 grid-col margin-bottom-2">
|
||||||
<canvas id="myChart2" width="400" height="200"
|
<details class="dja-detail-table" aria-role="button" closed>
|
||||||
aria-label="Chart: {{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}"
|
<summary class="dja-details-summary">Details for managed domains</summary>
|
||||||
|
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||||
|
{% include "admin/analytics_graph_table.html" with data=data property_name="managed_domains" %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="chart-2 grid-col">
|
||||||
|
<canvas id="unmanaged-domains-chart" width="400" height="200"
|
||||||
|
aria-label="Chart: {{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}"
|
||||||
role="img"
|
role="img"
|
||||||
data-list-one="{{data.unmanaged_domains_sliced_at_start_date}}"
|
data-list-one="{{ data.unmanaged_domains.start_date_count }}"
|
||||||
data-list-two="{{data.unmanaged_domains_sliced_at_end_date}}"
|
data-list-two="{{ data.unmanaged_domains.end_date_count }}"
|
||||||
>
|
>
|
||||||
<h2>Chart: Unmanaged domains</h2>
|
<h2>Chart: Unmanaged domains</h2>
|
||||||
<p>{{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}</p>
|
<p>{{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}</p>
|
||||||
</canvas>
|
</canvas>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="details-2 grid-col margin-bottom-2">
|
||||||
|
<details class="dja-detail-table" aria-role="button" closed>
|
||||||
|
<summary class="dja-details-summary">Details for unmanaged domains</summary>
|
||||||
|
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||||
|
{% include "admin/analytics_graph_table.html" with data=data property_name="unmanaged_domains" %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid-row grid-gap-2 margin-y-2">
|
{% comment %} Deleted/Ready domains {% endcomment %}
|
||||||
<div class="grid-col">
|
<div class="chart-3 grid-col">
|
||||||
<canvas id="myChart3" width="400" height="200"
|
<canvas id="deleted-domains-chart" width="400" height="200"
|
||||||
aria-label="Chart: {{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}"
|
aria-label="Chart: {{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}"
|
||||||
role="img"
|
role="img"
|
||||||
data-list-one="{{data.deleted_domains_sliced_at_start_date}}"
|
data-list-one="{{ data.deleted_domains.start_date_count }}"
|
||||||
data-list-two="{{data.deleted_domains_sliced_at_end_date}}"
|
data-list-two="{{ data.deleted_domains.end_date_count }}"
|
||||||
>
|
>
|
||||||
<h2>Chart: Deleted domains</h2>
|
<h2>Chart: Deleted domains</h2>
|
||||||
<p>{{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}</p>
|
<p>{{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}</p>
|
||||||
</canvas>
|
</canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-col">
|
<div class="details-3 grid-col margin-bottom-2">
|
||||||
<canvas id="myChart4" width="400" height="200"
|
<details class="dja-detail-table" aria-role="button" closed>
|
||||||
aria-label="Chart: {{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}"
|
<summary class="dja-details-summary">Details for deleted domains</summary>
|
||||||
|
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||||
|
{% include "admin/analytics_graph_table.html" with data=data property_name="deleted_domains" %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="chart-4 grid-col">
|
||||||
|
<canvas id="ready-domains-chart" width="400" height="200"
|
||||||
|
aria-label="Chart: {{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}"
|
||||||
role="img"
|
role="img"
|
||||||
data-list-one="{{data.ready_domains_sliced_at_start_date}}"
|
data-list-one="{{ data.ready_domains.start_date_count }}"
|
||||||
data-list-two="{{data.ready_domains_sliced_at_end_date}}"
|
data-list-two="{{ data.ready_domains.end_date_count }}"
|
||||||
>
|
>
|
||||||
<h2>Chart: Ready domains</h2>
|
<h2>Chart: Ready domains</h2>
|
||||||
<p>{{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}</p>
|
<p>{{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}</p>
|
||||||
</canvas>
|
</canvas>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="details-4 grid-col margin-bottom-2">
|
||||||
|
<details class="dja-detail-table" aria-role="button" closed>
|
||||||
|
<summary class="dja-details-summary">Details for ready domains</summary>
|
||||||
|
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||||
|
{% include "admin/analytics_graph_table.html" with data=data property_name="ready_domains" %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid-row grid-gap-2 margin-y-2">
|
{% comment %} Requests {% endcomment %}
|
||||||
<div class="grid-col">
|
<div class="chart-5 grid-col">
|
||||||
<canvas id="myChart5" width="400" height="200"
|
<canvas id="submitted-requests-chart" width="400" height="200"
|
||||||
aria-label="Chart: {{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}"
|
aria-label="Chart: {{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}"
|
||||||
role="img"
|
role="img"
|
||||||
data-list-one="{{data.submitted_requests_sliced_at_start_date}}"
|
data-list-one="{{ data.submitted_requests.start_date_count }}"
|
||||||
data-list-two="{{data.submitted_requests_sliced_at_end_date}}"
|
data-list-two="{{ data.submitted_requests.end_date_count }}"
|
||||||
>
|
>
|
||||||
<h2>Chart: Submitted requests</h2>
|
<h2>Chart: Submitted requests</h2>
|
||||||
<p>{{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}</p>
|
<p>{{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}</p>
|
||||||
</canvas>
|
</canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-col">
|
<div class="details-5 grid-col margin-bottom-2">
|
||||||
<canvas id="myChart6" width="400" height="200"
|
<details class="dja-detail-table" aria-role="button" closed>
|
||||||
aria-label="Chart: {{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}"
|
<summary class="dja-details-summary">Details for submitted requests</summary>
|
||||||
|
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||||
|
{% include "admin/analytics_graph_table.html" with data=data property_name="submitted_requests" %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="chart-6 grid-col">
|
||||||
|
<canvas id="all-requests-chart" width="400" height="200"
|
||||||
|
aria-label="Chart: {{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}"
|
||||||
role="img"
|
role="img"
|
||||||
data-list-one="{{data.requests_sliced_at_start_date}}"
|
data-list-one="{{ data.requests.start_date_count }}"
|
||||||
data-list-two="{{data.requests_sliced_at_end_date}}"
|
data-list-two="{{ data.requests.end_date_count }}"
|
||||||
>
|
>
|
||||||
<h2>Chart: All requests</h2>
|
<h2>Chart: All requests</h2>
|
||||||
<p>{{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}</p>
|
<p>{{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}</p>
|
||||||
</canvas>
|
</canvas>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="details-6 grid-col margin-bottom-2">
|
||||||
|
<details class="dja-detail-table" aria-role="button" closed>
|
||||||
|
<summary class="dja-details-summary">Details for all requests</summary>
|
||||||
|
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||||
|
{% include "admin/analytics_graph_table.html" with data=data property_name="requests" %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
26
src/registrar/templates/admin/analytics_graph_table.html
Normal file
26
src/registrar/templates/admin/analytics_graph_table.html
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<table class="usa-table usa-table--borderless">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Type</th>
|
||||||
|
<th scope="col">Start date {{ data.start_date }}</th>
|
||||||
|
<th scope="col">End date {{ data.end_date }} </th>
|
||||||
|
<tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% comment %}
|
||||||
|
This ugly notation is equivalent to data.property_name.start_date_count.index.
|
||||||
|
Or represented in the pure python way: data[property_name]["start_date_count"][index]
|
||||||
|
{% endcomment %}
|
||||||
|
{% with start_counts=data|get_item:property_name|get_item:"start_date_count" end_counts=data|get_item:property_name|get_item:"end_date_count" %}
|
||||||
|
{% for org_count_type in data.org_count_types %}
|
||||||
|
{% with index=forloop.counter %}
|
||||||
|
<tr>
|
||||||
|
<th class="padding-left-1" scope="row">{{ org_count_type }}</th>
|
||||||
|
<td class="padding-left-1">{{ start_counts|slice:index|last }}</td>
|
||||||
|
<td class="padding-left-1">{{ end_counts|slice:index|last }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
|
@ -22,7 +22,6 @@
|
||||||
<script src="{% static 'js/uswds.min.js' %}" defer></script>
|
<script src="{% static 'js/uswds.min.js' %}" defer></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script type="application/javascript" src="{% static 'js/getgov-admin.min.js' %}" defer></script>
|
<script type="application/javascript" src="{% static 'js/getgov-admin.min.js' %}" defer></script>
|
||||||
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
|
|
||||||
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
|
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -49,6 +48,10 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if opts.model_name %}
|
||||||
|
<a class="usa-skipnav" href="#changelist-filter" aria-label="Skip to the filters section">Skip to filters</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Djando update: this div will change to header #}
|
{# Djando update: this div will change to header #}
|
||||||
<div id="header">
|
<div id="header">
|
||||||
<div id="branding">
|
<div id="branding">
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
{% extends "admin/change_list.html" %}
|
{% extends "admin/change_list.html" %}
|
||||||
|
{% load i18n admin_urls static admin_list %}
|
||||||
|
|
||||||
{% block content_title %}
|
{% block content_title %}
|
||||||
<h1>{{ title }}</h1>
|
<h1>{{ title }}</h1>
|
||||||
|
@ -37,6 +38,7 @@
|
||||||
for {{ search_query }}
|
for {{ search_query }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% comment %} Replace the Django ul markup with a div. We'll replace the li with a p in change_list_object_tools {% endcomment %}
|
{% comment %} Replace the Django ul markup with a div. We'll replace the li with a p in change_list_object_tools {% endcomment %}
|
||||||
|
@ -47,3 +49,24 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
{% comment %} Replace the Django header markup for clearing all filters with a div. {% endcomment %}
|
||||||
|
{% block filters %}
|
||||||
|
{% if cl.has_filters %}
|
||||||
|
<nav id="changelist-filter" aria-labelledby="changelist-filter-header">
|
||||||
|
<h2 id="changelist-filter-header">{% translate 'Filter' %}</h2>
|
||||||
|
{% if cl.is_facets_optional or cl.has_active_filters %}<div id="changelist-filter-extra-actions">
|
||||||
|
{% if cl.is_facets_optional %}<h3>
|
||||||
|
{% if cl.add_facets %}<a href="{{ cl.remove_facet_link }}" class="hidelink">{% translate "Hide counts" %}</a>
|
||||||
|
{% else %}<a href="{{ cl.add_facet_link }}" class="viewlink">{% translate "Show counts" %}</a>{% endif %}
|
||||||
|
</h3>{% endif %}
|
||||||
|
{% if cl.has_active_filters %}<div class="margin-2">
|
||||||
|
<a href="{{ cl.clear_all_filters_qs }}" role="link">✖ {% translate "Clear all filters" %}</a>
|
||||||
|
</div>{% endif %}
|
||||||
|
</div>{% endif %}
|
||||||
|
{% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Phone</th>
|
<th>Phone</th>
|
||||||
<th>Roles</th>
|
|
||||||
<th>Action</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -28,11 +27,6 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ member.user.phone }}</td>
|
<td>{{ member.user.phone }}</td>
|
||||||
<td>
|
|
||||||
{% for role in member.user|portfolio_role_summary:original %}
|
|
||||||
<span class="usa-tag bg-primary-dark text-semibold">{{ role }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
<td class="padding-left-1 text-size-small">
|
<td class="padding-left-1 text-size-small">
|
||||||
{% if member.user.email %}
|
{% if member.user.email %}
|
||||||
<input aria-hidden="true" class="display-none" value="{{ member.user.email }}" />
|
<input aria-hidden="true" class="display-none" value="{{ member.user.email }}" />
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static field_helpers url_helpers %}
|
{% load static field_helpers url_helpers %}
|
||||||
|
|
||||||
|
<details data-filter-title="{{ title }}" open="">
|
||||||
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3>
|
<summary aria-label="Show/hide {{ title }} filters" role="button">
|
||||||
|
{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}
|
||||||
|
</summary>
|
||||||
<ul class="mulitple-choice">
|
<ul class="mulitple-choice">
|
||||||
{% for choice in choices %}
|
{% for choice in choices %}
|
||||||
{% if choice.reset %}
|
{% if choice.reset %}
|
||||||
|
@ -11,11 +13,12 @@
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% for choice in choices %}
|
{% for choice in choices %}
|
||||||
{% if not choice.reset %}
|
{% if not choice.reset %}
|
||||||
<li{% if choice.selected %} class="selected"{% endif %}">
|
<li{% if choice.selected %} class="selected"{% endif %}">
|
||||||
{% if choice.selected and choice.exclude_query_string %}
|
{% if choice.selected and choice.exclude_query_string %}
|
||||||
<a class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
|
<a role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
|
||||||
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
@ -25,7 +28,7 @@
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not choice.selected and choice.include_query_string %}
|
{% if not choice.selected and choice.include_query_string %}
|
||||||
<a class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
|
<a role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
|
||||||
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||||
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
|
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
|
||||||
</svg>
|
</svg>
|
||||||
|
@ -35,3 +38,4 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
35
src/registrar/templates/emails/portfolio_update.txt
Normal file
35
src/registrar/templates/emails/portfolio_update.txt
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
|
||||||
|
Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
|
||||||
|
|
||||||
|
Your permissions were updated in the .gov registrar.
|
||||||
|
|
||||||
|
ORGANIZATION: {{ portfolio.organization_name }}
|
||||||
|
UPDATED BY: {{ requestor_email }}
|
||||||
|
UPDATED ON: {{ date }}
|
||||||
|
YOUR PERMISSIONS: {{ permissions.role_display }}
|
||||||
|
Domains - {{ permissions.domains_display }}
|
||||||
|
Domain requests - {{ permissions.domain_requests_display }}
|
||||||
|
Members - {{ permissions.members_display }}
|
||||||
|
|
||||||
|
Your updated permissions are now active in the .gov registrar <https://manage.get.gov>.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
SOMETHING WRONG?
|
||||||
|
If you have questions or concerns, reach out to the person who updated your
|
||||||
|
permissions, or reply to this email.
|
||||||
|
|
||||||
|
|
||||||
|
THANK YOU
|
||||||
|
.Gov helps the public identify official, trusted information. Thank you for using a .gov
|
||||||
|
domain.
|
||||||
|
|
||||||
|
----------------------------------------------------------------
|
||||||
|
|
||||||
|
The .gov team
|
||||||
|
Contact us: <https://get.gov/contact/>
|
||||||
|
Learn about .gov <https://get.gov>
|
||||||
|
|
||||||
|
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
|
||||||
|
(CISA) <https://cisa.gov/>
|
||||||
|
{% endautoescape %}
|
|
@ -0,0 +1 @@
|
||||||
|
Your permissions were updated in the .gov registrar
|
|
@ -1,33 +1,11 @@
|
||||||
<h4 class="margin-bottom-0">Member access</h4>
|
<h4 class="margin-bottom-0">Member access</h4>
|
||||||
{% if permissions.roles and 'organization_admin' in permissions.roles %}
|
<p class="margin-top-0">{{ permissions.role_display }}</p>
|
||||||
<p class="margin-top-0">Admin</p>
|
|
||||||
{% elif permissions.roles and 'organization_member' in permissions.roles %}
|
|
||||||
<p class="margin-top-0">Basic</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="margin-top-0">⎯</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<h4 class="margin-bottom-0 text-primary">Domains</h4>
|
<h4 class="margin-bottom-0 text-primary">Domains</h4>
|
||||||
{% if member_has_view_all_domains_portfolio_permission %}
|
<p class="margin-top-0">{{ permissions.domains_display }}</p>
|
||||||
<p class="margin-top-0">Viewer, all</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="margin-top-0">Viewer, limited</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<h4 class="margin-bottom-0 text-primary">Domain requests</h4>
|
<h4 class="margin-bottom-0 text-primary">Domain requests</h4>
|
||||||
{% if member_has_edit_request_portfolio_permission %}
|
<p class="margin-top-0">{{ permissions.domain_requests_display }}</p>
|
||||||
<p class="margin-top-0">Creator</p>
|
|
||||||
{% elif member_has_view_all_requests_portfolio_permission %}
|
|
||||||
<p class="margin-top-0">Viewer</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="margin-top-0">No access</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<h4 class="margin-bottom-0 text-primary">Members</h4>
|
<h4 class="margin-bottom-0 text-primary">Members</h4>
|
||||||
{% if member_has_edit_members_portfolio_permission %}
|
<p class="margin-top-0">{{ permissions.members_display }}</p>
|
||||||
<p class="margin-top-0">Manager</p>
|
|
||||||
{% elif member_has_view_members_portfolio_permission %}
|
|
||||||
<p class="margin-top-0">Viewer</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="margin-top-0">No access</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
|
@ -251,15 +251,6 @@ def is_members_subpage(path):
|
||||||
return get_url_name(path) in url_names
|
return get_url_name(path) in url_names
|
||||||
|
|
||||||
|
|
||||||
@register.filter(name="portfolio_role_summary")
|
|
||||||
def portfolio_role_summary(user, portfolio):
|
|
||||||
"""Returns the value of user.portfolio_role_summary"""
|
|
||||||
if user and portfolio:
|
|
||||||
return user.portfolio_role_summary(portfolio)
|
|
||||||
else:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
@register.filter(name="display_requesting_entity")
|
@register.filter(name="display_requesting_entity")
|
||||||
def display_requesting_entity(domain_request):
|
def display_requesting_entity(domain_request):
|
||||||
"""Workaround for a newline issue in .txt files (our emails) as if statements
|
"""Workaround for a newline issue in .txt files (our emails) as if statements
|
||||||
|
|
|
@ -16,6 +16,7 @@ from registrar.utility.email_invitations import (
|
||||||
send_portfolio_admin_addition_emails,
|
send_portfolio_admin_addition_emails,
|
||||||
send_portfolio_admin_removal_emails,
|
send_portfolio_admin_removal_emails,
|
||||||
send_portfolio_invitation_email,
|
send_portfolio_invitation_email,
|
||||||
|
send_portfolio_member_permission_update_email,
|
||||||
)
|
)
|
||||||
|
|
||||||
from api.tests.common import less_console_noise_decorator
|
from api.tests.common import less_console_noise_decorator
|
||||||
|
@ -522,7 +523,6 @@ class PortfolioInvitationEmailTests(unittest.TestCase):
|
||||||
"registrar.utility.email_invitations._get_requestor_email",
|
"registrar.utility.email_invitations._get_requestor_email",
|
||||||
side_effect=MissingEmailError("Requestor has no 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):
|
def test_send_portfolio_invitation_email_missing_requestor_email(self, mock_get_email):
|
||||||
"""Test when requestor has no email"""
|
"""Test when requestor has no email"""
|
||||||
is_admin_invitation = False
|
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_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)
|
mock_send_removal_emails.assert_called_once_with(self.email, self.requestor.email, self.portfolio)
|
||||||
self.assertFalse(result)
|
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
|
||||||
|
|
|
@ -1191,67 +1191,6 @@ class TestUser(TestCase):
|
||||||
User.objects.all().delete()
|
User.objects.all().delete()
|
||||||
UserDomainRole.objects.all().delete()
|
UserDomainRole.objects.all().delete()
|
||||||
|
|
||||||
@patch.object(User, "has_edit_portfolio_permission", return_value=True)
|
|
||||||
def test_portfolio_role_summary_admin(self, mock_edit_org):
|
|
||||||
# Test if the user is recognized as an Admin
|
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
|
|
||||||
|
|
||||||
@patch.multiple(
|
|
||||||
User,
|
|
||||||
has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
has_any_requests_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
)
|
|
||||||
def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self):
|
|
||||||
# Test if the user has both 'View-only admin' and 'Domain requestor' roles
|
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin", "Domain requestor"])
|
|
||||||
|
|
||||||
@patch.multiple(
|
|
||||||
User,
|
|
||||||
has_view_all_domains_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
has_any_requests_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
)
|
|
||||||
def test_portfolio_role_summary_view_only_admin(self):
|
|
||||||
# Test if the user is recognized as a View-only admin
|
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin"])
|
|
||||||
|
|
||||||
@patch.multiple(
|
|
||||||
User,
|
|
||||||
has_view_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
has_any_domains_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
)
|
|
||||||
def test_portfolio_role_summary_member_domain_requestor_domain_manager(self):
|
|
||||||
# Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles
|
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"])
|
|
||||||
|
|
||||||
@patch.multiple(
|
|
||||||
User,
|
|
||||||
has_view_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
has_edit_request_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
)
|
|
||||||
def test_portfolio_role_summary_member_domain_requestor(self):
|
|
||||||
# Test if the user has 'Member' and 'Domain requestor' roles
|
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor"])
|
|
||||||
|
|
||||||
@patch.multiple(
|
|
||||||
User,
|
|
||||||
has_view_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
has_any_domains_portfolio_permission=lambda self, portfolio: True,
|
|
||||||
)
|
|
||||||
def test_portfolio_role_summary_member_domain_manager(self):
|
|
||||||
# Test if the user has 'Member' and 'Domain manager' roles
|
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
|
|
||||||
|
|
||||||
@patch.multiple(User, has_view_portfolio_permission=lambda self, portfolio: True)
|
|
||||||
def test_portfolio_role_summary_member(self):
|
|
||||||
# Test if the user is recognized as a Member
|
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
|
|
||||||
|
|
||||||
def test_portfolio_role_summary_empty(self):
|
|
||||||
# Test if the user has no roles
|
|
||||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
|
|
||||||
|
|
||||||
@patch("registrar.models.User._has_portfolio_permission")
|
@patch("registrar.models.User._has_portfolio_permission")
|
||||||
def test_has_view_portfolio_permission(self, mock_has_permission):
|
def test_has_view_portfolio_permission(self, mock_has_permission):
|
||||||
mock_has_permission.return_value = True
|
mock_has_permission.return_value = True
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import io
|
import io
|
||||||
|
from unittest import skip
|
||||||
from django.test import Client, RequestFactory
|
from django.test import Client, RequestFactory
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from registrar.models import (
|
from registrar.models import (
|
||||||
|
@ -819,6 +820,7 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.factory = RequestFactory()
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
@skip("flaky test that needs to be refactored")
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
|
|
|
@ -1063,7 +1063,7 @@ class TestPortfolio(WebTest):
|
||||||
self.assertContains(response, "Invited")
|
self.assertContains(response, "Invited")
|
||||||
self.assertContains(response, portfolio_invitation.email)
|
self.assertContains(response, portfolio_invitation.email)
|
||||||
self.assertContains(response, "Admin")
|
self.assertContains(response, "Admin")
|
||||||
self.assertContains(response, "Viewer, all")
|
self.assertContains(response, "Viewer")
|
||||||
self.assertContains(response, "Creator")
|
self.assertContains(response, "Creator")
|
||||||
self.assertContains(response, "Manager")
|
self.assertContains(response, "Manager")
|
||||||
self.assertContains(
|
self.assertContains(
|
||||||
|
@ -3980,7 +3980,10 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_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."""
|
"""Tests converting a basic member to admin with full permissions."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
@ -3995,6 +3998,7 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
|
|
||||||
# return indicator that notification emails sent successfully
|
# return indicator that notification emails sent successfully
|
||||||
mock_send_addition_emails.return_value = True
|
mock_send_addition_emails.return_value = True
|
||||||
|
mock_send_update_email.return_value = True
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
|
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
|
||||||
|
@ -4014,6 +4018,8 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
mock_send_addition_emails.assert_called_once()
|
mock_send_addition_emails.assert_called_once()
|
||||||
# assert removal emails are not sent
|
# assert removal emails are not sent
|
||||||
mock_send_removal_emails.assert_not_called()
|
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
|
# Get the arguments passed to send_portfolio_admin_addition_emails
|
||||||
_, called_kwargs = mock_send_addition_emails.call_args
|
_, called_kwargs = mock_send_addition_emails.call_args
|
||||||
|
@ -4023,14 +4029,22 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
self.assertEqual(called_kwargs["requestor"], self.user)
|
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||||
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
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
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("django.contrib.messages.warning")
|
@patch("django.contrib.messages.warning")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_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(
|
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.
|
"""Tests converting a basic member to admin with full permissions.
|
||||||
Handle when notification emails fail to send."""
|
Handle when notification emails fail to send."""
|
||||||
|
@ -4047,6 +4061,7 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
|
|
||||||
# At least one notification email failed to send
|
# At least one notification email failed to send
|
||||||
mock_send_addition_emails.return_value = False
|
mock_send_addition_emails.return_value = False
|
||||||
|
mock_send_update_email.return_value = False
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
|
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
|
||||||
|
@ -4066,6 +4081,8 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
mock_send_addition_emails.assert_called_once()
|
mock_send_addition_emails.assert_called_once()
|
||||||
# assert no removal emails are sent
|
# assert no removal emails are sent
|
||||||
mock_send_removal_emails.assert_not_called()
|
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
|
# Get the arguments passed to send_portfolio_admin_addition_emails
|
||||||
_, called_kwargs = mock_send_addition_emails.call_args
|
_, called_kwargs = mock_send_addition_emails.call_args
|
||||||
|
@ -4075,18 +4092,32 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
self.assertEqual(called_kwargs["requestor"], self.user)
|
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||||
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
||||||
|
|
||||||
# Assert warning message is called correctly
|
# Get the arguments passed to send_portfolio_member_permission_update_email
|
||||||
mock_messages_warning.assert_called_once()
|
_, called_kwargs = mock_send_update_email.call_args
|
||||||
warning_args, _ = mock_messages_warning.call_args
|
|
||||||
self.assertIsInstance(warning_args[0], WSGIRequest)
|
# Assert the update notification email content
|
||||||
self.assertEqual(warning_args[1], "Could not send email notification to existing organization admins.")
|
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
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_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."""
|
"""Tests updating an admin without changing permissions."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
@ -4096,6 +4127,7 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
user=admin_member,
|
user=admin_member,
|
||||||
portfolio=self.portfolio,
|
portfolio=self.portfolio,
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
|
additional_permissions=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
|
@ -4108,16 +4140,20 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
# Verify redirect and success message
|
# Verify redirect and success message
|
||||||
self.assertEqual(response.status_code, 302)
|
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_addition_emails.assert_not_called()
|
||||||
mock_send_removal_emails.assert_not_called()
|
mock_send_removal_emails.assert_not_called()
|
||||||
|
mock_send_update_email.assert_not_called()
|
||||||
|
|
||||||
@less_console_noise_decorator
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_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."""
|
"""Tests updating an admin without changing permissions."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
@ -4130,6 +4166,8 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
|
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mock_send_update_email.return_value = True
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
|
reverse("member-permissions", kwargs={"pk": basic_permission.id}),
|
||||||
{
|
{
|
||||||
|
@ -4146,13 +4184,25 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
# assert addition and removal emails are not sent to portfolio admins
|
# assert addition and removal emails are not sent to portfolio admins
|
||||||
mock_send_addition_emails.assert_not_called()
|
mock_send_addition_emails.assert_not_called()
|
||||||
mock_send_removal_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
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_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."""
|
"""Tests converting an admin to basic member."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
@ -4163,8 +4213,9 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
portfolio=self.portfolio,
|
portfolio=self.portfolio,
|
||||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||||
)
|
)
|
||||||
|
print(admin_permission)
|
||||||
mock_send_removal_emails.return_value = True
|
mock_send_removal_emails.return_value = True
|
||||||
|
mock_send_update_email.return_value = True
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
|
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
|
||||||
|
@ -4183,7 +4234,8 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
admin_permission.refresh_from_db()
|
admin_permission.refresh_from_db()
|
||||||
self.assertEqual(admin_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_MEMBER])
|
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_addition_emails.assert_not_called()
|
||||||
mock_send_removal_emails.assert_called_once()
|
mock_send_removal_emails.assert_called_once()
|
||||||
|
|
||||||
|
@ -4195,14 +4247,22 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
self.assertEqual(called_kwargs["requestor"], self.user)
|
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||||
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
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
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
@override_flag("organization_members", active=True)
|
@override_flag("organization_members", active=True)
|
||||||
@patch("django.contrib.messages.warning")
|
@patch("django.contrib.messages.warning")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
@patch("registrar.views.portfolios.send_portfolio_admin_addition_emails")
|
||||||
@patch("registrar.views.portfolios.send_portfolio_admin_removal_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(
|
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."""
|
"""Tests converting an admin to basic member."""
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
@ -4218,6 +4278,7 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
|
|
||||||
# False return indicates that at least one notification email failed to send
|
# False return indicates that at least one notification email failed to send
|
||||||
mock_send_removal_emails.return_value = False
|
mock_send_removal_emails.return_value = False
|
||||||
|
mock_send_update_email.return_value = False
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
|
reverse("member-permissions", kwargs={"pk": admin_permission.id}),
|
||||||
|
@ -4236,9 +4297,10 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
admin_permission.refresh_from_db()
|
admin_permission.refresh_from_db()
|
||||||
self.assertEqual(admin_permission.roles, [UserPortfolioRoleChoices.ORGANIZATION_MEMBER])
|
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_addition_emails.assert_not_called()
|
||||||
mock_send_removal_emails.assert_called_once()
|
mock_send_removal_emails.assert_called_once()
|
||||||
|
mock_send_update_email.assert_called_once()
|
||||||
|
|
||||||
# Get the arguments passed to send_portfolio_admin_removal_emails
|
# Get the arguments passed to send_portfolio_admin_removal_emails
|
||||||
_, called_kwargs = mock_send_removal_emails.call_args
|
_, called_kwargs = mock_send_removal_emails.call_args
|
||||||
|
@ -4248,11 +4310,22 @@ class TestPortfolioMemberEditView(WebTest):
|
||||||
self.assertEqual(called_kwargs["requestor"], self.user)
|
self.assertEqual(called_kwargs["requestor"], self.user)
|
||||||
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
|
||||||
|
|
||||||
# Assert warning message is called correctly
|
# Get the arguments passed to send_portfolio_member_permission_update_email
|
||||||
mock_messages_warning.assert_called_once()
|
_, called_kwargs = mock_send_update_email.call_args
|
||||||
warning_args, _ = mock_messages_warning.call_args
|
|
||||||
self.assertIsInstance(warning_args[0], WSGIRequest)
|
# Assert the email content
|
||||||
self.assertEqual(warning_args[1], "Could not send email notification to existing organization admins.")
|
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
|
@less_console_noise_decorator
|
||||||
@override_flag("organization_feature", active=True)
|
@override_flag("organization_feature", active=True)
|
||||||
|
|
|
@ -91,8 +91,6 @@ def send_templated_email( # noqa
|
||||||
subject = subject_template.render(context=context)
|
subject = subject_template.render(context=context)
|
||||||
subject = f"{prefix}{subject}"
|
subject = f"{prefix}{subject}"
|
||||||
|
|
||||||
context["subject"] = subject
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ses_client = boto3.client(
|
ses_client = boto3.client(
|
||||||
"sesv2",
|
"sesv2",
|
||||||
|
|
|
@ -226,6 +226,49 @@ def send_portfolio_invitation_email(email: str, requestor, portfolio, is_admin_i
|
||||||
return all_admin_emails_sent
|
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):
|
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
|
Notifies all portfolio admins of the provided portfolio of a newly invited portfolio admin
|
||||||
|
|
|
@ -32,6 +32,7 @@ from registrar.utility.email_invitations import (
|
||||||
send_portfolio_admin_addition_emails,
|
send_portfolio_admin_addition_emails,
|
||||||
send_portfolio_admin_removal_emails,
|
send_portfolio_admin_removal_emails,
|
||||||
send_portfolio_invitation_email,
|
send_portfolio_invitation_email,
|
||||||
|
send_portfolio_member_permission_update_email,
|
||||||
)
|
)
|
||||||
from registrar.utility.errors import MissingEmailError
|
from registrar.utility.errors import MissingEmailError
|
||||||
from registrar.utility.enums import DefaultUserValues
|
from registrar.utility.enums import DefaultUserValues
|
||||||
|
@ -219,6 +220,11 @@ class PortfolioMemberEditView(DetailView, View):
|
||||||
removing_admin_role_on_self = False
|
removing_admin_role_on_self = False
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
try:
|
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 form.is_change_from_member_to_admin():
|
||||||
if not send_portfolio_admin_addition_emails(
|
if not send_portfolio_admin_addition_emails(
|
||||||
email=portfolio_permission.user.email,
|
email=portfolio_permission.user.email,
|
||||||
|
|
|
@ -124,28 +124,54 @@ class AnalyticsView(View):
|
||||||
# include it in the larger context dictionary so it's available in the template rendering context.
|
# 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.
|
# This ensures that the admin interface styling and behavior are consistent with other admin pages.
|
||||||
**admin.site.each_context(request),
|
**admin.site.each_context(request),
|
||||||
data=dict(
|
data={
|
||||||
user_count=models.User.objects.all().count(),
|
# Tracks what kind of orgs we are keeping count of.
|
||||||
domain_count=models.Domain.objects.all().count(),
|
# Used for the details table beneath the graph.
|
||||||
ready_domain_count=models.Domain.objects.filter(state=models.Domain.State.READY).count(),
|
"org_count_types": [
|
||||||
last_30_days_applications=last_30_days_applications.count(),
|
"Total",
|
||||||
last_30_days_approved_applications=last_30_days_approved_applications.count(),
|
"Federal",
|
||||||
average_application_approval_time_last_30_days=avg_approval_time_display,
|
"Interstate",
|
||||||
managed_domains_sliced_at_start_date=managed_domains_sliced_at_start_date,
|
"State/Territory",
|
||||||
unmanaged_domains_sliced_at_start_date=unmanaged_domains_sliced_at_start_date,
|
"Tribal",
|
||||||
managed_domains_sliced_at_end_date=managed_domains_sliced_at_end_date,
|
"County",
|
||||||
unmanaged_domains_sliced_at_end_date=unmanaged_domains_sliced_at_end_date,
|
"City",
|
||||||
ready_domains_sliced_at_start_date=ready_domains_sliced_at_start_date,
|
"Special District",
|
||||||
deleted_domains_sliced_at_start_date=deleted_domains_sliced_at_start_date,
|
"School District",
|
||||||
ready_domains_sliced_at_end_date=ready_domains_sliced_at_end_date,
|
"Election Board",
|
||||||
deleted_domains_sliced_at_end_date=deleted_domains_sliced_at_end_date,
|
],
|
||||||
requests_sliced_at_start_date=requests_sliced_at_start_date,
|
"user_count": models.User.objects.all().count(),
|
||||||
submitted_requests_sliced_at_start_date=submitted_requests_sliced_at_start_date,
|
"domain_count": models.Domain.objects.all().count(),
|
||||||
requests_sliced_at_end_date=requests_sliced_at_end_date,
|
"ready_domain_count": models.Domain.objects.filter(state=models.Domain.State.READY).count(),
|
||||||
submitted_requests_sliced_at_end_date=submitted_requests_sliced_at_end_date,
|
"last_30_days_applications": last_30_days_applications.count(),
|
||||||
start_date=start_date,
|
"last_30_days_approved_applications": last_30_days_approved_applications.count(),
|
||||||
end_date=end_date,
|
"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)
|
return render(request, "admin/analytics.html", context)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue