merge of main

This commit is contained in:
David Kennedy 2025-01-02 19:54:51 -05:00
commit 84f6445405
No known key found for this signature in database
GPG key ID: 6528A5386E66B96B
43 changed files with 1223 additions and 392 deletions

53
src/package-lock.json generated
View file

@ -6921,16 +6921,6 @@
"validate-npm-package-license": "^3.0.1"
}
},
"node_modules/normalize-package-data/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -7307,39 +7297,6 @@
"node": ">= 12"
}
},
"node_modules/pa11y/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pa11y/node_modules/semver": {
"version": "7.3.8",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
"integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pa11y/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/parse-filepath": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
@ -8888,13 +8845,15 @@
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/semver-greatest-satisfied-range": {

View file

@ -0,0 +1,15 @@
import { submitForm } from './helpers.js';
export function initDomainDNSSEC() {
document.addEventListener('DOMContentLoaded', function() {
let domain_dnssec_page = document.getElementById("domain-dnssec");
if (domain_dnssec_page) {
const button = document.getElementById("disable-dnssec-button");
if (button) {
button.addEventListener("click", function () {
submitForm("disable-dnssec-form");
});
}
}
});
}

View file

@ -0,0 +1,27 @@
import { submitForm } from './helpers.js';
export function initDomainDSData() {
document.addEventListener('DOMContentLoaded', function() {
let domain_dsdata_page = document.getElementById("domain-dsdata");
if (domain_dsdata_page) {
const override_button = document.getElementById("disable-override-click-button");
const cancel_button = document.getElementById("btn-cancel-click-button");
const cancel_close_button = document.getElementById("btn-cancel-click-close-button");
if (override_button) {
override_button.addEventListener("click", function () {
submitForm("disable-override-click-form");
});
}
if (cancel_button) {
cancel_button.addEventListener("click", function () {
submitForm("btn-cancel-click-form");
});
}
if (cancel_close_button) {
cancel_close_button.addEventListener("click", function () {
submitForm("btn-cancel-click-form");
});
}
}
});
}

View file

@ -0,0 +1,20 @@
import { submitForm } from './helpers.js';
export function initDomainManagersPage() {
document.addEventListener('DOMContentLoaded', function() {
let domain_managers_page = document.getElementById("domain-managers");
if (domain_managers_page) {
// Add event listeners for all buttons matching user-delete-button-{NUMBER}
const deleteButtons = document.querySelectorAll('[id^="user-delete-button-"]'); // Select buttons with ID starting with "user-delete-button-"
deleteButtons.forEach((button) => {
const buttonId = button.id; // e.g., "user-delete-button-1"
const number = buttonId.split('-').pop(); // Extract the NUMBER part
const formId = `user-delete-form-${number}`; // Generate the corresponding form ID
button.addEventListener("click", function () {
submitForm(formId); // Pass the form ID to submitForm
});
});
}
});
}

View file

@ -0,0 +1,12 @@
import { submitForm } from './helpers.js';
export function initDomainRequestForm() {
document.addEventListener('DOMContentLoaded', function() {
const button = document.getElementById("domain-request-form-submit-button");
if (button) {
button.addEventListener("click", function () {
submitForm("submit-domain-request-form");
});
}
});
}

View file

@ -0,0 +1,19 @@
export function initFormErrorHandling() {
document.addEventListener('DOMContentLoaded', function() {
const errorSummary = document.getElementById('form-errors');
const firstErrorField = document.querySelector('.usa-input--error');
if (firstErrorField) {
// Scroll to the first field in error
firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Add focus to the first field in error
setTimeout(() => {
firstErrorField.focus();
}, 50);
} else if (errorSummary) {
// Scroll to the error summary
errorSummary.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}

View file

@ -1,9 +1,17 @@
export function hideElement(element) {
element.classList.add('display-none');
if (element) {
element.classList.add('display-none');
} else {
throw new Error('hideElement expected a passed DOM element as an argument, but none was provided.');
}
};
export function showElement(element) {
element.classList.remove('display-none');
if (element) {
element.classList.remove('display-none');
} else {
throw new Error('showElement expected a passed DOM element as an argument, but none was provided.');
}
};
/**
@ -75,3 +83,16 @@ export function debounce(handler, cooldown=600) {
export function getCsrfToken() {
return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
}
/**
* Helper function to submit a form
* @param {} form_id - the id of the form to be submitted
*/
export function submitForm(form_id) {
let form = document.getElementById(form_id);
if (form) {
form.submit();
} else {
console.error("Form '" + form_id + "' not found.");
}
}

View file

@ -11,6 +11,11 @@ import { initMembersTable } from './table-members.js';
import { initMemberDomainsTable } from './table-member-domains.js';
import { initEditMemberDomainsTable } from './table-edit-member-domains.js';
import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js';
import { initDomainRequestForm } from './domain-request-form.js';
import { initDomainManagersPage } from './domain-managers.js';
import { initDomainDSData } from './domain-dsdata.js';
import { initDomainDNSSEC } from './domain-dnssec.js';
import { initFormErrorHandling } from './form-errors.js';
initDomainValidators();
@ -36,6 +41,13 @@ initMembersTable();
initMemberDomainsTable();
initEditMemberDomainsTable();
initDomainRequestForm();
initDomainManagersPage();
initDomainDSData();
initDomainDNSSEC();
initFormErrorHandling();
// Init the portfolio new member page
initPortfolioMemberPageRadio();
initPortfolioNewMemberPageToggle();

View file

@ -143,7 +143,7 @@ export class BaseTable {
this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`);
this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`);
this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`);
this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`);
this.noDataTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`);
this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`);
this.portfolioElement = document.getElementById('portfolio-js-value');
this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null;
@ -451,7 +451,7 @@ export class BaseTable {
}
// handle the display of proper messaging in the event that no members exist in the list or search returns no results
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
this.updateDisplay(data, this.tableWrapper, this.noDataTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
// identify the DOM element where the list of results will be inserted into the DOM
const tbody = this.tableWrapper.querySelector('tbody');
tbody.innerHTML = '';
@ -495,7 +495,8 @@ export class BaseTable {
// Add event listeners to table headers for sorting
initializeTableHeaders() {
this.tableHeaders.forEach(header => {
header.addEventListener('click', () => {
header.addEventListener('click', event => {
let button = header.querySelector('.usa-table__header__button')
const sortBy = header.getAttribute('data-sortable');
let order = 'asc';
// sort order will be ascending, unless the currently sorted column is ascending, and the user
@ -505,6 +506,13 @@ export class BaseTable {
}
// load the results with the updated sort
this.loadTable(1, sortBy, order);
// If the click occurs outside of the button, need to simulate a button click in order
// for USWDS listener on the button to execute.
// Check first to see if click occurs outside of the button
if (!button.contains(event.target)) {
// Simulate a button click
button.click();
}
});
});
}

View file

@ -1,5 +1,6 @@
import { BaseTable } from './table-base.js';
import { hideElement, showElement } from './helpers.js';
/**
* EditMemberDomainsTable is used for PortfolioMember and PortfolioInvitedMember
@ -18,8 +19,14 @@ export class EditMemberDomainsTable extends BaseTable {
this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains which are readonly
this.addedDomains = []; // list of domains added to member
this.removedDomains = []; // list of domains removed from member
this.editModeContainer = document.getElementById('domain-assignments-edit-view');
this.readonlyModeContainer = document.getElementById('domain-assignments-readonly-view');
this.reviewButton = document.getElementById('review-domain-assignments');
this.backButton = document.getElementById('back-to-edit-domain-assignments');
this.saveButton = document.getElementById('save-domain-assignments');
this.initializeDomainAssignments();
this.initCancelEditDomainAssignmentButton();
this.initEventListeners();
}
getBaseUrl() {
return document.getElementById("get_member_domains_json_url");
@ -55,6 +62,14 @@ export class EditMemberDomainsTable extends BaseTable {
getSearchParams(page, sortBy, order, searchTerm, status, portfolio) {
let searchParams = super.getSearchParams(page, sortBy, order, searchTerm, status, portfolio);
// Add checkedDomains to searchParams
let checkedDomains = this.getCheckedDomains();
// Append updated checkedDomain IDs to searchParams
if (checkedDomains.length > 0) {
searchParams.append("checkedDomainIds", checkedDomains.join(","));
}
return searchParams;
}
getCheckedDomains() {
// Clone the initial domains to avoid mutating them
let checkedDomains = [...this.initialDomainAssignments];
// Add IDs from addedDomains that are not already in checkedDomains
@ -70,11 +85,7 @@ export class EditMemberDomainsTable extends BaseTable {
checkedDomains.splice(index, 1);
}
});
// Append updated checkedDomain IDs to searchParams
if (checkedDomains.length > 0) {
searchParams.append("checkedDomainIds", checkedDomains.join(","));
}
return searchParams;
return checkedDomains
}
addRow(dataObject, tbody, customTableOptions) {
const domain = dataObject;
@ -217,7 +228,123 @@ export class EditMemberDomainsTable extends BaseTable {
}
});
}
updateReadonlyDisplay() {
let totalAssignedDomains = this.getCheckedDomains().length;
// Create unassigned domains list
const unassignedDomainsList = document.createElement('ul');
unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled');
this.removedDomains.forEach(removedDomain => {
const removedDomainListItem = document.createElement('li');
removedDomainListItem.textContent = removedDomain.name; // Use textContent for security
unassignedDomainsList.appendChild(removedDomainListItem);
});
// Create assigned domains list
const assignedDomainsList = document.createElement('ul');
assignedDomainsList.classList.add('usa-list', 'usa-list--unstyled');
this.addedDomains.forEach(addedDomain => {
const addedDomainListItem = document.createElement('li');
addedDomainListItem.textContent = addedDomain.name; // Use textContent for security
assignedDomainsList.appendChild(addedDomainListItem);
});
// Get the summary container
const domainAssignmentSummary = document.getElementById('domain-assignments-summary');
// Clear existing content
domainAssignmentSummary.innerHTML = '';
// Append unassigned domains section
if (this.removedDomains.length) {
const unassignedHeader = document.createElement('h3');
unassignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1');
unassignedHeader.textContent = 'Unassigned domains';
domainAssignmentSummary.appendChild(unassignedHeader);
domainAssignmentSummary.appendChild(unassignedDomainsList);
}
// Append assigned domains section
if (this.addedDomains.length) {
const assignedHeader = document.createElement('h3');
assignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1');
assignedHeader.textContent = 'Assigned domains';
domainAssignmentSummary.appendChild(assignedHeader);
domainAssignmentSummary.appendChild(assignedDomainsList);
}
// Append total assigned domains section
const totalHeader = document.createElement('h3');
totalHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1');
totalHeader.textContent = 'Total assigned domains';
domainAssignmentSummary.appendChild(totalHeader);
const totalCount = document.createElement('p');
totalCount.classList.add('margin-y-0');
totalCount.textContent = totalAssignedDomains;
domainAssignmentSummary.appendChild(totalCount);
}
showReadonlyMode() {
this.updateReadonlyDisplay();
hideElement(this.editModeContainer);
showElement(this.readonlyModeContainer);
}
showEditMode() {
hideElement(this.readonlyModeContainer);
showElement(this.editModeContainer);
}
submitChanges() {
let memberDomainsEditForm = document.getElementById("member-domains-edit-form");
if (memberDomainsEditForm) {
// Serialize data to send
const addedDomainIds = this.addedDomains.map(domain => domain.id);
const addedDomainsInput = document.createElement('input');
addedDomainsInput.type = 'hidden';
addedDomainsInput.name = 'added_domains'; // Backend will use this key to retrieve data
addedDomainsInput.value = JSON.stringify(addedDomainIds); // Stringify the array
const removedDomainsIds = this.removedDomains.map(domain => domain.id);
const removedDomainsInput = document.createElement('input');
removedDomainsInput.type = 'hidden';
removedDomainsInput.name = 'removed_domains'; // Backend will use this key to retrieve data
removedDomainsInput.value = JSON.stringify(removedDomainsIds); // Stringify the array
// Append input to the form
memberDomainsEditForm.appendChild(addedDomainsInput);
memberDomainsEditForm.appendChild(removedDomainsInput);
memberDomainsEditForm.submit();
}
}
initEventListeners() {
if (this.reviewButton) {
this.reviewButton.addEventListener('click', () => {
this.showReadonlyMode();
});
} else {
console.warn('Missing DOM element. Expected element with id review-domain-assignments');
}
if (this.backButton) {
this.backButton.addEventListener('click', () => {
this.showEditMode();
});
} else {
console.warn('Missing DOM element. Expected element with id back-to-edit-domain-assignments');
}
if (this.saveButton) {
this.saveButton.addEventListener('click', () => {
this.submitChanges();
});
} else {
console.warn('Missing DOM element. Expected element with id save-domain-assignments');
}
}
}
export function initEditMemberDomainsTable() {

View file

@ -1,4 +1,5 @@
import { showElement, hideElement } from './helpers.js';
import { BaseTable } from './table-base.js';
export class MemberDomainsTable extends BaseTable {
@ -24,7 +25,28 @@ export class MemberDomainsTable extends BaseTable {
`;
tbody.appendChild(row);
}
updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
const { unfiltered_total, total } = data;
const searchSection = document.getElementById('edit-member-domains__search');
if (!searchSection) console.warn('MemberDomainsTable updateDisplay expected an element with id edit-member-domains__search but none was found');
if (unfiltered_total) {
showElement(searchSection);
if (total) {
showElement(dataWrapper);
hideElement(noSearchResultsWrapper);
hideElement(noDataWrapper);
} else {
hideElement(dataWrapper);
showElement(noSearchResultsWrapper);
hideElement(noDataWrapper);
}
} else {
hideElement(searchSection);
hideElement(dataWrapper);
hideElement(noSearchResultsWrapper);
showElement(noDataWrapper);
}
};
}
export function initMemberDomainsTable() {

View file

@ -40,7 +40,11 @@
top: 30px;
}
tr:last-child .usa-accordion--more-actions .usa-accordion__content {
// Special positioning for the kabob menu popup in the last row on a given page
// This won't work on the Members table rows because that table has show-more rows
// Currently, that's not an issue since that Members table is not wrapped in the
// reponsive wrapper.
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
top: auto;
bottom: -10px;
right: 30px;

View file

@ -12,7 +12,7 @@
margin-top: units(1);
}
// register-form-review-header is used on the summary page and
// header--body is used on the summary page and
// should not be styled like the register form headers
.register-form-step h3 {
color: color('primary-dark');
@ -25,15 +25,6 @@
}
}
.register-form-review-header {
color: color('primary-dark');
margin-top: units(2);
margin-bottom: 0;
font-weight: font-weight('semibold');
// The units mixin can only get us close, so it's between
// hardcoding the value and using in markup
font-size: 16.96px;
}
.register-form-step h4 {
margin-bottom: 0;

View file

@ -88,8 +88,14 @@ th {
}
@include at-media(tablet-lg) {
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
th[data-sortable] .usa-table__header__button {
right: auto;
&[aria-sort=ascending],
&[aria-sort=descending],
&:not([aria-sort]) {
right: auto;
}
}
}
}

View file

@ -23,6 +23,14 @@ h2 {
color: color('primary-darker');
}
.header--body {
margin-top: units(2);
font-weight: font-weight('semibold');
// The units mixin can only get us close, so it's between
// hardcoding the value and using in markup
font-size: 16.96px;
}
.h4--sm-05 {
font-size: size('body', 'sm');
font-weight: normal;

View file

@ -60,7 +60,10 @@ class UserPortfolioPermissionFixture:
user=user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
additional_permissions=[
UserPortfolioPermissionChoices.EDIT_MEMBERS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
user_portfolio_permissions_to_create.append(user_portfolio_permission)
else:

View file

@ -27,7 +27,7 @@
{% endif %}
{% endblock breadcrumb %}
<h1>DNSSEC</h1>
<h1 id="domain-dnssec">DNSSEC</h1>
<p>DNSSEC, or DNS Security Extensions, is an additional security layer to protect your website. Enabling DNSSEC ensures that when someone visits your domain, they can be certain that its connecting to the correct server, preventing potential hijacking or tampering with your domain's records.</p>
@ -78,7 +78,11 @@
aria-labelledby="Are you sure you want to continue?"
aria-describedby="Your DNSSEC records will be deleted from the registry."
>
{% include 'includes/modal.html' with modal_heading="Are you sure you want to disable DNSSEC?" modal_button=modal_button|safe %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to disable DNSSEC?" modal_button_id="disable-dnssec-button" modal_button_text="Confirm" modal_button_class="usa-button--secondary" %}
</div>
<form method="post" id="disable-dnssec-form">
{% csrf_token %}
<input type="hidden" name="disable_dnssec" value="1">
</form>
{% endblock %} {# domain_content #}

View file

@ -42,7 +42,7 @@
{% include "includes/form_errors.html" with form=form %}
{% endfor %}
<h1>DS data</h1>
<h1 id="domain-dsdata">DS data</h1>
<p>In order to enable DNSSEC, you must first configure it with your DNS hosting service.</p>
@ -141,7 +141,15 @@
aria-describedby="Your DNSSEC records will be deleted from the registry."
data-force-action
>
{% include 'includes/modal.html' with cancel_button_resets_ds_form=True modal_heading="Warning: You are about to remove all DS records on your domain." modal_description="To fully disable DNSSEC: In addition to removing your DS records here, youll need to delete the DS records at your DNS host. To avoid causing your domain to appear offline, you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button=modal_button|safe %}
{% include 'includes/modal.html' with cancel_button_resets_ds_form=True modal_heading="Warning: You are about to remove all DS records on your domain." modal_description="To fully disable DNSSEC: In addition to removing your DS records here, youll need to delete the DS records at your DNS host. To avoid causing your domain to appear offline, you should wait to delete your DS records at your DNS host until the Time to Live (TTL) expires. This is often less than 24 hours, but confirm with your provider." modal_button_id="disable-override-click-button" modal_button_text="Remove all DS data" modal_button_class="usa-button--secondary" %}
</div>
<form method="post" id="disable-override-click-form">
{% csrf_token %}
<input type="hidden" name="disable-override-click" value="1">
</form>
<form method="post" id="btn-cancel-click-form">
{% csrf_token %}
<input type="hidden" name="btn-cancel-click" value="1">
</form>
{% endblock %} {# domain_content #}

View file

@ -130,9 +130,17 @@
aria-describedby="Are you sure you want to submit a domain request?"
data-force-action
>
{% include 'includes/modal.html' with is_domain_request_form=True review_form_is_complete=review_form_is_complete modal_heading=modal_heading|safe modal_description=modal_description|safe modal_button=modal_button|safe %}
{% if review_form_is_complete %}
{% include 'includes/modal.html' with modal_heading="You are about to submit a domain request for " domain_name_modal=requested_domain__name modal_description="Once you submit this request, you wont be able to edit it until we review it. Youll only be able to withdraw your request." modal_button_id="domain-request-form-submit-button" modal_button_text="Submit request" %}
{% else %}
{% include 'includes/modal.html' with modal_heading="Your request form is incomplete" modal_description='This request cannot be submitted yet. Return to the request and visit the steps that are marked as "incomplete."' modal_button_text="Return to request" cancel_button_only=True %}
{% endif %}
</div>
<form method="post" id="submit-domain-request-form">
{% csrf_token %}
</form>
{% block after_form_content %}{% endblock %}
</main>

View file

@ -49,7 +49,7 @@
</ul>
{% if domain_manager_roles %}
<section class="section-outlined">
<section class="section-outlined" id="domain-managers">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<h2 class> Domain managers </h2>
<caption class="sr-only">Domain managers</caption>
@ -89,12 +89,13 @@
aria-describedby="You will be removed from this domain"
data-force-action
>
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}">
{% with domain_name=domain.name|force_escape %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button_self|safe %}
{% endwith %}
</form>
{% with domain_name=domain.name|force_escape counter_str=forloop.counter|stringformat:"s" %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove myself" modal_button_class="usa-button--secondary" %}
{% endwith %}
</div>
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}" >
{% csrf_token %}
</form>
{% else %}
<div
class="usa-modal"
@ -103,12 +104,13 @@
aria-describedby="{{ item.permission.user.email }} will be removed"
data-force-action
>
<form method="POST" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}">
{% with email=item.permission.user.email|default:item.permission.user|force_escape domain_name=domain.name|force_escape %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button=modal_button|safe %}
{% endwith %}
</form>
{% with email=item.permission.user.email|default:item.permission.user|force_escape domain_name=domain.name|force_escape counter_str=forloop.counter|stringformat:"s" %}
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove domain manager" modal_button_class="usa-button--secondary" %}
{% endwith %}
</div>
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}">
{% csrf_token %}
</form>
{% endif %}
{% else %}
<input

View file

@ -213,7 +213,7 @@
{# We always show this field even if None #}
{% if DomainRequest %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<h3 class="header--body text-primary-dark margin-bottom-0">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.cisa_representative_first_name %}
{{ DomainRequest.get_formatted_cisa_rep_name }}
@ -221,7 +221,7 @@
No
{% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3>
<h3 class="header--body text-primary-dark margin-bottom-0">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.anything_else %}
{{DomainRequest.anything_else}}

View file

@ -1,6 +1,7 @@
{% if form.errors %}
<div id="form-errors">
{% for error in form.non_field_errors %}
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2">
<div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" role="alert">
<div class="usa-alert__body">
{{ error|escape }}
</div>
@ -14,5 +15,6 @@
</div>
</div>
{% endfor %}
{% endfor %}
{% endfor %}
</div>
{% endif %}

View file

@ -34,7 +34,7 @@
{% endif %}
</h2>
<div class="section-outlined__header margin-bottom-3 grid-row">
<div class="section-outlined__header margin-bottom-3 grid-row" id="edit-member-domains__search">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9">
<section aria-label="Member domains search component" class="margin-top-2">

View file

@ -1,4 +1,5 @@
{% load static form_helpers url_helpers %}
{% load custom_filters %}
<div class="usa-modal__content">
<div class="usa-modal__main">
@ -24,39 +25,51 @@
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
{% if not_form and modal_button %}
{{ modal_button }}
{% if cancel_button_only %}
<button
type="button"
class="{{ modal_button_class|button_class }}"
data-close-modal
>
{% if modal_button_text %}
{{ modal_button_text }}
{% else %}
Cancel
{% endif %}
</button>
{% elif modal_button_id and modal_button_text %}
{% comment %} Adding button id allows for onclick listeners on button by id,
which execute form submission on form elsewhere on a page outside modal.{% endcomment %}
<button
id="{{ modal_button_id }}"
type="button"
class="{{ modal_button_class|button_class }}"
>
{{ modal_button_text }}
</button>
{% elif modal_button_url and modal_button_text %}
<a
href="{{ modal_button_url }}"
type="button"
class="usa-button"
class="{{ modal_button_class|button_class }}"
>
{{ modal_button_text }}
</a>
{% else %}
<form method="post">
{% csrf_token %}
{{ modal_button }}
</form>
{% endif %}
</li>
<li class="usa-button-group__item">
{% comment %} The cancel button the DS form actually triggers a context change in the view,
in addition to being a close modal hook {% endcomment %}
{% if cancel_button_resets_ds_form %}
<form method="post">
{% csrf_token %}
<button
type="submit"
class="usa-button usa-button--unstyled padding-105 text-center"
name="btn-cancel-click"
data-close-modal
>
Cancel
</button>
</form>
{% elif not is_domain_request_form or review_form_is_complete %}
<button
type="submit"
class="usa-button usa-button--unstyled padding-105 text-center"
id="btn-cancel-click-button"
data-close-modal
>
Cancel
</button>
{% elif not cancel_button_only %}
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
@ -72,20 +85,17 @@
{% comment %} The cancel button the DS form actually triggers a context change in the view,
in addition to being a close modal hook {% endcomment %}
{% if cancel_button_resets_ds_form %}
<form method="post">
{% csrf_token %}
<button
type="submit"
class="usa-button usa-modal__close"
aria-label="Close this window"
name="btn-cancel-click"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</form>
<button
type="submit"
class="usa-button usa-modal__close"
aria-label="Close this window"
id="btn-cancel-click-close-button"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
{% else %}
<button
type="button"

View file

@ -46,7 +46,7 @@
{% endwith %}
{% if domain_request.alternative_domains.all %}
<h3 class="register-form-review-header">Alternative domains</h3>
<h3 class="header--body text-primary-dark margin-bottom-0">Alternative domains</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% for site in domain_request.alternative_domains.all %}
<li>{{ site.website }}</li>

View file

@ -88,7 +88,7 @@
{% endwith %}
{% if domain_request.alternative_domains.all %}
<h3 class="register-form-review-header">Alternative domains</h3>
<h3 class="header--body text-primary-dark margin-bottom-0">Alternative domains</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% for site in domain_request.alternative_domains.all %}
<li>{{ site.website }}</li>
@ -132,7 +132,7 @@
{% with title=form_titles|get_item:step %}
{% if domain_request.has_additional_details %}
{% include "includes/summary_item.html" with title="Additional Details" value=" " heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<h3 class="header--body text-primary-dark margin-bottom-0">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if domain_request.cisa_representative_first_name %}
<li>{{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}</li>
@ -144,7 +144,7 @@
{% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3>
<h3 class="header--body text-primary-dark margin-bottom-0">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if domain_request.anything_else %}
{{domain_request.anything_else}}

View file

@ -216,7 +216,7 @@
{# We always show this field even if None #}
{% if DomainRequest %}
<h3 class="register-form-review-header">CISA Regional Representative</h3>
<h3 class="header--body text-primary-dark margin-bottom-0">CISA Regional Representative</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.cisa_representative_first_name %}
{{ DomainRequest.get_formatted_cisa_rep_name }}
@ -224,7 +224,7 @@
No
{% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3>
<h3 class="header--body text-primary-dark margin-bottom-0">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.anything_else %}
{{DomainRequest.anything_else}}

View file

@ -22,7 +22,7 @@
</h3>
{% endif %}
{% if sub_header_text %}
<h4 class="register-form-review-header">{{ sub_header_text }}</h4>
<h4 class="header--body text-primary-dark margin-bottom-0">{{ sub_header_text }}</h4>
{% endif %}
{% if permissions %}
{% include "includes/member_permissions.html" with permissions=value %}

View file

@ -17,53 +17,109 @@
{% url 'invitedmember-domains' pk=portfolio_invitation.id as url3 %}
{% endif %}
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{{ url2 }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{{ url3 }}" class="usa-breadcrumb__link"><span>Domain assignments</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current edit-domain-assignments-breadcrumb" aria-current="page">
<span>Edit domain assignments</span>
</li>
</ol>
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{{ url2 }}" class="usa-breadcrumb__link"><span>Manage member</span></a>
</li>
<li class="usa-breadcrumb__list-item">
<a href="{{ url3 }}" class="usa-breadcrumb__link"><span>Domain assignments</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current domain-assignments-edit-breadcrumb" aria-current="page">
<span>Edit domain assignments</span>
</li>
</ol>
</nav>
<h1 class="margin-bottom-3">Edit domain assignments</h1>
<section id="domain-assignments-edit-view">
<h1 class="margin-bottom-3">Edit domain assignments</h1>
<p class="margin-bottom-0">
A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains.
</p>
<p>
When you save this form the member will get an email to notify them of any changes.
</p>
<p class="margin-bottom-0">
A domain manager can be assigned to any domain across the organization. Domain managers can change domain information, adjust DNS settings, and invite or assign other domain managers to their assigned domains.
</p>
<p>
When you save this form the member will get an email to notify them of any changes.
</p>
{% include "includes/member_domains_edit_table.html" %}
{% include "includes/member_domains_edit_table.html" %}
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
id="cancel-edit-domain-assignments"
type="button"
class="usa-button usa-button--outline"
>
Cancel
</button>
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
id="cancel-edit-domain-assignments"
type="button"
class="usa-button usa-button--outline"
>
Cancel
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button"
>
Review
</button>
</li>
</ul>
</li>
<li class="usa-button-group__item">
<button
id="review-domain-assignments"
type="button"
class="usa-button"
>
Review
</button>
</li>
</ul>
</section>
<section id="domain-assignments-readonly-view" class="display-none">
<h1 class="margin-bottom-3">Review domain assignments</h1>
<h2 class="text-primary-dark">Would you like to continue with the following domain assignment changes for
{% if member %}
{{ member.email }}
{% else %}
{{ portfolio_invitation.email }}
{% endif %}
</h2>
<p>When you save this form the member will get an email to notify them of any changes.</p>
<div id="domain-assignments-summary" class="margin-bottom-2">
<!-- AJAX will populate this summary -->
<h3 class="header--body text-primary margin-bottom-1">Unassigned domains</h3>
<ul class="usa-list usa-list--unstyled">
<li>item1</li>
<li>item2</li>
</ul>
<h3 class="header--body text-primary-dark margin-bottom-0">Assigned domains</h3>
<ul class="usa-list usa-list--unstyled">
<li>item1</li>
<li>item2</li>
</ul>
</div>
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--outline"
id="back-to-edit-domain-assignments"
>
Back
</button>
</li>
<li class="usa-button-group__item">
<button
id="save-domain-assignments"
type="button"
class="usa-button"
>
Save
</button>
</li>
</ul>
</section>
<form method="post" id="member-domains-edit-form" action="{{ request.path }}"> {% csrf_token %} </form>
</div>
{% endblock %}

View file

@ -19,7 +19,7 @@
{% if has_edit_request_portfolio_permission %}
<div class="mobile:grid-col-12 tablet:grid-col-6">
<p class="margin-y-0 maxw-mobile">Domain requests can only be modified by the person who created the request.</p>
<p class="margin-y-0">Domain requests can only be modified by the person who created the request.</p>
</div>
<div class="mobile:grid-col-12 tablet:grid-col-6">

View file

@ -290,3 +290,9 @@ def get_dict_value(dictionary, key):
if isinstance(dictionary, dict):
return dictionary.get(key, "")
return ""
@register.filter
def button_class(custom_class):
default_class = "usa-button"
return f"{default_class} {custom_class}" if custom_class else default_class

View file

@ -16,7 +16,7 @@ from registrar.utility.csv_export import (
DomainDataType,
DomainDataFederal,
DomainDataTypeUser,
DomainRequestsDataType,
DomainRequestDataType,
DomainGrowth,
DomainManaged,
DomainUnmanaged,
@ -456,11 +456,11 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
portfolio.delete()
def _run_domain_request_data_type_user_export(self, request):
"""Helper function to run the exporting_dr_data_to_csv function on DomainRequestsDataType"""
"""Helper function to run the export_data_to_csv function on DomainRequestDataType"""
csv_file = StringIO()
DomainRequestsDataType.exporting_dr_data_to_csv(csv_file, request=request)
DomainRequestDataType.export_data_to_csv(csv_file, request=request)
csv_file.seek(0)
@ -773,9 +773,9 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# Content
"city5.gov,Approved,Federal,Executive,,Testorg,N/A,,NY,2,,,,1,0,city1.gov,Testy,Tester,testy@town.com,"
"Chief Tester,Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,,"
"city2.gov,In review,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,NY,2,,,,0,1,city1.gov,,,,,"
"Purpose of the site,There is more,Testy Tester testy2@town.com,,city.com,\n"
"city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,"
"city3.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,NY,2,,,,0,1,"
'"cheeseville.gov, city1.gov, igorville.gov",,,,,Purpose of the site,CISA-first-name CISA-last-name | '
'There is more,"Meow Tester24 te2@town.com, Testy1232 Tester24 te2@town.com, '
'Testy Tester testy2@town.com",'
@ -785,7 +785,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
"Chief Tester,Purpose of the site,CISA-first-name CISA-last-name | There is more,"
"Testy Tester testy2@town.com,"
"cisaRep@igorville.gov,city.com,\n"
"city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,,2,,,,0,1,city1.gov,,,,,"
"city6.gov,Submitted,Federal,Executive,Portfolio 1 Federal Agency,,N/A,,NY,2,,,,0,1,city1.gov,,,,,"
"Purpose of the site,CISA-first-name CISA-last-name | There is more,Testy Tester testy2@town.com,"
"cisaRep@igorville.gov,city.com,\n"
)
@ -794,6 +794,7 @@ class ExportDataTest(MockDbForIndividualTests, MockEppLib):
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content)

View file

@ -94,6 +94,12 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
DomainInvitation.objects.create(
email=cls.invited_member_email, domain=cls.domain2, status=DomainInvitation.DomainInvitationStatus.INVITED
)
DomainInvitation.objects.create(
email=cls.invited_member_email, domain=cls.domain3, status=DomainInvitation.DomainInvitationStatus.CANCELED
)
DomainInvitation.objects.create(
email=cls.invited_member_email, domain=cls.domain4, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
)
@classmethod
def tearDownClass(cls):
@ -138,7 +144,8 @@ class GetPortfolioMemberDomainsJsonTest(TestWithUser, WebTest):
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_get_portfolio_invitedmember_domains_json_authenticated(self):
"""Test that portfolio invitedmember's domains are returned properly for an authenticated user."""
"""Test that portfolio invitedmember's domains are returned properly for an authenticated user.
CANCELED and RETRIEVED invites should be ignored."""
response = self.app.get(
reverse("get_member_domains_json"),
params={"portfolio": self.portfolio.id, "email": self.invited_member_email, "member_only": "true"},

View file

@ -157,7 +157,7 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
@override_flag("organization_members", active=True)
def test_get_portfolio_invited_json_authenticated(self):
"""Test that portfolio invitees are returned properly for an authenticated user."""
"""Also tests that reposnse is 200 when no domains"""
"""Also tests that response is 200 when no domains"""
UserPortfolioPermission.objects.create(
user=self.user,
portfolio=self.portfolio,
@ -258,13 +258,14 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
role=UserDomainRole.Roles.MANAGER,
)
# create domain for which user is manager and domain not in portfolio
# create another domain in the portfolio
domain2 = Domain.objects.create(
name="somedomain2.com",
name="thissecondpermtestsmultipleperms@lets.notbreak",
)
DomainInformation.objects.create(
creator=self.user,
domain=domain2,
portfolio=self.portfolio,
)
UserDomainRole.objects.create(
user=self.user,
@ -272,6 +273,20 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
role=UserDomainRole.Roles.MANAGER,
)
# create domain for which user is manager and domain not in portfolio
domain3 = Domain.objects.create(
name="somedomain3.com",
)
DomainInformation.objects.create(
creator=self.user,
domain=domain3,
)
UserDomainRole.objects.create(
user=self.user,
domain=domain3,
role=UserDomainRole.Roles.MANAGER,
)
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200)
data = response.json
@ -279,7 +294,8 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
# Check if the domain appears in the response JSON and that domain2 does not
domain_names = [domain_name for member in data["members"] for domain_name in member.get("domain_names", [])]
self.assertIn("somedomain1.com", domain_names)
self.assertNotIn("somedomain2.com", domain_names)
self.assertIn("thissecondpermtestsmultipleperms@lets.notbreak", domain_names)
self.assertNotIn("somedomain3.com", domain_names)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@ -318,19 +334,33 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
domain=domain,
)
# create a domain not in the portfolio
# create another domain in the portfolio
domain2 = Domain.objects.create(
name="somedomain2.com",
name="thissecondinvitetestsasubqueryinjson@lets.notbreak",
)
DomainInformation.objects.create(
creator=self.user,
domain=domain2,
portfolio=self.portfolio,
)
DomainInvitation.objects.create(
email=self.email6,
domain=domain2,
)
# create a domain not in the portfolio
domain3 = Domain.objects.create(
name="somedomain3.com",
)
DomainInformation.objects.create(
creator=self.user,
domain=domain3,
)
DomainInvitation.objects.create(
email=self.email6,
domain=domain3,
)
response = self.app.get(reverse("get_portfolio_members_json"), params={"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200)
data = response.json
@ -338,7 +368,8 @@ class GetPortfolioMembersJsonTest(MockEppLib, WebTest):
# Check if the domain appears in the response JSON and domain2 does not
domain_names = [domain_name for member in data["members"] for domain_name in member.get("domain_names", [])]
self.assertIn("somedomain1.com", domain_names)
self.assertNotIn("somedomain2.com", domain_names)
self.assertIn("thissecondinvitetestsasubqueryinjson@lets.notbreak", domain_names)
self.assertNotIn("somedomain3.com", domain_names)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)

View file

@ -14,6 +14,7 @@ from registrar.models import (
Suborganization,
AllowedEmail,
)
from registrar.models.domain_invitation import DomainInvitation
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_group import UserGroup
from registrar.models.user_portfolio_permission import UserPortfolioPermission
@ -27,6 +28,7 @@ from django.contrib.sessions.middleware import SessionMiddleware
import boto3_mocking # type: ignore
from django.test import Client
import logging
import json
logger = logging.getLogger(__name__)
@ -1929,7 +1931,7 @@ class TestPortfolioMemberDomainsView(TestWithUser, WebTest):
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
cls.portfolio_permission = UserPortfolioPermission.objects.create(
user=cls.user,
portfolio=cls.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
@ -2108,11 +2110,22 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.url = reverse("member-domains-edit", kwargs={"pk": cls.portfolio_permission.pk})
@classmethod
def tearDownClass(cls):
super().tearDownClass()
def setUp(self):
super().setUp()
names = ["1.gov", "2.gov", "3.gov"]
Domain.objects.bulk_create([Domain(name=name) for name in names])
def tearDown(self):
super().tearDown()
UserDomainRole.objects.all().delete()
Domain.objects.all().delete()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@ -2164,16 +2177,140 @@ class TestPortfolioMemberDomainsEditView(TestPortfolioMemberDomainsView):
# Make sure the response is not found
self.assertEqual(response.status_code, 404)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_valid_added_domains(self):
"""Test that domains can be successfully added."""
self.client.force_login(self.user)
data = {
"added_domains": json.dumps([1, 2, 3]), # Mock domain IDs
}
response = self.client.post(self.url, data)
# Check that the UserDomainRole objects were created
self.assertEqual(UserDomainRole.objects.filter(user=self.user, role=UserDomainRole.Roles.MANAGER).count(), 3)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_valid_removed_domains(self):
"""Test that domains can be successfully removed."""
self.client.force_login(self.user)
# Create some UserDomainRole objects
domains = [1, 2, 3]
UserDomainRole.objects.bulk_create([UserDomainRole(domain_id=domain, user=self.user) for domain in domains])
data = {
"removed_domains": json.dumps([1, 2]),
}
response = self.client.post(self.url, data)
# Check that the UserDomainRole objects were deleted
self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 1)
self.assertEqual(UserDomainRole.objects.filter(domain_id=3, user=self.user).count(), 1)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
UserDomainRole.objects.all().delete()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_invalid_added_domains_data(self):
"""Test that an error is returned for invalid added domains data."""
self.client.force_login(self.user)
data = {
"added_domains": "json-statham",
}
response = self.client.post(self.url, data)
# Check that no UserDomainRole objects were created
self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0)
# Check for an error message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
str(messages[0]), "Invalid data for added domains. If the issue persists, please contact help@get.gov."
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_invalid_removed_domains_data(self):
"""Test that an error is returned for invalid removed domains data."""
self.client.force_login(self.user)
data = {
"removed_domains": "not-a-json",
}
response = self.client.post(self.url, data)
# Check that no UserDomainRole objects were deleted
self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0)
# Check for an error message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
str(messages[0]), "Invalid data for removed domains. If the issue persists, please contact help@get.gov."
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_no_changes(self):
"""Test that no changes message is displayed when no changes are made."""
self.client.force_login(self.user)
response = self.client.post(self.url, {})
# Check that no UserDomainRole objects were created or deleted
self.assertEqual(UserDomainRole.objects.filter(user=self.user).count(), 0)
# Check for an info message and a redirect
self.assertRedirects(response, reverse("member-domains", kwargs={"pk": self.portfolio_permission.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "No changes detected.")
class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomainsView):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.url = reverse("invitedmember-domains-edit", kwargs={"pk": cls.invitation.pk})
@classmethod
def tearDownClass(cls):
super().tearDownClass()
def setUp(self):
super().setUp()
names = ["1.gov", "2.gov", "3.gov"]
Domain.objects.bulk_create([Domain(name=name) for name in names])
def tearDown(self):
super().tearDown()
Domain.objects.all().delete()
DomainInvitation.objects.all().delete()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@ -2224,6 +2361,175 @@ class TestPortfolioInvitedMemberEditDomainsView(TestPortfolioInvitedMemberDomain
# Make sure the response is not found
self.assertEqual(response.status_code, 404)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_valid_added_domains(self):
"""Test adding new domains successfully."""
self.client.force_login(self.user)
data = {
"added_domains": json.dumps([1, 2, 3]), # Mock domain IDs
}
response = self.client.post(self.url, data)
# Check that the DomainInvitation objects were created
self.assertEqual(
DomainInvitation.objects.filter(
email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
).count(),
3,
)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "The domain assignment changes have been saved.")
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_existing_and_new_added_domains(self):
"""Test updating existing and adding new invitations."""
self.client.force_login(self.user)
# Create existing invitations
DomainInvitation.objects.bulk_create(
[
DomainInvitation(
domain_id=1, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.CANCELED
),
DomainInvitation(
domain_id=2, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
),
]
)
data = {
"added_domains": json.dumps([1, 2, 3]),
}
response = self.client.post(self.url, data)
# Check that status for domain_id=1 was updated to INVITED
self.assertEqual(
DomainInvitation.objects.get(domain_id=1, email="invited@example.com").status,
DomainInvitation.DomainInvitationStatus.INVITED,
)
# Check that domain_id=3 was created as INVITED
self.assertTrue(
DomainInvitation.objects.filter(
domain_id=3, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
).exists()
)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_valid_removed_domains(self):
"""Test removing domains successfully."""
self.client.force_login(self.user)
# Create existing invitations
DomainInvitation.objects.bulk_create(
[
DomainInvitation(
domain_id=1, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
),
DomainInvitation(
domain_id=2, email="invited@example.com", status=DomainInvitation.DomainInvitationStatus.INVITED
),
]
)
data = {
"removed_domains": json.dumps([1]),
}
response = self.client.post(self.url, data)
# Check that the status for domain_id=1 was updated to CANCELED
self.assertEqual(
DomainInvitation.objects.get(domain_id=1, email="invited@example.com").status,
DomainInvitation.DomainInvitationStatus.CANCELED,
)
# Check that domain_id=2 remains INVITED
self.assertEqual(
DomainInvitation.objects.get(domain_id=2, email="invited@example.com").status,
DomainInvitation.DomainInvitationStatus.INVITED,
)
# Check for a success message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_invalid_added_domains_data(self):
"""Test handling of invalid JSON for added domains."""
self.client.force_login(self.user)
data = {
"added_domains": "not-a-json",
}
response = self.client.post(self.url, data)
# Check that no DomainInvitation objects were created
self.assertEqual(DomainInvitation.objects.count(), 0)
# Check for an error message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
str(messages[0]), "Invalid data for added domains. If the issue persists, please contact help@get.gov."
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_invalid_removed_domains_data(self):
"""Test handling of invalid JSON for removed domains."""
self.client.force_login(self.user)
data = {
"removed_domains": "json-sudeikis",
}
response = self.client.post(self.url, data)
# Check that no DomainInvitation objects were updated
self.assertEqual(DomainInvitation.objects.count(), 0)
# Check for an error message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(
str(messages[0]), "Invalid data for removed domains. If the issue persists, please contact help@get.gov."
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_post_with_no_changes(self):
"""Test the case where no changes are made."""
self.client.force_login(self.user)
response = self.client.post(self.url, {})
# Check that no DomainInvitation objects were created or updated
self.assertEqual(DomainInvitation.objects.count(), 0)
# Check for an info message and a redirect
self.assertRedirects(response, reverse("invitedmember-domains", kwargs={"pk": self.invitation.pk}))
messages = list(response.wsgi_request._messages)
self.assertEqual(len(messages), 1)
self.assertEqual(str(messages[0]), "No changes detected.")
class TestRequestingEntity(WebTest):
"""The requesting entity page is a domain request form that only exists

View file

@ -538,11 +538,23 @@ class DomainExport(BaseExport):
# model objects as we export data, trying to reinstate model objects in order to grab @property
# values negatively impacts performance. Therefore, we will follow best practice and use annotations
return {
"converted_generic_org_type": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__organization_type")),
"converted_org_type": Case(
# When portfolio is present and is_election_board is True
When(
portfolio__isnull=False,
portfolio__organization_type__isnull=False,
is_election_board=True,
then=Concat(F("portfolio__organization_type"), Value("_election")),
),
# When portfolio is present and is_election_board is False or None
When(
Q(is_election_board=False) | Q(is_election_board__isnull=True),
portfolio__isnull=False,
portfolio__organization_type__isnull=False,
then=F("portfolio__organization_type"),
),
# Otherwise, return the natively assigned value
default=F("generic_org_type"),
default=F("organization_type"),
output_field=CharField(),
),
"converted_federal_agency": Case(
@ -573,20 +585,6 @@ class DomainExport(BaseExport):
default=F("organization_name"),
output_field=CharField(),
),
"converted_city": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__city")),
# Otherwise, return the natively assigned value
default=F("city"),
output_field=CharField(),
),
"converted_state_territory": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__state_territory")),
# Otherwise, return the natively assigned value
default=F("state_territory"),
output_field=CharField(),
),
"converted_so_email": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__email")),
@ -727,7 +725,8 @@ class DomainExport(BaseExport):
first_ready_on = "(blank)"
# organization_type has organization_type AND is_election
domain_org_type = model.get("converted_generic_org_type")
# domain_org_type includes "- Election" org_type variants
domain_org_type = model.get("converted_org_type")
human_readable_domain_org_type = DomainRequest.OrgChoicesElectionOffice.get_org_label(domain_org_type)
domain_federal_type = model.get("converted_federal_type")
human_readable_domain_federal_type = BranchChoices.get_branch_label(domain_federal_type)
@ -772,8 +771,8 @@ class DomainExport(BaseExport):
"Domain type": model.get("domain_type"),
"Agency": model.get("converted_federal_agency"),
"Organization name": model.get("converted_organization_name"),
"City": model.get("converted_city"),
"State": model.get("converted_state_territory"),
"City": model.get("city"),
"State": model.get("state_territory"),
"SO": model.get("converted_so_name"),
"SO email": model.get("converted_so_email"),
"Security contact email": model.get("security_contact_email"),
@ -908,7 +907,7 @@ class DomainDataType(DomainExport):
"""
# Coalesce is used to replace federal_type of None with ZZZZZ
return [
"converted_generic_org_type",
"converted_org_type",
Coalesce("converted_federal_type", Value("ZZZZZ")),
"converted_federal_agency",
"domain__name",
@ -987,105 +986,6 @@ class DomainDataTypeUser(DomainDataType):
return Q(domain__id__in=request.user.get_user_domain_ids(request))
class DomainRequestsDataType:
"""
The DomainRequestsDataType report, but filtered based on the current request user
"""
@classmethod
def get_filter_conditions(cls, request=None, **kwargs):
if request is None or not hasattr(request, "user") or not request.user.is_authenticated:
return Q(id__in=[])
request_ids = request.user.get_user_domain_request_ids(request)
return Q(id__in=request_ids)
@classmethod
def get_queryset(cls, request):
return DomainRequest.objects.filter(cls.get_filter_conditions(request))
def safe_get(attribute, default="N/A"):
# Return the attribute value or default if not present
return attribute if attribute is not None else default
@classmethod
def exporting_dr_data_to_csv(cls, response, request=None):
import csv
writer = csv.writer(response)
# CSV headers
writer.writerow(
[
"Domain request",
"Region",
"Status",
"Election office",
"Federal type",
"Domain type",
"Request additional details",
"Creator approved domains count",
"Creator active requests count",
"Alternative domains",
"Other contacts",
"Current websites",
"Federal agency",
"SO first name",
"SO last name",
"SO email",
"SO title/role",
"Creator first name",
"Creator last name",
"Creator email",
"Organization name",
"City",
"State/territory",
"Request purpose",
"CISA regional representative",
"Last submitted date",
"First submitted date",
"Last status update",
]
)
queryset = cls.get_queryset(request)
for request in queryset:
writer.writerow(
[
request.requested_domain,
cls.safe_get(getattr(request, "region_field", None)),
request.status,
cls.safe_get(getattr(request, "election_office", None)),
request.converted_federal_type,
cls.safe_get(getattr(request, "domain_type", None)),
cls.safe_get(getattr(request, "additional_details", None)),
cls.safe_get(getattr(request, "creator_approved_domains_count", None)),
cls.safe_get(getattr(request, "creator_active_requests_count", None)),
cls.safe_get(getattr(request, "all_alternative_domains", None)),
cls.safe_get(getattr(request, "all_other_contacts", None)),
cls.safe_get(getattr(request, "all_current_websites", None)),
cls.safe_get(getattr(request, "converted_federal_agency", None)),
cls.safe_get(getattr(request.converted_senior_official, "first_name", None)),
cls.safe_get(getattr(request.converted_senior_official, "last_name", None)),
cls.safe_get(getattr(request.converted_senior_official, "email", None)),
cls.safe_get(getattr(request.converted_senior_official, "title", None)),
cls.safe_get(getattr(request.creator, "first_name", None)),
cls.safe_get(getattr(request.creator, "last_name", None)),
cls.safe_get(getattr(request.creator, "email", None)),
cls.safe_get(getattr(request, "converted_organization_name", None)),
cls.safe_get(getattr(request, "converted_city", None)),
cls.safe_get(getattr(request, "converted_state_territory", None)),
cls.safe_get(getattr(request, "purpose", None)),
cls.safe_get(getattr(request, "cisa_representative_email", None)),
cls.safe_get(getattr(request, "last_submitted_date", None)),
cls.safe_get(getattr(request, "first_submitted_date", None)),
cls.safe_get(getattr(request, "last_status_update", None)),
]
)
return response
class DomainDataFull(DomainExport):
"""
Shows security contacts, filtered by state
@ -1760,20 +1660,6 @@ class DomainRequestExport(BaseExport):
default=F("organization_name"),
output_field=CharField(),
),
"converted_city": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__city")),
# Otherwise, return the natively assigned value
default=F("city"),
output_field=CharField(),
),
"converted_state_territory": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__state_territory")),
# Otherwise, return the natively assigned value
default=F("state_territory"),
output_field=CharField(),
),
"converted_so_email": Case(
# When portfolio is present, use its value instead
When(portfolio__isnull=False, then=F("portfolio__senior_official__email")),
@ -1952,8 +1838,8 @@ class DomainRequestExport(BaseExport):
"Investigator": model.get("investigator__email"),
# Untouched fields
"Organization name": model.get("converted_organization_name"),
"City": model.get("converted_city"),
"State/territory": model.get("converted_state_territory"),
"City": model.get("city"),
"State/territory": model.get("state_territory"),
"Request purpose": model.get("purpose"),
"CISA regional representative": model.get("cisa_representative_email"),
"Last submitted date": model.get("last_submitted_date"),
@ -1965,6 +1851,92 @@ class DomainRequestExport(BaseExport):
return row
class DomainRequestDataType(DomainRequestExport):
"""
The DomainRequestDataType report, but filtered based on the current request user
"""
@classmethod
def get_columns(cls):
"""
Overrides the columns for CSV export specific to DomainRequestDataType.
"""
return [
"Domain request",
"Region",
"Status",
"Election office",
"Federal type",
"Domain type",
"Request additional details",
"Creator approved domains count",
"Creator active requests count",
"Alternative domains",
"Other contacts",
"Current websites",
"Federal agency",
"SO first name",
"SO last name",
"SO email",
"SO title/role",
"Creator first name",
"Creator last name",
"Creator email",
"Organization name",
"City",
"State/territory",
"Request purpose",
"CISA regional representative",
"Last submitted date",
"First submitted date",
"Last status update",
]
@classmethod
def get_filter_conditions(cls, request=None, **kwargs):
"""
Get a Q object of filter conditions to filter when building queryset.
"""
if request is None or not hasattr(request, "user") or not request.user:
# Return nothing
return Q(id__in=[])
else:
# Get all domain requests the user is associated with
return Q(id__in=request.user.get_user_domain_request_ids(request))
@classmethod
def get_select_related(cls):
"""
Get a list of tables to pass to select_related when building queryset.
"""
return ["creator", "senior_official", "federal_agency", "investigator", "requested_domain"]
@classmethod
def get_prefetch_related(cls):
"""
Get a list of tables to pass to prefetch_related when building queryset.
"""
return ["current_websites", "other_contacts", "alternative_domains"]
@classmethod
def get_related_table_fields(cls):
"""
Get a list of fields from related tables.
"""
return [
"requested_domain__name",
"federal_agency__agency",
"senior_official__first_name",
"senior_official__last_name",
"senior_official__email",
"senior_official__title",
"creator__first_name",
"creator__last_name",
"creator__email",
"investigator__email",
]
class DomainRequestGrowth(DomainRequestExport):
"""
Shows submitted requests within a date range, sorted

View file

@ -809,15 +809,6 @@ class DomainDNSSECView(DomainFormBaseView):
context = super().get_context_data(**kwargs)
has_dnssec_records = self.object.dnssecdata is not None
# Create HTML for the modal button
modal_button = (
'<button type="submit" '
'class="usa-button usa-button--secondary" '
'name="disable_dnssec">Confirm</button>'
)
context["modal_button"] = modal_button
context["has_dnssec_records"] = has_dnssec_records
context["dnssec_enabled"] = self.request.session.pop("dnssec_enabled", False)
@ -910,15 +901,6 @@ class DomainDsDataView(DomainFormBaseView):
# to preserve the context["form"]
context = super().get_context_data(form=formset)
context["trigger_modal"] = True
# Create HTML for the modal button
modal_button = (
'<button type="submit" '
'class="usa-button usa-button--secondary" '
'name="disable-override-click">Remove all DS data</button>'
)
# context to back out of a broken form on all fields delete
context["modal_button"] = modal_button
return self.render_to_response(context)
if formset.is_valid() or override:
@ -1054,9 +1036,6 @@ class DomainUsersView(DomainBaseView):
# Add conditionals to the context (such as "can_delete_users")
context = self._add_booleans_to_context(context)
# Add modal buttons to the context (such as for delete)
context = self._add_modal_buttons_to_context(context)
# Get portfolio from session (if set)
portfolio = self.request.session.get("portfolio")
@ -1159,26 +1138,6 @@ class DomainUsersView(DomainBaseView):
context["can_delete_users"] = can_delete_users
return context
def _add_modal_buttons_to_context(self, context):
"""Adds modal buttons (and their HTML) to the context"""
# Create HTML for the modal button
modal_button = (
'<button type="submit" '
'class="usa-button usa-button--secondary" '
'name="delete_domain_manager">Yes, remove domain manager</button>'
)
context["modal_button"] = modal_button
# Create HTML for the modal button when deleting yourself
modal_button_self = (
'<button type="submit" '
'class="usa-button usa-button--secondary" '
'name="delete_domain_manager_self">Yes, remove myself</button>'
)
context["modal_button_self"] = modal_button_self
return context
class DomainAddUserView(DomainFormBaseView):
"""Inside of a domain's user management, a form for adding users.

View file

@ -448,34 +448,21 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
non_org_steps_complete = DomainRequest._form_complete(self.domain_request, self.request)
org_steps_complete = len(self.db_check_for_unlocking_steps()) == len(self.steps)
if (not self.is_portfolio and non_org_steps_complete) or (self.is_portfolio and org_steps_complete):
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
context = {
"not_form": False,
"form_titles": self.titles,
"steps": self.steps,
"visited": self.storage.get("step_history", []),
"is_federal": self.domain_request.is_federal(),
"modal_button": modal_button,
"modal_heading": "You are about to submit a domain request for ",
"domain_name_modal": str(self.domain_request.requested_domain),
"modal_description": "Once you submit this request, you wont be able to edit it until we review it.\
Youll only be able to withdraw your request.",
"review_form_is_complete": True,
"user": self.request.user,
"requested_domain__name": requested_domain_name,
}
else: # form is not complete
modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>'
context = {
"not_form": True,
"form_titles": self.titles,
"steps": self.steps,
"visited": self.storage.get("step_history", []),
"is_federal": self.domain_request.is_federal(),
"modal_button": modal_button,
"modal_heading": "Your request form is incomplete",
"modal_description": 'This request cannot be submitted yet.\
Return to the request and visit the steps that are marked as "incomplete."',
"review_form_is_complete": False,
"user": self.request.user,
"requested_domain__name": requested_domain_name,

View file

@ -109,6 +109,10 @@ def apply_sorting(queryset, request):
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc'
# Handle special case for 'creator'
if sort_by == "creator":
sort_by = "creator__email"
if order == "desc":
sort_by = f"-{sort_by}"
return queryset.order_by(sort_by)

View file

@ -90,7 +90,9 @@ class PortfolioMemberDomainsJson(PortfolioMemberDomainsPermission, View):
domain_info_ids = DomainInformation.objects.filter(portfolio=portfolio).values_list(
"domain_id", flat=True
)
domain_invitations = DomainInvitation.objects.filter(email=email).values_list("domain_id", flat=True)
domain_invitations = DomainInvitation.objects.filter(
email=email, status=DomainInvitation.DomainInvitationStatus.INVITED
).values_list("domain_id", flat=True)
return domain_info_ids.intersection(domain_invitations)
else:
domain_infos = DomainInformation.objects.filter(portfolio=portfolio)

View file

@ -12,6 +12,7 @@ from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.views.utility.mixins import PortfolioMembersPermission
from registrar.models.utility.orm_helper import ArrayRemoveNull
from django.contrib.postgres.aggregates import StringAgg
class PortfolioMembersJson(PortfolioMembersPermission, View):
@ -119,11 +120,22 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
def initial_invitations_search(self, portfolio):
"""Perform initial invitations search and get related DomainInvitation data based on the email."""
# Get DomainInvitation query for matching email and for the portfolio
domain_invitations = DomainInvitation.objects.filter(
email=OuterRef("email"), # Check if email matches the OuterRef("email")
domain__domain_info__portfolio=portfolio, # Check if the domain's portfolio matches the given portfolio
).annotate(domain_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField()))
# Subquery to get concatenated domain information for each email
domain_invitations = (
DomainInvitation.objects.filter(email=OuterRef("email"), domain__domain_info__portfolio=portfolio)
.annotate(
concatenated_info=Concat(F("domain__id"), Value(":"), F("domain__name"), output_field=CharField())
)
.values("concatenated_info")
)
concatenated_domain_info = (
domain_invitations.values("email")
.annotate(domain_info=StringAgg("concatenated_info", delimiter=", "))
.values("domain_info")
)
# PortfolioInvitation query
invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
invitations = invitations.annotate(
@ -136,7 +148,12 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
# Use ArrayRemove to return an empty list when no domain invitations are found
domain_info=ArrayRemoveNull(
ArrayAgg(
Subquery(domain_invitations.values("domain_info")),
# We've pre-concatenated the domain infos to limit the subquery to return a single virtual 'row',
# otherwise we'll trigger a "more than one row returned by a subquery used as an expression"
# when an email matches multiple domain invitations.
# We'll take care when processing the list of one single concatenated items item
# in serialize_members.
Subquery(concatenated_domain_info),
distinct=True,
)
),
@ -153,6 +170,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
"domain_info",
"type",
)
return invitations
def apply_search_term(self, queryset, request):
@ -190,10 +208,19 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
action_url = reverse(item["type"], kwargs={"pk": item["id"]})
item_type = item.get("type", "")
# Ensure domain_info is properly processed for invites -
# we need to un-concatenate the subquery
domain_info_list = item.get("domain_info", [])
if item_type == "invitedmember" and isinstance(domain_info_list, list) and domain_info_list:
# Split the first item in the list if it exists
domain_info_list = domain_info_list[0].split(", ")
# Serialize member data
member_json = {
"id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation
"type": item.get("type", ""), # source is member or invitedmember
"type": item_type, # source is member or invitedmember
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
"email": item.get("email_display", ""),
"member_display": item.get("member_display", ""),
@ -203,9 +230,9 @@ class PortfolioMembersJson(PortfolioMembersPermission, View):
),
# split domain_info array values into ids to form urls, and names
"domain_urls": [
reverse("domain", kwargs={"pk": domain_info.split(":")[0]}) for domain_info in item.get("domain_info")
reverse("domain", kwargs={"pk": domain_info.split(":")[0]}) for domain_info in domain_info_list
],
"domain_names": [domain_info.split(":")[1] for domain_info in item.get("domain_info")],
"domain_names": [domain_info.split(":")[1] for domain_info in domain_info_list],
"is_admin": is_admin,
"last_active": item.get("last_active"),
"action_url": action_url,

View file

@ -1,3 +1,4 @@
import json
import logging
from django.http import Http404, JsonResponse
@ -7,12 +8,15 @@ from django.utils.safestring import mark_safe
from django.contrib import messages
from registrar.forms import portfolio as portfolioForms
from registrar.models import Portfolio, User
from registrar.models.domain_invitation import DomainInvitation
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.utility.email import EmailSendingError
from registrar.utility.email_invitations import send_portfolio_invitation_email
from registrar.utility.errors import MissingEmailError
from registrar.utility.enums import DefaultUserValues
from registrar.views.utility.mixins import PortfolioMemberPermission
from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView,
@ -27,6 +31,7 @@ from registrar.views.utility.permission_views import (
)
from django.views.generic import View
from django.views.generic.edit import FormMixin
from django.db import IntegrityError
logger = logging.getLogger(__name__)
@ -224,6 +229,86 @@ class PortfolioMemberDomainsEditView(PortfolioMemberDomainsEditPermissionView, V
},
)
def post(self, request, pk):
"""
Handles adding and removing domains for a portfolio member.
"""
added_domains = request.POST.get("added_domains")
removed_domains = request.POST.get("removed_domains")
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
member = portfolio_permission.user
added_domain_ids = self._parse_domain_ids(added_domains, "added domains")
if added_domain_ids is None:
return redirect(reverse("member-domains", kwargs={"pk": pk}))
removed_domain_ids = self._parse_domain_ids(removed_domains, "removed domains")
if removed_domain_ids is None:
return redirect(reverse("member-domains", kwargs={"pk": pk}))
if added_domain_ids or removed_domain_ids:
try:
self._process_added_domains(added_domain_ids, member)
self._process_removed_domains(removed_domain_ids, member)
messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("member-domains", kwargs={"pk": pk}))
except IntegrityError:
messages.error(
request,
"A database error occurred while saving changes. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error("A database error occurred while saving changes.")
return redirect(reverse("member-domains-edit", kwargs={"pk": pk}))
except Exception as e:
messages.error(
request,
"An unexpected error occurred: {str(e)}. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error(f"An unexpected error occurred: {str(e)}")
return redirect(reverse("member-domains-edit", kwargs={"pk": pk}))
else:
messages.info(request, "No changes detected.")
return redirect(reverse("member-domains", kwargs={"pk": pk}))
def _parse_domain_ids(self, domain_data, domain_type):
"""
Parses the domain IDs from the request and handles JSON errors.
"""
try:
return json.loads(domain_data) if domain_data else []
except json.JSONDecodeError:
messages.error(
self.request,
f"Invalid data for {domain_type}. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error(f"Invalid data for {domain_type}")
return None
def _process_added_domains(self, added_domain_ids, member):
"""
Processes added domains by bulk creating UserDomainRole instances.
"""
if added_domain_ids:
# Bulk create UserDomainRole instances for added domains
UserDomainRole.objects.bulk_create(
[
UserDomainRole(domain_id=domain_id, user=member, role=UserDomainRole.Roles.MANAGER)
for domain_id in added_domain_ids
],
ignore_conflicts=True, # Avoid duplicate entries
)
def _process_removed_domains(self, removed_domain_ids, member):
"""
Processes removed domains by deleting corresponding UserDomainRole instances.
"""
if removed_domain_ids:
# Delete UserDomainRole instances for removed domains
UserDomainRole.objects.filter(domain_id__in=removed_domain_ids, user=member).delete()
class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View):
@ -351,6 +436,106 @@ class PortfolioInvitedMemberDomainsEditView(PortfolioMemberDomainsEditPermission
},
)
def post(self, request, pk):
"""
Handles adding and removing domains for a portfolio invitee.
"""
added_domains = request.POST.get("added_domains")
removed_domains = request.POST.get("removed_domains")
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
email = portfolio_invitation.email
added_domain_ids = self._parse_domain_ids(added_domains, "added domains")
if added_domain_ids is None:
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
removed_domain_ids = self._parse_domain_ids(removed_domains, "removed domains")
if removed_domain_ids is None:
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
if added_domain_ids or removed_domain_ids:
try:
self._process_added_domains(added_domain_ids, email)
self._process_removed_domains(removed_domain_ids, email)
messages.success(request, "The domain assignment changes have been saved.")
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
except IntegrityError:
messages.error(
request,
"A database error occurred while saving changes. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error("A database error occurred while saving changes.")
return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk}))
except Exception as e:
messages.error(
request,
"An unexpected error occurred: {str(e)}. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error(f"An unexpected error occurred: {str(e)}.")
return redirect(reverse("invitedmember-domains-edit", kwargs={"pk": pk}))
else:
messages.info(request, "No changes detected.")
return redirect(reverse("invitedmember-domains", kwargs={"pk": pk}))
def _parse_domain_ids(self, domain_data, domain_type):
"""
Parses the domain IDs from the request and handles JSON errors.
"""
try:
return json.loads(domain_data) if domain_data else []
except json.JSONDecodeError:
messages.error(
self.request,
f"Invalid data for {domain_type}. If the issue persists, "
f"please contact {DefaultUserValues.HELP_EMAIL}.",
)
logger.error(f"Invalid data for {domain_type}.")
return None
def _process_added_domains(self, added_domain_ids, email):
"""
Processes added domain invitations by updating existing invitations
or creating new ones.
"""
if not added_domain_ids:
return
# Update existing invitations from CANCELED to INVITED
existing_invitations = DomainInvitation.objects.filter(domain_id__in=added_domain_ids, email=email)
existing_invitations.update(status=DomainInvitation.DomainInvitationStatus.INVITED)
# Determine which domains need new invitations
existing_domain_ids = existing_invitations.values_list("domain_id", flat=True)
new_domain_ids = set(added_domain_ids) - set(existing_domain_ids)
# Bulk create new invitations
DomainInvitation.objects.bulk_create(
[
DomainInvitation(
domain_id=domain_id,
email=email,
status=DomainInvitation.DomainInvitationStatus.INVITED,
)
for domain_id in new_domain_ids
]
)
def _process_removed_domains(self, removed_domain_ids, email):
"""
Processes removed domain invitations by updating their status to CANCELED.
"""
if not removed_domain_ids:
return
# Update invitations from INVITED to CANCELED
DomainInvitation.objects.filter(
domain_id__in=removed_domain_ids,
email=email,
status=DomainInvitation.DomainInvitationStatus.INVITED,
).update(status=DomainInvitation.DomainInvitationStatus.CANCELED)
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains.

View file

@ -203,7 +203,7 @@ class ExportDataTypeRequests(View):
def get(self, request, *args, **kwargs):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="domain-requests.csv"'
csv_export.DomainRequestsDataType.exporting_dr_data_to_csv(response, request=request)
csv_export.DomainRequestDataType.export_data_to_csv(response, request=request)
return response