Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/3129-domain-renewal-form

This commit is contained in:
asaki222 2025-01-07 14:47:44 -05:00
commit f14122da6a
No known key found for this signature in database
GPG key ID: C51913A3A09FDC03
108 changed files with 4487 additions and 2448 deletions

View file

@ -4,7 +4,6 @@ Pull requests should be titled in the format of `#issue_number: Descriptive name
Pull requests including a migration should be suffixed with ` - MIGRATION`
After creating a pull request, pull request submitters should:
- Add at least 2 developers as PR reviewers (only 1 will need to approve).
- Message on Slack or in standup to notify the team that a PR is ready for review.
- If any model was updated to modify/add/delete columns, run makemigrations and commit the associated migrations file.

1317
src/Pipfile.lock generated

File diff suppressed because it is too large Load diff

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

@ -21,10 +21,14 @@ from registrar.utility.admin_helpers import (
get_field_links_as_list,
)
from django.conf import settings
from django.contrib.messages import get_messages
from django.contrib.admin.helpers import AdminForm
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
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 waffle.decorators import flag_is_active
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@ -37,7 +41,7 @@ from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes, MissingEmailError
from registrar.utility.waffle import flag_is_active_for_user
from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR
@ -1312,6 +1316,8 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
search_fields = ["user__first_name", "user__last_name", "user__email", "portfolio__organization_name"]
search_help_text = "Search by first name, last name, email, or portfolio."
change_form_template = "django/admin/user_portfolio_permission_change_form.html"
def get_roles(self, obj):
readable_roles = obj.get_readable_roles()
return ", ".join(readable_roles)
@ -1468,7 +1474,7 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
autocomplete_fields = ["portfolio"]
change_form_template = "django/admin/email_clipboard_change_form.html"
change_form_template = "django/admin/portfolio_invitation_change_form.html"
# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
@ -1478,6 +1484,118 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def save_model(self, request, obj, form, change):
"""
Override the save_model method.
Only send email on creation of the PortfolioInvitation object. Not on updates.
Emails sent to requested user / email.
When exceptions are raised, return without saving model.
"""
if not change: # Only send email if this is a new PortfolioInvitation (creation)
portfolio = obj.portfolio
requested_email = obj.email
requestor = request.user
permission_exists = UserPortfolioPermission.objects.filter(
user__email=requested_email, portfolio=portfolio, user__email__isnull=False
).exists()
try:
if not permission_exists:
# if permission does not exist for a user with requested_email, send email
send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
messages.success(request, f"{requested_email} has been invited.")
else:
messages.warning(request, "User is already a member of this portfolio.")
except Exception as e:
# when exception is raised, handle and do not save the model
self._handle_exceptions(e, request, obj)
return
# Call the parent save method to save the object
super().save_model(request, obj, form, change)
def _handle_exceptions(self, exception, request, obj):
"""Handle exceptions raised during the process.
Log warnings / errors, and message errors to the user.
"""
if isinstance(exception, EmailSendingError):
logger.warning(
"Could not sent email invitation to %s for portfolio %s (EmailSendingError)",
obj.email,
obj.portfolio,
exc_info=True,
)
messages.error(request, "Could not send email invitation. Portfolio invitation not saved.")
elif isinstance(exception, MissingEmailError):
messages.error(request, str(exception))
logger.error(
f"Can't send email to '{obj.email}' for portfolio '{obj.portfolio}'. "
f"No email exists for the requestor.",
exc_info=True,
)
else:
logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
messages.error(request, "Could not send email invitation. Portfolio invitation not saved.")
def response_add(self, request, obj, post_url_continue=None):
"""
Override response_add to handle rendering when exceptions are raised during add model.
Normal flow on successful save_model on add is to redirect to changelist_view.
If there are errors, flow is modified to instead render change form.
"""
# Check if there are any error or warning messages in the `messages` framework
storage = get_messages(request)
has_errors = any(message.level_tag in ["error", "warning"] for message in storage)
if has_errors:
# Re-render the change form if there are errors or warnings
# Prepare context for rendering the change form
# Get the model form
ModelForm = self.get_form(request, obj=obj)
form = ModelForm(instance=obj)
# Create an AdminForm instance
admin_form = AdminForm(
form,
list(self.get_fieldsets(request, obj)),
self.get_prepopulated_fields(request, obj),
self.get_readonly_fields(request, obj),
model_admin=self,
)
media = self.media + form.media
opts = obj._meta
change_form_context = {
**self.admin_site.each_context(request), # Add admin context
"title": f"Add {opts.verbose_name}",
"opts": opts,
"original": obj,
"save_as": self.save_as,
"has_change_permission": self.has_change_permission(request, obj),
"add": True, # Indicate this is an "Add" form
"change": False, # Indicate this is not a "Change" form
"is_popup": False,
"inline_admin_formsets": [],
"save_on_top": self.save_on_top,
"show_delete": self.has_delete_permission(request, obj),
"obj": obj,
"adminform": admin_form, # Pass the AdminForm instance
"media": media,
"errors": None,
}
return self.render_change_form(
request,
context=change_form_context,
add=True,
change=False,
obj=obj,
)
return super().response_add(request, obj, post_url_continue)
class DomainInformationResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -2618,8 +2736,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return response
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Display restricted warning,
Setup the auditlog trail and pass it in extra context."""
"""Display restricted warning, setup the auditlog trail and pass it in extra context,
display warning that status cannot be changed from 'Approved' if domain is in Ready state"""
# Fetch the domain request instance
domain_request: models.DomainRequest = models.DomainRequest.objects.get(pk=object_id)
if domain_request.approved_domain and domain_request.approved_domain.state == models.Domain.State.READY:
domain = domain_request.approved_domain
# get change url for domain
app_label = domain_request.approved_domain._meta.app_label
model_name = domain._meta.model_name
obj_id = domain.id
change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id])
message = format_html(
"The status of this domain request cannot be changed because it has been joined to a domain in Ready status: " # noqa: E501
"<a href='{}'>{}</a>",
mark_safe(change_url), # nosec
escape(str(domain)),
)
messages.warning(
request,
message,
)
obj = self.get_object(request, object_id)
self.display_restricted_warning(request, obj)

View file

@ -629,6 +629,51 @@ export function initRejectedEmail() {
});
}
/**
* A function that handles the suborganzation and requested suborganization fields and buttons.
* - Fieldwise: Hooks to the sub_organization, suborganization_city, and suborganization_state_territory fields.
* On change, this function checks if any of these fields are not empty:
* sub_organization, suborganization_city, and suborganization_state_territory.
* If they aren't, then we show the "clear" button. If they are, then we hide it because we don't need it.
*
* - Buttonwise: Hooks to the #clear-requested-suborganization button.
* On click, this will clear the input value of sub_organization, suborganization_city, and suborganization_state_territory.
*/
function handleSuborgFieldsAndButtons() {
const requestedSuborganizationField = document.getElementById("id_requested_suborganization");
const suborganizationCity = document.getElementById("id_suborganization_city");
const suborganizationStateTerritory = document.getElementById("id_suborganization_state_territory");
const rejectButton = document.querySelector("#clear-requested-suborganization");
// Ensure that every variable is present before proceeding
if (!requestedSuborganizationField || !suborganizationCity || !suborganizationStateTerritory || !rejectButton) {
console.warn("handleSuborganizationSelection() => Could not find required fields.")
return;
}
function handleRejectButtonVisibility() {
if (requestedSuborganizationField.value || suborganizationCity.value || suborganizationStateTerritory.value) {
showElement(rejectButton);
}else {
hideElement(rejectButton)
}
}
function handleRejectButton() {
// Clear the text fields
requestedSuborganizationField.value = "";
suborganizationCity.value = "";
suborganizationStateTerritory.value = "";
// Update button visibility after clearing
handleRejectButtonVisibility();
}
rejectButton.addEventListener("click", handleRejectButton)
requestedSuborganizationField.addEventListener("blur", handleRejectButtonVisibility);
suborganizationCity.addEventListener("blur", handleRejectButtonVisibility);
suborganizationStateTerritory.addEventListener("change", handleRejectButtonVisibility);
}
/**
* A function for dynamic DomainRequest fields
*/
@ -636,5 +681,6 @@ export function initDynamicDomainRequestFields(){
const domainRequestPage = document.getElementById("domainrequest_form");
if (domainRequestPage) {
handlePortfolioSelection();
handleSuborgFieldsAndButtons();
}
}

View file

@ -49,6 +49,13 @@ export function handlePortfolioSelection(
const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization");
const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly");
const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null;
// These requested suborganization fields only exist on the domain request page
const rejectSuborganizationButton = document.querySelector("#clear-requested-suborganization");
const requestedSuborganizationFieldInput = document.getElementById("id_requested_suborganization");
const suborganizationCityInput = document.getElementById("id_suborganization_city");
const suborganizationStateTerritoryInput = document.getElementById("id_suborganization_state_territory");
// Global var to track page load
let isPageLoading = true;
/**
@ -469,11 +476,28 @@ export function handlePortfolioSelection(
if (requestedSuborganizationField) showElement(requestedSuborganizationField);
if (suborganizationCity) showElement(suborganizationCity);
if (suborganizationStateTerritory) showElement(suborganizationStateTerritory);
// == LOGIC FOR THE DOMAIN REQUEST PAGE == //
// Handle rejectSuborganizationButton (display of the clear requested suborg button).
// Basically, this button should only be visible when we have data for suborg, city, and state_territory.
// The function handleSuborgFieldsAndButtons() in domain-request-form.js handles doing this same logic
// but on field input for city, state_territory, and the suborg field.
// If it doesn't exist, don't do anything.
if (rejectSuborganizationButton){
if (requestedSuborganizationFieldInput?.value || suborganizationCityInput?.value || suborganizationStateTerritoryInput?.value) {
showElement(rejectSuborganizationButton);
}else {
hideElement(rejectSuborganizationButton);
}
}
} else {
// Hide suborganization request fields if suborganization is selected
if (requestedSuborganizationField) hideElement(requestedSuborganizationField);
if (suborganizationCity) hideElement(suborganizationCity);
if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory);
// == LOGIC FOR THE DOMAIN REQUEST PAGE == //
if (rejectSuborganizationButton) hideElement(rejectSuborganizationButton);
}
}

View file

@ -1,9 +1,17 @@
export function hideElement(element) {
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) {
if (element) {
element.classList.remove('display-none');
} else {
throw new Error('showElement expected a passed DOM element as an argument, but none was provided.');
}
};
/**

View file

@ -150,14 +150,14 @@ export function initAddNewMemberPageListeners() {
document.getElementById('modalEmail').textContent = emailValue;
// Get selected radio button for access level
let selectedAccess = document.querySelector('input[name="member_access_level"]:checked');
let selectedAccess = document.querySelector('input[name="role"]:checked');
// Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button)
// This value does not have the first letter capitalized so let's capitalize it
let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected";
document.getElementById('modalAccessLevel').textContent = accessText;
// Populate permission details based on access level
if (selectedAccess && selectedAccess.value === 'admin') {
if (selectedAccess && selectedAccess.value === 'organization_admin') {
populatePermissionDetails('new-member-admin-permissions');
} else {
populatePermissionDetails('new-member-basic-permissions');
@ -187,10 +187,10 @@ export function initPortfolioMemberPageRadio() {
);
}else if (newMemberForm){
hookupRadioTogglerListener(
'member_access_level',
'role',
{
'admin': 'new-member-admin-permissions',
'basic': 'new-member-basic-permissions'
'organization_admin': 'new-member-admin-permissions',
'organization_member': 'new-member-basic-permissions'
}
);
}

View file

@ -13,6 +13,7 @@ export function handleRequestingEntityFieldset() {
const selectParent = select?.parentElement;
const suborgContainer = document.getElementById("suborganization-container");
const suborgDetailsContainer = document.getElementById("suborganization-container__details");
const suborgAddtlInstruction = document.getElementById("suborganization-addtl-instruction");
const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value;
// Make sure all crucial page elements exist before proceeding.
// This more or less ensures that we are on the Requesting Entity page, and not elsewhere.
@ -26,7 +27,13 @@ export function handleRequestingEntityFieldset() {
function toggleSuborganization(radio=null) {
if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True";
requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer);
if (select.options.length == 2) { // --Select-- and other are the only options
hideElement(selectParent); // Hide the select drop down and indicate requesting new suborg
hideElement(suborgAddtlInstruction); // Hide additional instruction related to the list
requestingNewSuborganization.value = "True";
} else {
requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False";
}
requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer);
}

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 = '';

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;
@ -218,6 +229,122 @@ 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

@ -176,7 +176,16 @@ html[data-theme="dark"] {
color: var(--primary-fg);
}
// Reset the USWDS styles for alerts
@include at-media(desktop) {
.dashboard .usa-alert__body--widescreen {
padding-left: 4rem !important;
}
.dashboard .usa-alert__body--widescreen::before {
left: 1.5rem !important;
}
}
#branding h1,
h1, h2, h3,

View file

@ -1,21 +1,18 @@
@use "uswds-core" as *;
@use "base" as *;
// Fixes some font size disparities with the Figma
// for usa-alert alert elements
.usa-alert {
.usa-alert__heading.larger-font-sizing {
font-size: units(3);
}
}
.usa-alert__text.measure-none {
max-width: measure(none);
}
/*----------------
Alert Layout
-----------------*/
// The icon was off center for some reason
// Fixes that issue
@media (min-width: 64em){
@include at-media(desktop) {
// NOTE: !important is used because _font.scss overrides this
.usa-alert__body {
max-width: $widescreen-max-width !important;
}
.usa-alert--warning{
.usa-alert__body::before {
left: 1rem !important;
@ -24,13 +21,29 @@
.usa-alert__body.margin-left-1 {
margin-left: 0.5rem!important;
}
.usa-alert__body--widescreen::before {
left: 4rem !important;
}
.usa-alert__body--widescreen {
padding-left: 7rem!important;
}
}
// NOTE: !important is used because _font.scss overrides this
.usa-alert__body--widescreen {
max-width: $widescreen-max-width !important;
/*----------------
Alert Fonts
-----------------*/
// Fixes some font size disparities with the Figma
// for usa-alert alert elements
.usa-alert {
.usa-alert__heading.larger-font-sizing {
font-size: 1.5rem;
}
}
/*----------------
Alert Coloring
-----------------*/
.usa-site-alert--hot-pink {
.usa-alert {
background-color: $hot-pink;
@ -47,3 +60,8 @@
background-color: color('base-darkest');
}
}
// Override the specificity of USWDS css to enable no max width on admin alerts
.usa-alert__body.maxw-none {
max-width: none;
}

View file

@ -2,6 +2,8 @@
@use "cisa_colors" as *;
$widescreen-max-width: 1920px;
$widescreen-x-padding: 4.5rem;
$hot-pink: #FFC3F9;
/* Styles for making visible to screen reader / AT users only. */
@ -39,7 +41,8 @@ body {
padding-top: units(5)!important;
}
#wrapper.dashboard--grey-1 {
#wrapper.dashboard--grey-1,
.bg-gray-1 {
background-color: color('gray-1');
}
@ -252,6 +255,15 @@ abbr[title] {
max-width: $widescreen-max-width;
}
// This is used in cases where we want to align content to widescreen margins
// but we don't want the content itself to have widescreen widths
@include at-media(desktop) {
.padding-x--widescreen {
padding-left: $widescreen-x-padding !important;
padding-right: $widescreen-x-padding !important;
}
}
.margin-right-neg-4px {
margin-right: -4px;
}
@ -266,3 +278,7 @@ abbr[title] {
height: 1.5em;
width: 1.5em;
}
.maxw-fit-content {
max-width: fit-content;
}

View file

@ -6,3 +6,21 @@
.usa-identifier__container--widescreen {
max-width: $widescreen-max-width !important;
}
// NOTE: !important is used because we are overriding default
// USWDS paddings in a few locations
@include at-media(desktop) {
.grid-container--widescreen {
padding-left: $widescreen-x-padding !important;
padding-right: $widescreen-x-padding !important;
}
}
// matches max-width to equal the max-width of .grid-container
// used to trick the eye into thinking we have left-aligned a
// regular grid-container within a widescreen (see instances
// where is_widescreen_centered is used in the html).
.max-width--grid-container {
max-width: 960px;
}

View file

@ -78,3 +78,7 @@ legend.float-left-tablet + button.float-right-tablet {
.read-only-value {
margin-top: units(0);
}
.bg-gray-1 .usa-radio {
background: color('gray-1');
}

View file

@ -110,8 +110,8 @@
}
}
.usa-nav__secondary {
// I don't know why USWDS has this at 2 rem, which puts it out of alignment
right: 3rem;
right: 1rem;
padding-right: $widescreen-x-padding;
color: color('white');
bottom: 4.3rem;
.usa-nav-link,

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

@ -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

@ -251,7 +251,7 @@ TEMPLATES = [
"registrar.context_processors.org_user_status",
"registrar.context_processors.add_path_to_context",
"registrar.context_processors.portfolio_permissions",
"registrar.context_processors.is_widescreen_mode",
"registrar.context_processors.is_widescreen_centered",
],
},
},

View file

@ -146,7 +146,7 @@ urlpatterns = [
# ),
path(
"members/new-member/",
views.NewMemberView.as_view(),
views.PortfolioAddMemberView.as_view(),
name="new-member",
),
path(

View file

@ -109,31 +109,21 @@ def portfolio_permissions(request):
return portfolio_context
def is_widescreen_mode(request):
widescreen_paths = [] # If this list is meant to include specific paths, populate it.
portfolio_widescreen_paths = [
def is_widescreen_centered(request):
include_paths = [
"/domains/",
"/requests/",
"/request/",
"/no-organization-requests/",
"/no-organization-domains/",
"/domain-request/",
"/members/",
]
# widescreen_paths can be a bear as it trickles down sub-urls. exclude_paths gives us a way out.
exclude_paths = [
"/domains/edit",
"members/new-member/",
]
# Check if the current path matches a widescreen path or the root path.
is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/"
is_excluded = any(exclude_path in request.path for exclude_path in exclude_paths)
# Check if the user is an organization user and the path matches portfolio paths.
is_portfolio_widescreen = (
hasattr(request.user, "is_org_user")
and request.user.is_org_user(request)
and any(path in request.path for path in portfolio_widescreen_paths)
and not any(exclude_path in request.path for exclude_path in exclude_paths)
)
# Check if the current path matches a path in included_paths or the root path.
is_widescreen_centered = any(path in request.path for path in include_paths) or request.path == "/"
# Return a dictionary with the widescreen mode status.
return {"is_widescreen_mode": is_widescreen or is_portfolio_widescreen}
return {"is_widescreen_centered": is_widescreen_centered and not is_excluded}

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

@ -17,6 +17,7 @@ from registrar.models import Contact, DomainRequest, DraftDomain, Domain, Federa
from registrar.templatetags.url_helpers import public_site_url
from registrar.utility.enums import ValidationReturnType
from registrar.utility.constants import BranchChoices
from django.core.exceptions import ValidationError
logger = logging.getLogger(__name__)
@ -78,6 +79,20 @@ class RequestingEntityForm(RegistrarForm):
# Otherwise just return the suborg as normal
return self.cleaned_data.get("sub_organization")
def clean_requested_suborganization(self):
name = self.cleaned_data.get("requested_suborganization")
if (
name
and Suborganization.objects.filter(
name__iexact=name, portfolio=self.domain_request.portfolio, name__isnull=False, portfolio__isnull=False
).exists()
):
raise ValidationError(
"This suborganization already exists. "
"Choose a new name, or select it directly if you would like to use it."
)
return name
def full_clean(self):
"""Validation logic to remove the custom suborganization value before clean is triggered.
Without this override, the form will throw an 'invalid option' error."""
@ -114,7 +129,7 @@ class RequestingEntityForm(RegistrarForm):
if requesting_entity_is_suborganization == "True":
if is_requesting_new_suborganization:
# Validate custom suborganization fields
if not cleaned_data.get("requested_suborganization"):
if not cleaned_data.get("requested_suborganization") and "requested_suborganization" not in self.errors:
self.add_error("requested_suborganization", "Enter the name of your suborganization.")
if not cleaned_data.get("suborganization_city"):
self.add_error("suborganization_city", "Enter the city where your suborganization is located.")
@ -144,9 +159,12 @@ class RequestingEntityYesNoForm(BaseYesNoForm):
"""Extend the initialization of the form from RegistrarForm __init__"""
super().__init__(*args, **kwargs)
if self.domain_request.portfolio:
choose_text = (
"(choose from list)" if self.domain_request.portfolio.portfolio_suborganizations.exists() else ""
)
self.form_choices = (
(False, self.domain_request.portfolio),
(True, "A suborganization (choose from list)"),
(True, f"A suborganization {choose_text}"),
)
self.fields[self.field_name] = self.get_typed_choice_field()

View file

@ -12,7 +12,6 @@ from registrar.models import (
DomainInformation,
Portfolio,
SeniorOfficial,
User,
)
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@ -111,170 +110,7 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
return cleaned_data
class PortfolioMemberForm(forms.ModelForm):
"""
Form for updating a portfolio member.
"""
roles = forms.MultipleChoiceField(
choices=UserPortfolioRoleChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Roles",
)
additional_permissions = forms.MultipleChoiceField(
choices=UserPortfolioPermissionChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Additional Permissions",
)
class Meta:
model = UserPortfolioPermission
fields = [
"roles",
"additional_permissions",
]
class PortfolioInvitedMemberForm(forms.ModelForm):
"""
Form for updating a portfolio invited member.
"""
roles = forms.MultipleChoiceField(
choices=UserPortfolioRoleChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Roles",
)
additional_permissions = forms.MultipleChoiceField(
choices=UserPortfolioPermissionChoices.choices,
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
required=False,
label="Additional Permissions",
)
class Meta:
model = PortfolioInvitation
fields = [
"roles",
"additional_permissions",
]
class NewMemberForm(forms.ModelForm):
member_access_level = forms.ChoiceField(
label="Select permission",
choices=[("admin", "Admin Access"), ("basic", "Basic Access")],
widget=forms.RadioSelect(attrs={"class": "usa-radio__input usa-radio__input--tile"}),
required=True,
error_messages={
"required": "Member access level is required",
},
)
admin_org_domain_request_permissions = forms.ChoiceField(
label="Select permission",
choices=[("view_only", "View all requests"), ("view_and_create", "View all requests plus create requests")],
widget=forms.RadioSelect,
required=True,
error_messages={
"required": "Admin domain request permission is required",
},
)
admin_org_members_permissions = forms.ChoiceField(
label="Select permission",
choices=[("view_only", "View all members"), ("view_and_create", "View all members plus manage members")],
widget=forms.RadioSelect,
required=True,
error_messages={
"required": "Admin member permission is required",
},
)
basic_org_domain_request_permissions = forms.ChoiceField(
label="Select permission",
choices=[
("view_only", "View all requests"),
("view_and_create", "View all requests plus create requests"),
("no_access", "No access"),
],
widget=forms.RadioSelect,
required=True,
error_messages={
"required": "Basic member permission is required",
},
)
email = forms.EmailField(
label="Enter the email of the member you'd like to invite",
max_length=None,
error_messages={
"invalid": ("Enter an email address in the required format, like name@example.com."),
"required": ("Enter an email address in the required format, like name@example.com."),
},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
required=True,
)
class Meta:
model = User
fields = ["email"]
def clean(self):
cleaned_data = super().clean()
# Lowercase the value of the 'email' field
email_value = cleaned_data.get("email")
if email_value:
cleaned_data["email"] = email_value.lower()
##########################################
# TODO: future ticket
# (invite new member)
##########################################
# Check for an existing user (if there isn't any, send an invite)
# if email_value:
# try:
# existingUser = User.objects.get(email=email_value)
# except User.DoesNotExist:
# raise forms.ValidationError("User with this email does not exist.")
member_access_level = cleaned_data.get("member_access_level")
# Intercept the error messages so that we don't validate hidden inputs
if not member_access_level:
# If no member access level has been selected, delete error messages
# for all hidden inputs (which is everything except the e-mail input
# and member access selection)
for field in self.fields:
if field in self.errors and field != "email" and field != "member_access_level":
del self.errors[field]
return cleaned_data
basic_dom_req_error = "basic_org_domain_request_permissions"
admin_dom_req_error = "admin_org_domain_request_permissions"
admin_member_error = "admin_org_members_permissions"
if member_access_level == "admin" and basic_dom_req_error in self.errors:
# remove the error messages pertaining to basic permission inputs
del self.errors[basic_dom_req_error]
elif member_access_level == "basic":
# remove the error messages pertaining to admin permission inputs
if admin_dom_req_error in self.errors:
del self.errors[admin_dom_req_error]
if admin_member_error in self.errors:
del self.errors[admin_member_error]
return cleaned_data
class BasePortfolioMemberForm(forms.Form):
class BasePortfolioMemberForm(forms.ModelForm):
"""Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm"""
# The label for each of these has a red "required" star. We can just embed that here for simplicity.
@ -345,13 +181,18 @@ class BasePortfolioMemberForm(forms.Form):
],
}
def __init__(self, *args, instance=None, **kwargs):
"""Initialize self.instance, self.initial, and descriptions under each radio button.
Uses map_instance_to_initial to set the initial dictionary."""
class Meta:
model = None
fields = ["roles", "additional_permissions"]
def __init__(self, *args, **kwargs):
"""
Override the form's initialization.
Map existing model values to custom form fields.
Update field descriptions.
"""
super().__init__(*args, **kwargs)
if instance:
self.instance = instance
self.initial = self.map_instance_to_initial(self.instance)
# Adds a <p> description beneath each role option
self.fields["role"].descriptions = {
"organization_admin": UserPortfolioRoleChoices.get_role_description(
@ -361,17 +202,15 @@ class BasePortfolioMemberForm(forms.Form):
UserPortfolioRoleChoices.ORGANIZATION_MEMBER
),
}
def save(self):
"""Saves self.instance by grabbing data from self.cleaned_data.
Uses map_cleaned_data_to_instance.
"""
self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance)
self.instance.save()
return self.instance
# Map model instance values to custom form fields
if self.instance:
self.map_instance_to_initial()
def clean(self):
"""Validates form data based on selected role and its required fields."""
"""Validates form data based on selected role and its required fields.
Updates roles and additional_permissions in cleaned_data so they can be properly
mapped to the model.
"""
cleaned_data = super().clean()
role = cleaned_data.get("role")
@ -389,20 +228,30 @@ class BasePortfolioMemberForm(forms.Form):
if cleaned_data.get("domain_request_permission_member") == "no_access":
cleaned_data["domain_request_permission_member"] = None
# Handle roles
cleaned_data["roles"] = [role]
# Handle additional_permissions
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
# Handle EDIT permissions (should be accompanied with a view permission)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
# Only set unique permissions not already defined in the base role
role_permissions = UserPortfolioPermission.get_portfolio_permissions(cleaned_data["roles"], [], get_list=False)
cleaned_data["additional_permissions"] = list(additional_permissions - role_permissions)
return cleaned_data
# Explanation of how map_instance_to_initial / map_cleaned_data_to_instance work:
# map_instance_to_initial => called on init to set self.initial.
# Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission)
# into a dictionary representation for the form to use automatically.
# map_cleaned_data_to_instance => called on save() to save the instance to the db.
# Takes the self.cleaned_data dict, and converts this dict back to the object.
def map_instance_to_initial(self, instance):
def map_instance_to_initial(self):
"""
Maps self.instance to self.initial, handling roles and permissions.
Returns form data dictionary with appropriate permission levels based on user role:
Updates self.initial dictionary with appropriate permission levels based on user role:
{
"role": "organization_admin" or "organization_member",
"member_permission_admin": permission level if admin,
@ -410,12 +259,12 @@ class BasePortfolioMemberForm(forms.Form):
"domain_request_permission_member": permission level if member
}
"""
if self.initial is None:
self.initial = {}
# Function variables
form_data = {}
perms = UserPortfolioPermission.get_portfolio_permissions(
instance.roles, instance.additional_permissions, get_list=False
self.instance.roles, self.instance.additional_permissions, get_list=False
)
# Get the available options for roles, domains, and member.
roles = [
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
@ -433,49 +282,62 @@ class BasePortfolioMemberForm(forms.Form):
# Build form data based on role (which options are available).
# Get which one should be "selected" by assuming that EDIT takes precedence over view,
# and ADMIN takes precedence over MEMBER.
roles = instance.roles or []
roles = self.instance.roles or []
selected_role = next((role for role in roles if role in roles), None)
form_data = {"role": selected_role}
self.initial["role"] = selected_role
is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN
if is_admin:
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None)
selected_member_permission = next((perm for perm in member_perms if perm in perms), None)
form_data["domain_request_permission_admin"] = selected_domain_permission
form_data["member_permission_admin"] = selected_member_permission
self.initial["domain_request_permission_admin"] = selected_domain_permission
self.initial["member_permission_admin"] = selected_member_permission
else:
# Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection.
selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access")
form_data["domain_request_permission_member"] = selected_domain_permission
self.initial["domain_request_permission_member"] = selected_domain_permission
return form_data
def map_cleaned_data_to_instance(self, cleaned_data, instance):
class PortfolioMemberForm(BasePortfolioMemberForm):
"""
Maps self.cleaned_data to self.instance, setting roles and permissions.
Args:
cleaned_data (dict): Cleaned data containing role and permission choices
instance: Instance to update
Returns:
instance: Updated instance
Form for updating a portfolio member.
"""
role = cleaned_data.get("role")
# Handle roles
instance.roles = [role]
class Meta:
model = UserPortfolioPermission
fields = ["roles", "additional_permissions"]
# Handle additional_permissions
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
# Handle EDIT permissions (should be accompanied with a view permission)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS)
class PortfolioInvitedMemberForm(BasePortfolioMemberForm):
"""
Form for updating a portfolio invited member.
"""
if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions:
additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)
class Meta:
model = PortfolioInvitation
fields = ["roles", "additional_permissions"]
# Only set unique permissions not already defined in the base role
role_permissions = UserPortfolioPermission.get_portfolio_permissions(instance.roles, [], get_list=False)
instance.additional_permissions = list(additional_permissions - role_permissions)
return instance
class PortfolioNewMemberForm(BasePortfolioMemberForm):
"""
Form for adding a portfolio invited member.
"""
email = forms.EmailField(
label="Enter the email of the member you'd like to invite",
max_length=None,
error_messages={
"invalid": ("Enter an email address in the required format, like name@example.com."),
"required": ("Enter an email address in the required format, like name@example.com."),
},
validators=[
MaxLengthValidator(
320,
message="Response must be less than 320 characters.",
)
],
required=True,
)
class Meta:
model = PortfolioInvitation
fields = ["portfolio", "email", "roles", "additional_permissions"]

View file

@ -12,6 +12,7 @@ from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTy
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.utility.constants import BranchChoices
from auditlog.models import LogEntry
from django.core.exceptions import ValidationError
from registrar.utility.waffle import flag_is_active_for_user
@ -671,6 +672,59 @@ class DomainRequest(TimeStampedModel):
# Store original values for caching purposes. Used to compare them on save.
self._cache_status_and_status_reasons()
def clean(self):
"""
Validates suborganization-related fields in two scenarios:
1. New suborganization request: Prevents duplicate names within same portfolio
2. Partial suborganization data: Enforces a all-or-nothing rule for city/state/name fields
when portfolio exists without selected suborganization
Add new domain request validation rules here to ensure they're
enforced during both model save and form submission.
Not presently used on the domain request wizard, though.
"""
super().clean()
# Validation logic for a suborganization request
if self.is_requesting_new_suborganization():
# Raise an error if this suborganization already exists
Suborganization = apps.get_model("registrar.Suborganization")
if (
self.requested_suborganization
and Suborganization.objects.filter(
name__iexact=self.requested_suborganization,
portfolio=self.portfolio,
name__isnull=False,
portfolio__isnull=False,
).exists()
):
# Add a field-level error to requested_suborganization.
# To pass in field-specific errors, we need to embed a dict of
# field: validationerror then pass that into a validation error itself.
# This is slightly confusing, but it just adds it at that level.
msg = (
"This suborganization already exists. "
"Choose a new name, or select it directly if you would like to use it."
)
errors = {"requested_suborganization": ValidationError(msg)}
raise ValidationError(errors)
elif self.portfolio and not self.sub_organization:
# You cannot create a new suborganization without these fields
required_suborg_fields = {
"requested_suborganization": self.requested_suborganization,
"suborganization_city": self.suborganization_city,
"suborganization_state_territory": self.suborganization_state_territory,
}
# If at least one value is populated, enforce a all-or-nothing rule
if any(bool(value) for value in required_suborg_fields.values()):
# Find which fields are empty and throw an error on the field
errors = {}
for field_name, value in required_suborg_fields.items():
if not value:
errors[field_name] = ValidationError(
"This field is required when creating a new suborganization.",
)
raise ValidationError(errors)
def save(self, *args, **kwargs):
"""Save override for custom properties"""
self.sync_organization_type()
@ -690,6 +744,18 @@ class DomainRequest(TimeStampedModel):
# Update the cached values after saving
self._cache_status_and_status_reasons()
def create_requested_suborganization(self):
"""Creates the requested suborganization.
Adds the name, portfolio, city, and state_territory fields.
Returns the created suborganization."""
Suborganization = apps.get_model("registrar.Suborganization")
return Suborganization.objects.create(
name=self.requested_suborganization,
portfolio=self.portfolio,
city=self.suborganization_city,
state_territory=self.suborganization_state_territory,
)
def send_custom_status_update_email(self, status):
"""Helper function to send out a second status email when the status remains the same,
but the reason has changed."""
@ -784,7 +850,9 @@ class DomainRequest(TimeStampedModel):
return True
def delete_and_clean_up_domain(self, called_from):
# Delete the approved domain
try:
# Clean up the approved domain
domain_state = self.approved_domain.state
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
@ -796,6 +864,39 @@ class DomainRequest(TimeStampedModel):
logger.error(err)
logger.error(f"Can't query an approved domain while attempting {called_from}")
# Delete the suborg as long as this is the only place it is used
self._cleanup_dangling_suborg()
def _cleanup_dangling_suborg(self):
"""Deletes the existing suborg if its only being used by the deleted record"""
# Nothing to delete, so we just smile and walk away
if self.sub_organization is None:
return
Suborganization = apps.get_model("registrar.Suborganization")
# Stored as so because we need to set the reference to none first,
# so we can't just use the self.sub_organization property
suborg = Suborganization.objects.get(id=self.sub_organization.id)
requests = suborg.request_sub_organization
domain_infos = suborg.information_sub_organization
# Check if this is the only reference to the suborganization
if requests.count() != 1 or domain_infos.count() > 1:
return
# Remove the suborganization reference from request.
self.sub_organization = None
self.save()
# Remove the suborganization reference from domain if it exists.
if domain_infos.count() == 1:
domain_infos.update(sub_organization=None)
# Delete the now-orphaned suborganization
logger.info(f"_cleanup_dangling_suborg() -> Deleting orphan suborganization: {suborg}")
suborg.delete()
def _send_status_update_email(
self,
new_status,
@ -984,6 +1085,7 @@ class DomainRequest(TimeStampedModel):
if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("action_needed")
elif self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None
@ -1014,8 +1116,16 @@ class DomainRequest(TimeStampedModel):
domain request into an admin on that domain. It also triggers an email
notification."""
should_save = False
if self.federal_agency is None:
self.federal_agency = FederalAgency.objects.filter(agency="Non-Federal Agency").first()
should_save = True
if self.is_requesting_new_suborganization():
self.sub_organization = self.create_requested_suborganization()
should_save = True
if should_save:
self.save()
# create the domain
@ -1148,7 +1258,7 @@ class DomainRequest(TimeStampedModel):
def is_requesting_new_suborganization(self) -> bool:
"""Determines if a user is trying to request
a new suborganization using the domain request form, rather than one that already exists.
Used for the RequestingEntity page.
Used for the RequestingEntity page and on DomainInformation.create_from_da().
Returns True if a sub_organization does not exist and if requested_suborganization,
suborganization_city, and suborganization_state_territory all exist.

View file

@ -171,8 +171,10 @@ class UserPortfolioPermission(TimeStampedModel):
# The solution to this is to only grab what is only COMMONLY "forbidden".
# This will scale if we add more roles in the future.
# This is thes same as applying the `&` operator across all sets for each role.
common_forbidden_perms = set.intersection(
*[set(cls.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) for role in roles]
common_forbidden_perms = (
set.intersection(*[set(cls.FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) for role in roles])
if roles
else set()
)
# Check if the users current permissions overlap with any forbidden permissions

View file

@ -5,8 +5,8 @@
{% block title %}{% translate "Unauthorized | " %}{% endblock %}
{% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-row grow-gap">
<main id="main-content" class="grid-container grid-container--widescreen">
<div class="grid-row grow-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
<h1>
{% translate "You are not authorized to view this page" %}

View file

@ -5,8 +5,8 @@
{% block title %}{% translate "Forbidden | " %}{% endblock %}
{% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-row grow-gap">
<main id="main-content" class="grid-container grid-container--widescreen">
<div class="grid-row grow-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
<h1>
{% translate "You're not authorized to view this page." %}

View file

@ -5,8 +5,8 @@
{% block title %}{% translate "Page not found | " %}{% endblock %}
{% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-row grid-gap">
<main id="main-content" class="grid-container grid-container--widescreen">
<div class="grid-row grid-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
<h1>
{% translate "We couldnt find that page" %}

View file

@ -5,8 +5,8 @@
{% block title %}{% translate "Server error | " %}{% endblock %}
{% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-row grid-gap">
<main id="main-content" class="grid-container grid-container--widescreen">
<div class="grid-row grid-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="tablet:grid-col-6 usa-prose margin-bottom-3">
<h1>
{% translate "We're having some trouble." %}

View file

@ -39,7 +39,7 @@
{% for model in app.models %}
<tr class="model-{{ model.object_name|lower }}{% if model.admin_url in request.path|urlencode %} current-model{% endif %}">
{% if model.admin_url %}
<th scope="row"><a href="{{ model.admin_url }}"{% if model.admin_url in request.path|urlencode %} aria-current="page"{% endif %}>{{ model.name }}</a></th>
<th scope="row"><a href="{{ model.admin_url }}"{% if model.admin_url in request.path|urlencode %} aria-current="page"{% endif %}">{{ model.name }}</a></th>
{% else %}
<th scope="row">{{ model.name }}</th>
{% endif %}

View file

@ -61,7 +61,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% if field.field.help_text %}
{# .gov override #}
{% block help_text %}
<div class="help"{% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}>
<div class="help"{% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}">
<div>{{ field.field.help_text|safe }}</div>
</div>
{% endblock help_text %}

View file

@ -43,7 +43,7 @@
<select name="selected_user" id="selected_user" class="admin-combobox margin-top-0" onchange="this.form.submit()">
<option value="">Select a user</option>
{% for user in other_users %}
<option value="{{ user.pk }}" {% if selected_user and user.pk == selected_user.pk %}selected{% endif %}>
<option value="{{ user.pk }}" {% if selected_user and user.pk == selected_user.pk %}selected{% endif %}">
{{ user.first_name }} {{ user.last_name }}
</option>
{% endfor %}

View file

@ -97,7 +97,7 @@
<section class="usa-banner" aria-label="Official website of the United States government">
<div class="usa-accordion">
<header class="usa-banner__header">
<div class="usa-banner__inner {% if is_widescreen_mode %} usa-banner__inner--widescreen {% endif %}">
<div class="usa-banner__inner usa-banner__inner--widescreen padding-x--widescreen">
<div class="grid-col-auto">
<img class="usa-banner__header-flag" src="{% static 'img/us_flag_small.png' %}" alt="U.S. flag" />
</div>
@ -113,7 +113,7 @@
</button>
</div>
</header>
<div class="usa-banner__content usa-accordion__content" id="gov-banner-default">
<div class="usa-banner__content usa-accordion__content padding-x--widescreen margin-x-0" id="gov-banner-default">
<div class="grid-row grid-gap-lg">
<div class="usa-banner__guidance tablet:grid-col-6">
<img class="usa-banner__icon usa-media-block__img" src="{% static 'img/icon-dot-gov.svg' %}" role="img"
@ -159,14 +159,14 @@
{% block wrapper %}
{% block wrapperdiv %}
<div id="wrapper">
<div id="wrapper" class="wrapper--padding-top-6">
{% endblock wrapperdiv %}
{% block messages %}
{% if messages %}
<ul class="messages">
{% for message in messages %}
{% if 'base' in message.extra_tags %}
<li{% if message.tags %} class="{{ message.tags }}" {% endif %}>
<li{% if message.tags %} class="{{ message.tags }}" {% endif %}">
{{ message }}
</li>
{% endif %}

View file

@ -11,7 +11,7 @@
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li {% if message.tags %} class="{{ message.tags }}" {% endif %}>
<li {% if message.tags %} class="{{ message.tags }}" {% endif %}">
{{ message }}
</li>
{% endfor %}

View file

@ -321,6 +321,22 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% else %}
<input id="last-sent-rejection-email-content" class="display-none" value="None">
{% endif %}
{% elif field.field.name == "requested_suborganization" %}
{{ field.field }}
<div class="requested-suborganization--clear-button">
<button
id="clear-requested-suborganization"
class="usa-button--dja usa-button usa-button__small-text usa-button--unstyled"
type="button"
>
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Clear requested suborganization
</button>
</div>
{% else %}
{{ field.field }}
{% endif %}

View file

@ -1,6 +1,6 @@
{% comment %} This view provides a detail button that can be used to show/hide content {% endcomment %}
<details class="margin-top-1 dja-detail-table" aria-role="button" {% if start_open %}open{% else %}closed{% endif %}>
<details class="margin-top-1 dja-detail-table" aria-role="button" {% if start_open %}open{% else %}closed{% endif %}">
<summary class="padding-1 padding-left-0 dja-details-summary">Details</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% block detail_content %}

View file

@ -11,7 +11,7 @@
{% endblock %}
{% block help_text %}
<div class="help margin-bottom-1" {% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}>
<div class="help margin-bottom-1" {% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}">
{% if field.field.name == "state" %}
<div>{{ state_help_message }}</div>
{% else %}

View file

@ -6,14 +6,14 @@
<ul class="mulitple-choice">
{% for choice in choices %}
{% if choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}>
<li{% if choice.selected %} class="selected"{% endif %}">
<a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
</li>
{% endif %}
{% endfor %}
{% for choice in choices %}
{% 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 %}
<a 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">

View file

@ -0,0 +1,14 @@
{% extends 'django/admin/email_clipboard_change_form.html' %}
{% load custom_filters %}
{% load i18n static %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a portfolio here, it will trigger an invitation email when you click "save." If you don't want to trigger an email, use the <a class="usa-link" href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">User portfolio permissions table</a> instead.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -2,15 +2,13 @@
{% load custom_filters %}
{% load i18n static %}
{% block field_sets %}
{% for fieldset in adminform %}
{% comment %}
This is a placeholder for now.
Disclaimer:
When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
{% endcomment %}
{% include "django/admin/includes/user_portfolio_permission_fieldset.html" with original_object=original %}
{% endfor %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you add someone to a portfolio here, it will not trigger an invitation email. To trigger an email, use the <a class="usa-link" href="{% url 'admin:registrar_portfolioinvitation_changelist' %}">Portfolio invitations table</a> instead.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -6,10 +6,10 @@
{% block title %}{{ domain.name }} | {% endblock %}
{% block content %}
<div class="grid-container">
<div class="grid-container grid-container--widescreen">
<div class="grid-row grid-gap">
<div class="tablet:grid-col-3">
<div class="grid-row grid-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="tablet:grid-col-3 ">
<p class="font-body-md margin-top-0 margin-bottom-2
text-primary-darker text-semibold domain-name-wrap"
>

View file

@ -6,11 +6,7 @@
<ul class="usa-list">
<li>Be available </li>
<li>Relate to your organizations name, location, and/or services </li>
{% if portfolio %}
<li>Be clear to the general public. Your domain name must not be easily confused with other organizations.</li>
{% else %}
<li>Be unlikely to mislead or confuse the general public (even if your domain is only intended for a specific audience) </li>
{% endif %}
</ul>
</p>

View file

@ -3,8 +3,8 @@
{% block title %}{{form_titles|get_item:steps.current}} | Request a .gov | {% endblock %}
{% block content %}
<div class="grid-container">
<div class="grid-row grid-gap">
<div class="grid-container grid-container--widescreen">
<div class="grid-row grid-gap {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="tablet:grid-col-3">
{% include 'domain_request_sidebar.html' %}
</div>

View file

@ -4,7 +4,8 @@
{% block title %} Start a request | {% endblock %}
{% block content %}
<main id="main-content" class="grid-container">
<main id="main-content" class="grid-container grid-container--widescreen">
<div class="grid-row {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
<form class="usa-form usa-form--extra-large" method="post" novalidate>
@ -25,7 +26,7 @@
{% include "includes/profile_information.html" with user=user%}
{% block form_buttons %}
{% block form_buttons %}
<div class="stepnav">
<button
type="submit"
@ -34,7 +35,7 @@
class="usa-button"
>Continue</button>
</div>
{% endblock %}
{% endblock %}
</form>

View file

@ -38,8 +38,9 @@
<div id="suborganization-container" class="margin-top-4">
<h2>Add suborganization information</h2>
<p>
This information will be published in <a class="usa-link usa-link--always-blue" target="_blank" href="{% public_site_url 'about/data' %}">.govs public data</a>. If you dont see your suborganization in the list,
select “other.”
This information will be published in <a class="usa-link usa-link--always-blue" target="_blank" href="{% public_site_url 'about/data' %}">.govs public data</a>.
<span id="suborganization-addtl-instruction"> If you dont see your suborganization in the list,
select “other.”</span>
</p>
{% with attr_required=True %}
{% input_with_errors forms.1.sub_organization %}

View file

@ -8,18 +8,20 @@
{% endblock wrapperdiv %}
{% block content %}
<div class="grid-container">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
<div class="grid-container grid-container--widescreen">
<div class="grid-row {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
<h1>Withdraw request for {{ DomainRequest.requested_domain.name }}?</h1>
<h1>Withdraw request for {{ DomainRequest.requested_domain.name }}?</h1>
<p>If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again. </p>
<p>If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again. </p>
<p><a href="{% url 'domain-request-withdrawn' DomainRequest.id %}" class="usa-button withdraw">Withdraw request</a>
<p><a href="{% url 'domain-request-withdrawn' DomainRequest.id %}" class="usa-button withdraw">Withdraw request</a>
<a href="{% url 'domain-request-status' DomainRequest.id %}">Cancel</a></p>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -18,7 +18,7 @@
<li class="usa-sidenav__item">
{% url 'domain-dns' pk=domain.id as url %}
<a href="{{ url }}" {% if request.path|startswith:url %}class="usa-current"{% endif %}>
<a href="{{ url }}" {% if request.path|startswith:url %}class="usa-current"{% endif %}">
DNS
</a>
{% if request.path|startswith:url %}

View file

@ -0,0 +1,34 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi.
{{ requestor_email }} has invited you to {{ portfolio.organization_name }}.
You can view this organization on the .gov registrar <https://manage.get.gov>.
----------------------------------------------------------------
YOU NEED A LOGIN.GOV ACCOUNT
Youll need a Login.gov account to access this .gov organization. That account
needs to be associated with the following email address: {{ email }}
Login.gov provides a simple and secure process for signing in to many government
services with one account. If you dont already have one, follow these steps to
create your Login.gov account <https://login.gov/help/get-started/create-your-account/>.
SOMETHING WRONG?
If youre not affiliated with {{ portfolio.organization_name }} or think you received this
message in error, reply to this email.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
Youve been invited to a .gov organization

View file

@ -5,12 +5,12 @@
{% block title %} Home | {% endblock %}
{% block content %}
<main id="main-content" class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<main id="main-content" class="grid-container grid-container--widescreen">
{% if user.is_authenticated %}
{# the entire logged in page goes here #}
{% block homepage_content %}
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
<div class="tablet:grid-col-11 desktop:grid-col-10 {% if is_widescreen_centered %}tablet:grid-offset-1{% endif %}">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}

View file

@ -1,6 +1,6 @@
<div class="margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert usa-alert--error">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<div class="usa-alert__body usa-alert__body--widescreen">
<h4 class="usa-alert__heading">
Header
</h4>

View file

@ -1,6 +1,6 @@
<section class="usa-site-alert usa-site-alert--info margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<div class="usa-alert__body usa-alert__body--widescreen">
<h4 class="usa-alert__heading">
Header
</h4>

View file

@ -1,6 +1,6 @@
<section class="usa-site-alert usa-site-alert--emergency usa-site-alert--hot-pink margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %} {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %} usa-alert__body--widescreen">
<p class="usa-alert__text maxw-none">
<strong>Attention:</strong> You are on a test site.
</p>

View file

@ -1,6 +1,6 @@
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<div class="usa-alert__body usa-alert__body--widescreen">
<h3 class="usa-alert__heading">
Service disruption
</h3>

View file

@ -1,6 +1,6 @@
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<div class="usa-alert__body usa-alert__body--widescreen">
<h3 class="usa-alert__heading">
Header here
</h3>

View file

@ -1,6 +1,6 @@
<section class="usa-site-alert usa-site-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<div class="usa-alert__body usa-alert__body--widescreen">
<h3 class="usa-alert__heading">
System outage
</h3>

View file

@ -1,6 +1,6 @@
<div class="margin-y-0 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert usa-alert--warning">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<div class="usa-alert__body usa-alert__body--widescreen">
<h4 class="usa-alert__heading">
Header
</h4>

View file

@ -1,236 +0,0 @@
{% load custom_filters %}
{% load static url_helpers %}
<main id="main-content" class="grid-container">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
{% block breadcrumb %}
{% if portfolio %}
{% url 'domain-requests' as url %}
{% else %}
{% url 'home' as url %}
{% endif %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
{% if portfolio %}
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Domain requests</span></a>
{% else %}
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Manage your domains</span></a>
{% endif %}
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
{% if not DomainRequest.requested_domain and DomainRequest.status == DomainRequest.DomainRequestStatus.STARTED %}
<span>New domain request</span>
{% else %}
<span>{{ DomainRequest.requested_domain.name }}</span>
{% endif %}
</li>
</ol>
</nav>
{% endblock breadcrumb %}
{% block header %}
{% if not DomainRequest.requested_domain and DomainRequest.status == DomainRequest.DomainRequestStatus.STARTED %}
<h1>New domain request</h1>
{% else %}
<h1>Domain request for {{ DomainRequest.requested_domain.name }}</h1>
{% endif %}
{% endblock header %}
{% block status_summary %}
<div
class="usa-summary-box dotgov-status-box margin-top-3 padding-left-2"
role="region"
aria-labelledby="summary-box-key-information"
>
<div class="usa-summary-box__body">
<p class="usa-summary-box__heading font-sans-md margin-bottom-0"
id="summary-box-key-information"
>
<span class="text-bold text-primary-darker">
Status:
</span>
{{ DomainRequest.get_status_display|default:"ERROR Please contact technical support/dev" }}
</p>
</div>
</div>
<br>
{% endblock status_summary %}
{% block status_metadata %}
{% if portfolio %}
{% if DomainRequest.creator %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">Created by:</b> {{DomainRequest.creator.email|default:DomainRequest.creator.get_formatted_name }}
</p>
{% else %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">No creator found:</b> this is an error, please email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
</p>
{% endif %}
{% endif %}
{% with statuses=DomainRequest.DomainRequestStatus last_submitted=DomainRequest.last_submitted_date|date:"F j, Y" first_submitted=DomainRequest.first_submitted_date|date:"F j, Y" last_status_update=DomainRequest.last_status_update|date:"F j, Y" %}
{% comment %}
These are intentionally seperated this way.
There is some code repetition, but it gives us more flexibility rather than a dense reduction.
Leave it this way until we've solidified our requirements.
{% endcomment %}
{% if DomainRequest.status == statuses.STARTED %}
{% with first_started_date=DomainRequest.get_first_status_started_date|date:"F j, Y" %}
<p class="margin-top-1">
{% comment %}
A newly created domain request will not have a value for last_status update.
This is because the status never really updated.
However, if this somehow goes back to started we can default to displaying that new date.
{% endcomment %}
<b class="review__step__name">Started on:</b> {{last_status_update|default:first_started_date}}
</p>
{% endwith %}
{% elif DomainRequest.status == statuses.SUBMITTED %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">Submitted on:</b> {{last_submitted|default:first_submitted }}
</p>
<p class="margin-top-1">
<b class="review__step__name">Last updated on:</b> {{DomainRequest.updated_at|date:"F j, Y"}}
</p>
{% elif DomainRequest.status == statuses.ACTION_NEEDED %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">Submitted on:</b> {{last_submitted|default:first_submitted }}
</p>
<p class="margin-top-1">
<b class="review__step__name">Last updated on:</b> {{DomainRequest.updated_at|date:"F j, Y"}}
</p>
{% elif DomainRequest.status == statuses.REJECTED %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">Submitted on:</b> {{last_submitted|default:first_submitted }}
</p>
<p class="margin-top-1">
<b class="review__step__name">Rejected on:</b> {{last_status_update}}
</p>
{% elif DomainRequest.status == statuses.WITHDRAWN %}
<p class="margin-top-1 margin-bottom-1">
<b class="review__step__name">Submitted on:</b> {{last_submitted|default:first_submitted }}
</p>
<p class="margin-top-1">
<b class="review__step__name">Withdrawn on:</b> {{last_status_update}}
</p>
{% else %}
{% comment %} Shown for in_review, approved, ineligible {% endcomment %}
<p class="margin-top-1">
<b class="review__step__name">Last updated on:</b> {{DomainRequest.updated_at|date:"F j, Y"}}
</p>
{% endif %}
{% endwith %}
{% endblock status_metadata %}
{% block status_blurb %}
{% if DomainRequest.is_awaiting_review %}
<p>{% include "includes/domain_request_awaiting_review.html" with show_withdraw_text=DomainRequest.is_withdrawable %}</p>
{% endif %}
{% endblock status_blurb %}
{% block modify_request %}
{% if DomainRequest.is_withdrawable %}
<p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a>
</p>
{% endif %}
{% endblock modify_request %}
</div>
<div class="grid-col desktop:grid-offset-2 maxw-tablet">
{% block request_summary_header %}
<h2 class="text-primary-darker"> Summary of your domain request </h2>
{% endblock request_summary_header%}
{% block request_summary %}
{% with heading_level='h3' %}
{% with org_type=DomainRequest.get_generic_org_type_display %}
{% include "includes/summary_item.html" with title='Type of organization' value=org_type heading_level=heading_level %}
{% endwith %}
{% if DomainRequest.tribe_name %}
{% include "includes/summary_item.html" with title='Tribal government' value=DomainRequest.tribe_name heading_level=heading_level %}
{% if DomainRequest.federally_recognized_tribe %}
<p>Federally-recognized tribe</p>
{% endif %}
{% if DomainRequest.state_recognized_tribe %}
<p>State-recognized tribe</p>
{% endif %}
{% endif %}
{% if DomainRequest.get_federal_type_display %}
{% include "includes/summary_item.html" with title='Federal government branch' value=DomainRequest.get_federal_type_display heading_level=heading_level %}
{% endif %}
{% if DomainRequest.is_election_board %}
{% with value=DomainRequest.is_election_board|yesno:"Yes,No,Incomplete" %}
{% include "includes/summary_item.html" with title='Election office' value=value heading_level=heading_level %}
{% endwith %}
{% endif %}
{% if DomainRequest.organization_name %}
{% include "includes/summary_item.html" with title='Organization' value=DomainRequest address='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.about_your_organization %}
{% include "includes/summary_item.html" with title='About your organization' value=DomainRequest.about_your_organization heading_level=heading_level %}
{% endif %}
{% if DomainRequest.senior_official %}
{% include "includes/summary_item.html" with title='Senior official' value=DomainRequest.senior_official contact='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.current_websites.all %}
{% include "includes/summary_item.html" with title='Current websites' value=DomainRequest.current_websites.all list='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.requested_domain %}
{% include "includes/summary_item.html" with title='.gov domain' value=DomainRequest.requested_domain heading_level=heading_level %}
{% endif %}
{% if DomainRequest.alternative_domains.all %}
{% include "includes/summary_item.html" with title='Alternative domains' value=DomainRequest.alternative_domains.all list='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.purpose %}
{% include "includes/summary_item.html" with title='Purpose of your domain' value=DomainRequest.purpose heading_level=heading_level %}
{% endif %}
{% if DomainRequest.creator %}
{% include "includes/summary_item.html" with title='Your contact information' value=DomainRequest.creator contact='true' heading_level=heading_level %}
{% endif %}
{% if DomainRequest.other_contacts.all %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.other_contacts.all contact='true' list='true' heading_level=heading_level %}
{% else %}
{% include "includes/summary_item.html" with title='Other employees from your organization' value=DomainRequest.no_other_contacts_rationale heading_level=heading_level %}
{% endif %}
{# We always show this field even if None #}
{% if DomainRequest %}
<h3 class="register-form-review-header">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 }}
{% else %}
No
{% endif %}
</ul>
<h3 class="register-form-review-header">Anything else</h3>
<ul class="usa-list usa-list--unstyled margin-top-0">
{% if DomainRequest.anything_else %}
{{DomainRequest.anything_else}}
{% else %}
No
{% endif %}
</ul>
{% endif %}
{% endwith %}
{% endblock request_summary%}
</div>
</main>

View file

@ -4,8 +4,8 @@
{% url 'get_domain_requests_json' as url %}
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
<section class="section-outlined domain-requests {% if portfolio %}section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %}section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not portfolio %}
<h2 id="domain-requests-header" class="display-inline-block">Domain requests</h2>
{% else %}
@ -13,7 +13,7 @@
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %}
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %} {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
<div class="section-outlined__search section-outlined__search--widescreen {% if portfolio %}mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domain requests search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}

View file

@ -12,7 +12,7 @@
{% if has_domain_renewal_flag and num_expiring_domains > 0 and has_any_domains_portfolio_permission %}
<section class="usa-site-alert usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<div class="usa-alert__body usa-alert__body--widescreen">
<p class="usa-alert__text maxw-none">
{% if num_expiring_domains == 1%}
One domain will expire soon. Go to "Manage" to renew the domain. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domain.</a>
@ -33,7 +33,7 @@
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %}
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %} {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
<div class="section-outlined__search section-outlined__search--widescreen {% if portfolio %}mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
@ -78,7 +78,7 @@
{% if has_domain_renewal_flag and num_expiring_domains > 0 and not portfolio %}
<section class="usa-site-alert usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert">
<div class="usa-alert__body {% if is_widescreen_mode %}usa-alert__body--widescreen{% endif %}">
<div class="usa-alert__body usa-alert__body--widescreen">
<p class="usa-alert__text maxw-none">
{% if num_expiring_domains == 1%}
One domain will expire soon. Go to "Manage" to renew the domain. <a href="#" id="link-expiring-domains" class="usa-link">Show expiring domain.</a>

View file

@ -3,7 +3,7 @@
<footer class="usa-footer">
<div class="usa-footer__secondary-section">
<div class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<div class="grid-container grid-container--widescreen">
<div class="grid-row grid-gap">
<div
class="
@ -24,7 +24,7 @@
<div class="usa-footer__contact-links
mobile-lg:grid-col-6 flex-align-self-center"
>
<address class="usa-footer__address">
<address class="usa-footer__address maxw-none">
<div class="usa-footer__contact-info grid-row grid-gap-md">
{% if show_manage_your_domains %}
<div class="grid-col-auto">
@ -51,7 +51,7 @@
class="usa-identifier__section usa-identifier__section--masthead"
aria-label="Agency identifier"
>
<div class="usa-identifier__container {% if is_widescreen_mode %} usa-identifier__container--widescreen {% endif %}">
<div class="usa-identifier__container usa-identifier__container--widescreen padding-x--widescreen">
<div class="usa-identifier__logos">
<a rel="noopener noreferrer" target="_blank" href="https://www.cisa.gov" class="usa-identifier__logo"
><img
@ -77,7 +77,7 @@
class="usa-identifier__section usa-identifier__section--required-links"
aria-label="Important links"
>
<div class="usa-identifier__container {% if is_widescreen_mode %} usa-identifier__container--widescreen {% endif %}">
<div class="usa-identifier__container usa-identifier__container--widescreen padding-x--widescreen">
<ul class="usa-identifier__required-links-list">
<li class="usa-identifier__required-links-item">
<a rel="noopener noreferrer" target="_blank" href="{% public_site_url 'about/' %}"
@ -119,7 +119,7 @@
class="usa-identifier__section usa-identifier__section--usagov"
aria-label="U.S. government information and services"
>
<div class="usa-identifier__container {% if is_widescreen_mode %} usa-identifier__container--widescreen {% endif %}">
<div class="usa-identifier__container usa-identifier__container--widescreen padding-x--widescreen">
<div class="usa-identifier__usagov-description">
Looking for U.S. government information and services?
</div>

View file

@ -1,7 +1,7 @@
{% load static %}
<header class="usa-header usa-header--basic">
<div class="usa-nav-container {% if is_widescreen_mode %} usa-nav-container--widescreen {% endif %}">
<div class="usa-nav-container usa-nav-container--widescreen padding-x--widescreen">
<div class="usa-navbar">
{% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %}
<button type="button" class="usa-menu-btn">Menu</button>

View file

@ -2,13 +2,13 @@
{% load custom_filters %}
<header class="usa-header usa-header--extended">
<div class="usa-navbar {% if is_widescreen_mode %} usa-navbar--widescreen {% endif %}">
<div class="usa-navbar usa-navbar--widescreen padding-x--widescreen">
{% include "includes/gov_extended_logo.html" with logo_clickable=logo_clickable %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
{% block usa_nav %}
<nav class="usa-nav" aria-label="Primary navigation">
<div class="usa-nav__inner {% if is_widescreen_mode %} usa-nav__inner--widescreen {% endif %}">
<div class="usa-nav__inner usa-nav__inner--widescreen padding-x--widescreen">
<button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button>

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

@ -8,7 +8,7 @@
<section class="section-outlined members margin-top-0 section-outlined--border-base-light" id="members">
<div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6 {% if is_widescreen_mode %} section-outlined__search--widescreen {% endif %}">
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6 section-outlined__search--widescreen">
<section aria-label="Members search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}

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

@ -1,7 +1,8 @@
{% load custom_filters %}
{% load static url_helpers %}
<main id="main-content" class="grid-container">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
<main id="main-content" class="grid-container grid-container--widescreen">
<div class="{% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="grid-col desktop:grid-col-8 desktop:grid-offset-2 ">
{% block breadcrumb %}
{% if portfolio %}
{% url 'domain-requests' as url %}
@ -139,7 +140,7 @@
{% endblock modify_request %}
</div>
<div class="grid-col desktop:grid-offset-2 maxw-tablet">
<div class="grid-col maxw-fit-content desktop:grid-offset-2 ">
{% block request_summary_header %}
<h2 class="text-primary-darker"> Summary of your domain request </h2>
{% endblock request_summary_header%}
@ -216,7 +217,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 +225,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}}
@ -237,4 +238,5 @@
{% endif %}
{% endblock request_summary%}
</div>
</div>
</main>

View file

@ -1,3 +1,3 @@
<p class="{% if not remove_margin_top %}margin-top-3 {% endif %}">
<p class="{% if not remove_margin_top %}margin-top-3{% endif %}">
<em>Required fields are marked with an asterisk (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
</p>

View file

@ -1,5 +1,9 @@
{% load static field_helpers url_helpers %}
<!-- Form messages -->
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
{% if can_edit %}
{% include "includes/form_errors.html" with form=form %}
{% endif %}

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

@ -4,14 +4,12 @@
<div id="wrapper" class="{% block wrapper_class %}wrapper--padding-top-6{% endblock %}">
{% block content %}
<main class="grid-container {% if is_widescreen_mode %} grid-container--widescreen{% endif %}">
<main class="grid-container grid-container--widescreen">
{% if user.is_authenticated %}
{# the entire logged in page goes here #}
<div class="tablet:grid-col-11 desktop:grid-col-10 tablet:grid-offset-1">
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div class="grid-row {% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="tablet:grid-col-11 desktop:grid-col-10 {% if is_widescreen_centered %}tablet:grid-offset-1{% endif %}">
{% block portfolio_content %}{% endblock %}
@ -24,6 +22,8 @@
</a></p>
{% endif %}
</div>
</main>
{% endblock content%}

View file

@ -9,9 +9,11 @@
{% endblock %}
{% block portfolio_content %}
<!-- Form messages -->
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
{% endblock messages%}
<div id="main-content">
<h1 id="domains-header">Domains</h1>

View file

@ -8,7 +8,12 @@ Organization member
{% load static %}
{% block portfolio_content %}
<div id="main-content">
<div id="main-content" class="{% if not is_widescreen_centered %}desktop:grid-offset-2{% endif %}">
<!-- Form messages -->
{% include "includes/form_errors.html" with form=form %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
{% url 'members' as url %}
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">

View file

@ -6,7 +6,12 @@
{% load static %}
{% block portfolio_content %}
<div id="main-content">
<div id="main-content" class=" {% if not is_widescreen_centered %}desktop:grid-offset-2{% endif %}">
<!-- Form messages -->
{% include "includes/form_errors.html" with form=form %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
{% url 'members' as url %}
{% if portfolio_permission %}

View file

@ -6,7 +6,12 @@
{% load static %}
{% block portfolio_content %}
<div id="main-content">
<div id="main-content" class=" {% if not is_widescreen_centered %}desktop:grid-offset-2{% endif %}">
<!-- Form messages -->
{% include "includes/form_errors.html" with form=form %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
{% url 'members' as url %}
{% if portfolio_permission %}
@ -27,12 +32,13 @@
<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">
<li class="usa-breadcrumb__list-item usa-current domain-assignments-edit-breadcrumb" aria-current="page">
<span>Edit domain assignments</span>
</li>
</ol>
</nav>
<section id="domain-assignments-edit-view">
<h1 class="margin-bottom-3">Edit domain assignments</h1>
<p class="margin-bottom-0">
@ -57,6 +63,7 @@
</li>
<li class="usa-button-group__item">
<button
id="review-domain-assignments"
type="button"
class="usa-button"
>
@ -64,6 +71,60 @@
</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

@ -11,8 +11,15 @@
{% block portfolio_content %}
{% include "includes/form_errors.html" with form=form %}
<!-- Navigation breadcrumbs -->
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
<div id="main-content" class=" {% if not is_widescreen_centered %}desktop:grid-offset-2{% endif %}">
<!-- Form messages -->
{% include "includes/form_errors.html" with form=form %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
<!-- Navigation breadcrumbs -->
<nav class="usa-breadcrumb padding-top-0 bg-gray-1" aria-label="Domain request breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{% url 'members' %}" class="usa-breadcrumb__link"><span>Members</span></a>
@ -30,14 +37,14 @@
<span>Member access and permissions</span>
</li>
</ol>
</nav>
</nav>
<!-- Page header -->
<h1>Member access and permissions</h1>
<!-- Page header -->
<h1>Member access and permissions</h1>
{% include "includes/required_fields.html" with remove_margin_top=True %}
{% include "includes/required_fields.html" with remove_margin_top=True %}
<form class="usa-form usa-form--large" method="post" id="member_form" novalidate>
<form class="usa-form usa-form--large" method="post" id="member_form" novalidate>
{% csrf_token %}
<fieldset class="usa-fieldset">
<legend>
@ -69,8 +76,8 @@
<!-- Member email -->
</fieldset>
<!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset">
<!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset">
<legend>
<h2 class="margin-top-0">Member Access</h2>
</legend>
@ -81,17 +88,17 @@
{% input_with_errors form.role %}
{% endwith %}
</fieldset>
</fieldset>
<!-- Admin access form -->
<div id="member-admin-permissions" class="margin-top-2">
<!-- Admin access form -->
<div id="member-admin-permissions" class="margin-top-2">
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p>
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.domain_request_permission_admin %}
{% endwith %}
@ -99,7 +106,7 @@
text-primary-dark
margin-bottom-0
margin-top-3">Organization members</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.member_permission_admin %}
{% endwith %}
</div>
@ -110,7 +117,7 @@
<p>Member permissions available for basic-level acccess.</p>
<h3 class="margin-bottom-0 summary-item__title text-primary-dark">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.domain_request_permission_member %}
{% endwith %}
</div>
@ -128,5 +135,6 @@
</a>
<button type="submit" class="usa-button">Update Member</button>
</div>
</form>
</form>
</div>
{% endblock portfolio_content%}

View file

@ -10,6 +10,11 @@
{% block portfolio_content %}
<!-- Form messages -->
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
<div id="main-content">
<div id="toggleable-alert" class="usa-alert usa-alert--slim margin-bottom-2 display-none">
<div class="usa-alert__body usa-alert__body--widescreen">

View file

@ -10,14 +10,15 @@
{% block portfolio_content %}
<!-- Form mesages -->
{% include "includes/form_errors.html" with form=form %}
{% block messages %}
<div id="main-content" class=" {% if not is_widescreen_centered %}desktop:grid-offset-2{% endif %}">
<!-- Form messages -->
{% include "includes/form_errors.html" with form=form %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
{% endblock messages%}
<!-- Navigation breadcrumbs -->
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain request breadcrumb">
<nav class="usa-breadcrumb padding-top-0 bg-gray-1" aria-label="Domain request breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{% url 'members' %}" class="usa-breadcrumb__link"><span>Members</span></a>
@ -28,16 +29,16 @@
</ol>
</nav>
<!-- Page header -->
{% block new_member_header %}
<h1>Add a new member</h1>
{% endblock new_member_header %}
<!-- Page header -->
{% block new_member_header %}
<h1>Add a new member</h1>
{% endblock new_member_header %}
{% include "includes/required_fields.html" %}
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" id="add_member_form" novalidate>
<form class="usa-form usa-form--large" method="post" id="add_member_form" novalidate>
<fieldset class="usa-fieldset margin-top-2">
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Email</h2>
</legend>
@ -46,70 +47,56 @@
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.email %}
{% endwith %}
</fieldset>
</fieldset>
<!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset margin-top-2">
<!-- Member access radio buttons (Toggles other sections) -->
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Member Access</h2>
</legend>
<em>Select the level of access for this member. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></em>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
<div class="usa-radio">
{% for radio in form.member_access_level %}
{{ radio.tag }}
<label class="usa-radio__label usa-legend" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
<p class="margin-0 margin-top-2">
{% if radio.choice_label == "Admin Access" %}
Grants this member access to the organization-wide information on domains, domain requests, and members. Domain management can be assigned separately.
{% else %}
Grants this member access to the organization. They can be given extra permissions to view all organization domain requests and submit domain requests on behalf of the organization. Basic access members cant view all members of an organization or manage them. Domain management can be assigned separately.
{% endif %}
</p>
</label>
{% endfor %}
</div>
{% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %}
{% input_with_errors form.role %}
{% endwith %}
</fieldset>
</fieldset>
<!-- Admin access form -->
<div id="new-member-admin-permissions" class="margin-top-2">
<!-- Admin access form -->
<div id="new-member-admin-permissions" class="margin-top-2">
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p>
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.admin_org_domain_request_permissions %}
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.domain_request_permission_admin %}
{% endwith %}
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0
margin-top-3">Organization members</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.admin_org_members_permissions %}
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.member_permission_admin %}
{% endwith %}
</div>
<!-- Basic access form -->
<div id="new-member-basic-permissions" class="margin-top-2">
<!-- Basic access form -->
<div id="new-member-basic-permissions" class="margin-top-2">
<h2>Basic member permissions</h2>
<p>Member permissions available for basic-level acccess.</p>
<h3 class="margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.basic_org_domain_request_permissions %}
{% with group_classes="usa-form-editable usa-form-editable--no-border bg-gray-1 padding-top-0" %}
{% input_with_errors form.domain_request_permission_member %}
{% endwith %}
</div>
<!-- Submit/cancel buttons -->
<div class="margin-top-3">
<!-- Submit/cancel buttons -->
<div class="margin-top-3">
<a
type="button"
href="{% url 'members' %}"
@ -117,7 +104,7 @@
name="btn-cancel-click"
aria-label="Cancel adding new member"
>Cancel
</a>
</a>
<a
id="invite_member_trigger"
href="#invite-member-modal"
@ -126,15 +113,14 @@
data-open-modal
>Trigger invite member modal</a>
<button id="invite_new_member_submit" type="submit" class="usa-button">Invite Member</button>
</div>
</form>
</div>
</form>
<div
class="usa-modal"
id="invite-member-modal"
aria-labelledby="invite-member-heading"
aria-describedby="confirm-invite-description"
style="display: none;"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
@ -185,6 +171,7 @@
</svg>
</button>
</div>
</div>
</div>

View file

@ -4,8 +4,13 @@
{% block title %} Domains | {% endblock %}
<!-- Form messages -->
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
{% block portfolio_content %}
<div id="main-content">
<div id="main-content" class="{% if not is_widescreen_centered %}desktop:grid-offset-2{% endif %}">
<h1 id="domains-header">Domains</h1>
<section class="section-outlined">
<div class="section-outlined__header margin-bottom-3">

View file

@ -4,7 +4,13 @@
{% block title %} Domain Requests | {% endblock %}
<!-- Form messages -->
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock messages%}
{% block portfolio_content %}
<div id="main-content" class="{% if not is_widescreen_centered %}desktop:grid-offset-2{% endif %}">
<h1 id="domains-header">Domain requests</h1>
<section class="section-outlined">
<div class="section-outlined__header margin-bottom-3">
@ -27,4 +33,5 @@
{% endif %}
</div>
</section>
</div>
{% endblock %}

View file

@ -18,12 +18,11 @@
</div>
<div class="tablet:grid-col-9" id="main-content">
<!-- Form messages -->
{% include "includes/form_errors.html" with form=form %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
{% include "includes/form_errors.html" with form=form %}
{% endblock messages%}
<h1>Organization</h1>

View file

@ -9,9 +9,11 @@
{% endblock %}
{% block portfolio_content %}
<!-- Form messages -->
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
{% endblock messages%}
<div id="main-content">
<h1 id="domain-requests-header" class="margin-bottom-1">Domain requests</h1>
@ -19,7 +21,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

@ -6,9 +6,6 @@
{% load static %}
{% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div class="grid-row grid-gap">
<div class="tablet:grid-col-3">

View file

@ -11,8 +11,9 @@ Edit your User Profile |
{% endblock %}
{% block content %}
<main id="main-content" class="grid-container">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
<main id="main-content" class="grid-container grid-container--widescreen">
<div class="{% if not is_widescreen_centered %}max-width--grid-container{% endif %}">
<div class="desktop:grid-col-8 desktop:grid-offset-2">
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-3">
@ -90,11 +91,12 @@ Edit your User Profile |
{% endblock content %}
{% endblock content %}
{% block content_bottom %}
{% block content_bottom %}
{% include "includes/profile_form.html" with form=form %}
</div>
</div>
</main>
{% endblock content_bottom %}

View file

@ -1034,6 +1034,10 @@ def completed_domain_request( # noqa
action_needed_reason=None,
portfolio=None,
organization_name=None,
sub_organization=None,
requested_suborganization=None,
suborganization_city=None,
suborganization_state_territory=None,
):
"""A completed domain request."""
if not user:
@ -1098,6 +1102,18 @@ def completed_domain_request( # noqa
if portfolio:
domain_request_kwargs["portfolio"] = portfolio
if sub_organization:
domain_request_kwargs["sub_organization"] = sub_organization
if requested_suborganization:
domain_request_kwargs["requested_suborganization"] = requested_suborganization
if suborganization_city:
domain_request_kwargs["suborganization_city"] = suborganization_city
if suborganization_state_territory:
domain_request_kwargs["suborganization_state_territory"] = suborganization_state_territory
domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
if has_other_contacts:

View file

@ -2,6 +2,8 @@ from datetime import datetime
from django.utils import timezone
from django.test import TestCase, RequestFactory, Client
from django.contrib.admin.sites import AdminSite
from registrar.utility.email import EmailSendingError
from registrar.utility.errors import MissingEmailError
from waffle.testutils import override_flag
from django_webtest import WebTest # type: ignore
from api.tests.common import less_console_noise_decorator
@ -277,6 +279,29 @@ class TestUserPortfolioPermissionAdmin(TestCase):
# Should return the forbidden permissions for member role
self.assertEqual(member_only_permissions, set(member_forbidden))
@less_console_noise_decorator
def test_has_change_form_description(self):
"""Tests if this model has a model description on the change form view"""
self.client.force_login(self.superuser)
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
user=self.superuser, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
response = self.client.get(
"/admin/registrar/userportfoliopermission/{}/change/".format(user_portfolio_permission.pk),
follow=True,
)
# Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200)
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a portfolio here, it will not trigger an invitation email.",
)
class TestPortfolioInvitationAdmin(TestCase):
"""Tests for the PortfolioInvitationAdmin class as super user
@ -432,6 +457,30 @@ class TestPortfolioInvitationAdmin(TestCase):
)
self.assertContains(response, "Show more")
@less_console_noise_decorator
def test_has_change_form_description(self):
"""Tests if this model has a model description on the change form view"""
self.client.force_login(self.superuser)
invitation, _ = PortfolioInvitation.objects.get_or_create(
email=self.superuser.email, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
response = self.client.get(
"/admin/registrar/portfolioinvitation/{}/change/".format(invitation.pk),
follow=True,
)
# Make sure that the page is loaded correctly
self.assertEqual(response.status_code, 200)
# Test for a description snippet
self.assertContains(
response,
"If you add someone to a portfolio here, it will trigger an invitation email when you click",
)
@less_console_noise_decorator
def test_get_filters(self):
"""Ensures that our filters are displaying correctly"""
with less_console_noise():
@ -456,6 +505,176 @@ class TestPortfolioInvitationAdmin(TestCase):
self.assertContains(response, invited_html, count=1)
self.assertContains(response, retrieved_html, count=1)
@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_invitation_email")
@patch("django.contrib.messages.success") # Mock the `messages.warning` call
def test_save_sends_email(self, mock_messages_warning, mock_send_email):
"""On save_model, an email is NOT sent if an invitation already exists."""
self.client.force_login(self.superuser)
# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
# Create a PortfolioInvitation instance
portfolio_invitation = PortfolioInvitation(
email="james.gordon@gotham.gov",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
# Create a request object
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
request.user = self.superuser
# Call the save_model method
admin_instance.save_model(request, portfolio_invitation, None, None)
# Assert that send_portfolio_invitation_email is not called
mock_send_email.assert_called()
# Get the arguments passed to send_portfolio_invitation_email
_, called_kwargs = mock_send_email.call_args
# Assert the email content
self.assertEqual(called_kwargs["email"], "james.gordon@gotham.gov")
self.assertEqual(called_kwargs["requestor"], self.superuser)
self.assertEqual(called_kwargs["portfolio"], self.portfolio)
# Assert that a warning message was triggered
mock_messages_warning.assert_called_once_with(request, "james.gordon@gotham.gov has been invited.")
@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_invitation_email")
@patch("django.contrib.messages.warning") # Mock the `messages.warning` call
def test_save_does_not_send_email_if_requested_user_exists(self, mock_messages_warning, mock_send_email):
"""On save_model, an email is NOT sent if an the requested email belongs to an existing user.
It also throws a warning."""
self.client.force_login(self.superuser)
# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
# Mock the UserPortfolioPermission query to simulate the invitation already existing
existing_user = create_user()
UserPortfolioPermission.objects.create(user=existing_user, portfolio=self.portfolio)
# Create a PortfolioInvitation instance
portfolio_invitation = PortfolioInvitation(
email=existing_user.email,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
# Create a request object
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
request.user = self.superuser
# Call the save_model method
admin_instance.save_model(request, portfolio_invitation, None, None)
# Assert that send_portfolio_invitation_email is not called
mock_send_email.assert_not_called()
# Assert that a warning message was triggered
mock_messages_warning.assert_called_once_with(request, "User is already a member of this portfolio.")
@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_invitation_email")
@patch("django.contrib.messages.error") # Mock the `messages.error` call
def test_save_exception_email_sending_error(self, mock_messages_error, mock_send_email):
"""Handle EmailSendingError correctly when sending the portfolio invitation fails."""
self.client.force_login(self.superuser)
# Mock the email sending function to raise EmailSendingError
mock_send_email.side_effect = EmailSendingError("Email service unavailable")
# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
# Create a PortfolioInvitation instance
portfolio_invitation = PortfolioInvitation(
email="james.gordon@gotham.gov",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
# Create a request object
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
request.user = self.superuser
# Call the save_model method
admin_instance.save_model(request, portfolio_invitation, None, None)
# Assert that messages.error was called with the correct message
mock_messages_error.assert_called_once_with(
request, "Could not send email invitation. Portfolio invitation not saved."
)
@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_invitation_email")
@patch("django.contrib.messages.error") # Mock the `messages.error` call
def test_save_exception_missing_email_error(self, mock_messages_error, mock_send_email):
"""Handle MissingEmailError correctly when no email exists for the requestor."""
self.client.force_login(self.superuser)
# Mock the email sending function to raise MissingEmailError
mock_send_email.side_effect = MissingEmailError()
# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
# Create a PortfolioInvitation instance
portfolio_invitation = PortfolioInvitation(
email="james.gordon@gotham.gov",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
# Create a request object
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
request.user = self.superuser
# Call the save_model method
admin_instance.save_model(request, portfolio_invitation, None, None)
# Assert that messages.error was called with the correct message
mock_messages_error.assert_called_once_with(
request,
"Can't send invitation email. No email is associated with your user account.",
)
@less_console_noise_decorator
@patch("registrar.admin.send_portfolio_invitation_email")
@patch("django.contrib.messages.error") # Mock the `messages.error` call
def test_save_exception_generic_error(self, mock_messages_error, mock_send_email):
"""Handle generic exceptions correctly during portfolio invitation."""
self.client.force_login(self.superuser)
# Mock the email sending function to raise a generic exception
mock_send_email.side_effect = Exception("Unexpected error")
# Create an instance of the admin class
admin_instance = PortfolioInvitationAdmin(PortfolioInvitation, admin_site=None)
# Create a PortfolioInvitation instance
portfolio_invitation = PortfolioInvitation(
email="james.gordon@gotham.gov",
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
# Create a request object
request = self.factory.post("/admin/registrar/PortfolioInvitation/add/")
request.user = self.superuser
# Call the save_model method
admin_instance.save_model(request, portfolio_invitation, None, None)
# Assert that messages.error was called with the correct message
mock_messages_error.assert_called_once_with(
request, "Could not send email invitation. Portfolio invitation not saved."
)
class TestHostAdmin(TestCase):
"""Tests for the HostAdmin class as super user

View file

@ -1,5 +1,7 @@
from datetime import datetime
from django.forms import ValidationError
from django.utils import timezone
from waffle.testutils import override_flag
import re
from django.test import RequestFactory, Client, TestCase, override_settings
from django.contrib.admin.sites import AdminSite
@ -24,7 +26,10 @@ from registrar.models import (
SeniorOfficial,
Portfolio,
AllowedEmail,
Suborganization,
)
from registrar.models.host import Host
from registrar.models.public_contact import PublicContact
from .common import (
MockSESClient,
completed_domain_request,
@ -36,7 +41,7 @@ from .common import (
MockEppLib,
GenericTestHelper,
)
from unittest.mock import patch
from unittest.mock import ANY, patch
from django.conf import settings
import boto3_mocking # type: ignore
@ -76,12 +81,15 @@ class TestDomainRequestAdmin(MockEppLib):
def tearDown(self):
super().tearDown()
Host.objects.all().delete()
PublicContact.objects.all().delete()
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
Contact.objects.all().delete()
Website.objects.all().delete()
SeniorOfficial.objects.all().delete()
Suborganization.objects.all().delete()
Portfolio.objects.all().delete()
self.mock_client.EMAILS_SENT.clear()
@ -91,6 +99,83 @@ class TestDomainRequestAdmin(MockEppLib):
User.objects.all().delete()
AllowedEmail.objects.all().delete()
@override_flag("organization_feature", active=True)
@less_console_noise_decorator
def test_clean_validates_duplicate_suborganization(self):
"""Tests that clean() prevents duplicate suborganization names within the same portfolio"""
# Create a portfolio and existing suborganization
portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
# Create an existing suborganization
Suborganization.objects.create(name="Existing Suborg", portfolio=portfolio)
# Create a domain request trying to use the same suborganization name
# (intentionally lowercase)
domain_request = completed_domain_request(
name="test1234.gov",
portfolio=portfolio,
requested_suborganization="existing suborg",
suborganization_city="Rome",
suborganization_state_territory=DomainRequest.StateTerritoryChoices.OHIO,
)
# Assert that the validation error is raised
with self.assertRaises(ValidationError) as err:
domain_request.clean()
self.assertIn("This suborganization already exists", str(err.exception))
# Test that a different name is allowed. Should not raise a error.
domain_request.requested_suborganization = "New Suborg"
domain_request.clean()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_clean_validates_partial_suborganization_fields(self):
"""Tests that clean() enforces all-or-nothing rule for suborganization fields"""
portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.superuser)
# Create domain request with only city filled out
domain_request = completed_domain_request(
name="test1234.gov",
portfolio=portfolio,
suborganization_city="Test City",
)
# Assert validation error is raised with correct missing fields
with self.assertRaises(ValidationError) as err:
domain_request.clean()
error_dict = err.exception.error_dict
expected_missing = ["requested_suborganization", "suborganization_state_territory"]
# Verify correct fields are flagged as required
self.assertEqual(sorted(error_dict.keys()), sorted(expected_missing))
# Verify error message
for field in expected_missing:
self.assertEqual(
str(error_dict[field][0].message), "This field is required when creating a new suborganization."
)
# When all data is passed in, this should validate correctly
domain_request.requested_suborganization = "Complete Suborg"
domain_request.suborganization_state_territory = DomainRequest.StateTerritoryChoices.OHIO
# Assert that no ValidationError is raised
try:
domain_request.clean()
except ValidationError as e:
self.fail(f"ValidationError was raised unexpectedly: {e}")
# Also ensure that no validation error is raised if nothing is passed in at all
domain_request.suborganization_city = None
domain_request.requested_suborganization = None
domain_request.suborganization_state_territory = None
try:
domain_request.clean()
except ValidationError as e:
self.fail(f"ValidationError was raised unexpectedly: {e}")
@less_console_noise_decorator
def test_domain_request_senior_official_is_alphabetically_sorted(self):
"""Tests if the senior offical dropdown is alphanetically sorted in the django admin display"""
@ -1808,6 +1893,37 @@ class TestDomainRequestAdmin(MockEppLib):
"Cannot edit a domain request with a restricted creator.",
)
@less_console_noise_decorator
def test_approved_domain_request_with_ready_domain_has_warning_message(self):
"""Tests if the domain request has a warning message when the approved domain is in Ready state"""
# Create an instance of the model
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
# Approve the domain request
domain_request.approve()
domain_request.save()
# Add nameservers to get to Ready state
domain_request.approved_domain.nameservers = [
("ns1.city.gov", ["1.1.1.1"]),
("ns2.city.gov", ["1.1.1.2"]),
]
domain_request.approved_domain.save()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with patch("django.contrib.messages.warning") as mock_warning:
# Create a request object
self.client.force_login(self.superuser)
self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
# Assert that the error message was called with the correct argument
mock_warning.assert_called_once_with(
ANY, # don't care about the request argument
f"The status of this domain request cannot be changed because it has been joined to a domain in Ready status: <a href='/admin/registrar/domain/{domain_request.approved_domain.id}/change/'>{domain_request.approved_domain.name}</a>", # noqa
)
def trigger_saving_approved_to_another_state(self, domain_is_active, another_state, rejection_reason=None):
"""Helper method that triggers domain request state changes from approved to another state,
with an associated domain that can be either active (READY) or not.

View file

@ -18,7 +18,17 @@ from registrar.forms.domain_request_wizard import (
AboutYourOrganizationForm,
)
from registrar.forms.domain import ContactForm
from registrar.tests.common import MockEppLib
from registrar.forms.portfolio import (
PortfolioInvitedMemberForm,
PortfolioMemberForm,
PortfolioNewMemberForm,
)
from registrar.models.portfolio import Portfolio
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user import User
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.tests.common import MockEppLib, create_user
from django.contrib.auth import get_user_model
@ -408,3 +418,196 @@ class TestContactForm(TestCase):
def test_contact_form_email_invalid2(self):
form = ContactForm(data={"email": "@"})
self.assertEqual(form.errors["email"], ["Enter a valid email address."])
class TestBasePortfolioMemberForms(TestCase):
"""We test on the child forms instead of BasePortfolioMemberForm because the base form
is a model form with no model bound."""
def setUp(self):
super().setUp()
self.user = create_user()
self.portfolio, _ = Portfolio.objects.get_or_create(
creator_id=self.user.id, organization_name="Hotel California"
)
def tearDown(self):
super().tearDown()
Portfolio.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
PortfolioInvitation.objects.all().delete()
User.objects.all().delete()
def _assert_form_is_valid(self, form_class, data, instance=None):
if instance is not None:
form = form_class(data=data, instance=instance)
else:
print("no instance")
form = form_class(data=data)
self.assertTrue(form.is_valid(), f"Form {form_class.__name__} failed validation with data: {data}")
return form
def _assert_form_has_error(self, form_class, data, field_name):
form = form_class(data=data)
self.assertFalse(form.is_valid())
self.assertIn(field_name, form.errors)
def _assert_initial_data(self, form_class, instance, expected_initial_data):
"""Helper to check if the instance data is correctly mapped to the initial form values."""
form = form_class(instance=instance)
for field, expected_value in expected_initial_data.items():
self.assertEqual(form.initial[field], expected_value)
def _assert_permission_mapping(self, form_class, data, expected_permissions):
"""Helper to check if permissions are correctly handled and mapped."""
form = self._assert_form_is_valid(form_class, data)
cleaned_data = form.cleaned_data
for permission in expected_permissions:
self.assertIn(permission, cleaned_data["additional_permissions"])
def test_required_field_for_admin(self):
"""Test that required fields are validated for an admin role."""
data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
"domain_request_permission_admin": "", # Simulate missing field
"member_permission_admin": "", # Simulate missing field
}
# Check required fields for all forms
self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin")
self._assert_form_has_error(PortfolioMemberForm, data, "member_permission_admin")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_admin")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin")
self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_admin")
self._assert_form_has_error(PortfolioNewMemberForm, data, "member_permission_admin")
def test_required_field_for_member(self):
"""Test that required fields are validated for a member role."""
data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": "", # Simulate missing field
}
# Check required fields for all forms
self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_member")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "domain_request_permission_member")
self._assert_form_has_error(PortfolioNewMemberForm, data, "domain_request_permission_member")
def test_clean_validates_required_fields_for_role(self):
"""Test that the `clean` method validates the correct fields for each role.
For PortfolioMemberForm and PortfolioInvitedMemberForm, we pass an object as the instance to the form.
For UserPortfolioPermissionChoices, we add a portfolio and an email to the POST data.
These things are handled in the views."""
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=self.portfolio, user=self.user
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho")
data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value,
}
# Check form validity for all forms
form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission)
cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation)
cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
data = {
"email": "hi@ho.com",
"portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"member_permission_admin": UserPortfolioPermissionChoices.EDIT_MEMBERS.value,
}
form = self._assert_form_is_valid(PortfolioNewMemberForm, data)
cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["roles"], [UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value])
self.assertEqual(cleaned_data["additional_permissions"], [UserPortfolioPermissionChoices.EDIT_MEMBERS])
def test_clean_member_permission_edgecase(self):
"""Test that the clean method correctly handles the special "no_access" value for members.
We'll need to add a portfolio, which in the app is handled by the view post."""
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
portfolio=self.portfolio, user=self.user
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(portfolio=self.portfolio, email="hi@ho")
data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": "no_access", # Simulate no access permission
}
form = self._assert_form_is_valid(PortfolioMemberForm, data, user_portfolio_permission)
cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["domain_request_permission_member"], None)
form = self._assert_form_is_valid(PortfolioInvitedMemberForm, data, portfolio_invitation)
cleaned_data = form.cleaned_data
self.assertEqual(cleaned_data["domain_request_permission_member"], None)
def test_map_instance_to_initial_admin_role(self):
"""Test that instance data is correctly mapped to the initial form values for an admin role."""
user_portfolio_permission = UserPortfolioPermission(
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
portfolio=self.portfolio,
email="hi@ho",
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MEMBERS],
)
expected_initial_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
"domain_request_permission_admin": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
"member_permission_admin": UserPortfolioPermissionChoices.VIEW_MEMBERS,
}
self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data)
self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data)
def test_map_instance_to_initial_member_role(self):
"""Test that instance data is correctly mapped to the initial form values for a member role."""
user_portfolio_permission = UserPortfolioPermission(
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
portfolio=self.portfolio,
email="hi@ho",
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS],
)
expected_initial_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
}
self._assert_initial_data(PortfolioMemberForm, user_portfolio_permission, expected_initial_data)
self._assert_initial_data(PortfolioInvitedMemberForm, portfolio_invitation, expected_initial_data)
def test_invalid_data_for_admin(self):
"""Test invalid form submission for an admin role with missing permissions."""
data = {
"email": "hi@ho.com",
"portfolio": self.portfolio.id,
"role": UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value,
"domain_request_permission_admin": "", # Missing field
"member_permission_admin": "", # Missing field
}
self._assert_form_has_error(PortfolioMemberForm, data, "domain_request_permission_admin")
self._assert_form_has_error(PortfolioInvitedMemberForm, data, "member_permission_admin")

View file

@ -15,6 +15,7 @@ from registrar.models import (
FederalAgency,
AllowedEmail,
Portfolio,
Suborganization,
)
import boto3_mocking
@ -23,6 +24,8 @@ from registrar.utility.errors import FSMDomainRequestError
from .common import (
MockSESClient,
create_user,
create_superuser,
less_console_noise,
completed_domain_request,
set_domain_request_investigators,
@ -1070,3 +1073,142 @@ class TestDomainRequest(TestCase):
)
self.assertEqual(domain_request2.generic_org_type, domain_request2.converted_generic_org_type)
self.assertEqual(domain_request2.federal_agency, domain_request2.converted_federal_agency)
class TestDomainRequestSuborganization(TestCase):
"""Tests for the suborganization fields on domain requests"""
def setUp(self):
super().setUp()
self.user = create_user()
self.superuser = create_superuser()
def tearDown(self):
super().tearDown()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
Domain.objects.all().delete()
Suborganization.objects.all().delete()
Portfolio.objects.all().delete()
@less_console_noise_decorator
def test_approve_creates_requested_suborganization(self):
"""Test that approving a domain request with a requested suborganization creates it"""
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
domain_request = completed_domain_request(
name="test.gov",
portfolio=portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
requested_suborganization="Boom",
suborganization_city="Explody town",
suborganization_state_territory=DomainRequest.StateTerritoryChoices.OHIO,
)
domain_request.investigator = self.superuser
domain_request.save()
domain_request.approve()
created_suborg = Suborganization.objects.filter(
name="Boom",
city="Explody town",
state_territory=DomainRequest.StateTerritoryChoices.OHIO,
portfolio=portfolio,
).first()
self.assertIsNotNone(created_suborg)
self.assertEqual(domain_request.sub_organization, created_suborg)
@less_console_noise_decorator
def test_approve_without_requested_suborganization_makes_no_changes(self):
"""Test that approving without a requested suborganization doesn't create one"""
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
domain_request = completed_domain_request(
name="test.gov",
portfolio=portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
)
domain_request.investigator = self.superuser
domain_request.save()
initial_suborg_count = Suborganization.objects.count()
domain_request.approve()
self.assertEqual(Suborganization.objects.count(), initial_suborg_count)
self.assertIsNone(domain_request.sub_organization)
@less_console_noise_decorator
def test_approve_with_existing_suborganization_makes_no_changes(self):
"""Test that approving with an existing suborganization doesn't create a new one"""
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
existing_suborg = Suborganization.objects.create(name="Existing Division", portfolio=portfolio)
domain_request = completed_domain_request(
name="test.gov",
portfolio=portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
sub_organization=existing_suborg,
)
domain_request.investigator = self.superuser
domain_request.save()
initial_suborg_count = Suborganization.objects.count()
domain_request.approve()
self.assertEqual(Suborganization.objects.count(), initial_suborg_count)
self.assertEqual(domain_request.sub_organization, existing_suborg)
@less_console_noise_decorator
def test_cleanup_dangling_suborg_with_single_reference(self):
"""Test that a suborganization is deleted when it's only referenced once"""
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
suborg = Suborganization.objects.create(name="Test Division", portfolio=portfolio)
domain_request = completed_domain_request(
name="test.gov",
portfolio=portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
sub_organization=suborg,
)
domain_request.approve()
# set it back to in review
domain_request.in_review()
domain_request.refresh_from_db()
# Verify the suborganization was deleted
self.assertFalse(Suborganization.objects.filter(id=suborg.id).exists())
self.assertIsNone(domain_request.sub_organization)
@less_console_noise_decorator
def test_cleanup_dangling_suborg_with_multiple_references(self):
"""Test that a suborganization is preserved when it has multiple references"""
portfolio = Portfolio.objects.create(organization_name="Test Org", creator=self.user)
suborg = Suborganization.objects.create(name="Test Division", portfolio=portfolio)
# Create two domain requests using the same suborganization
domain_request1 = completed_domain_request(
name="test1.gov",
portfolio=portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
sub_organization=suborg,
)
domain_request2 = completed_domain_request(
name="test2.gov",
portfolio=portfolio,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
sub_organization=suborg,
)
domain_request1.approve()
domain_request2.approve()
# set one back to in review
domain_request1.in_review()
domain_request1.refresh_from_db()
# Verify the suborganization still exists
self.assertTrue(Suborganization.objects.filter(id=suborg.id).exists())
self.assertEqual(domain_request1.sub_organization, suborg)
self.assertEqual(domain_request2.sub_organization, suborg)

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

@ -4,6 +4,8 @@ from unittest.mock import MagicMock, ANY, patch
from django.conf import settings
from django.urls import reverse
from django.contrib.auth import get_user_model
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.utility.email import EmailSendingError
from waffle.testutils import override_flag
from api.tests.common import less_console_noise_decorator
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@ -681,6 +683,7 @@ class TestDomainManagers(TestDomainOverview):
self.portfolio = Portfolio.objects.create(creator=self.user, organization_name="Ice Cream")
# Add the portfolio to the domain_information object
self.domain_information.portfolio = self.portfolio
self.domain_information.save()
# Add portfolio perms to the user object
self.portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
@ -693,6 +696,7 @@ class TestDomainManagers(TestDomainOverview):
def tearDown(self):
"""Ensure that the user has its original permissions"""
PortfolioInvitation.objects.all().delete()
super().tearDown()
@less_console_noise_decorator
@ -725,7 +729,7 @@ class TestDomainManagers(TestDomainOverview):
@less_console_noise_decorator
def test_domain_user_add_form(self):
"""Adding an existing user works."""
other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov")
get_user_model().objects.get_or_create(email="mayor@igorville.gov")
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
@ -748,6 +752,148 @@ class TestDomainManagers(TestDomainOverview):
success_page = success_result.follow()
self.assertContains(success_page, "mayor@igorville.gov")
@boto3_mocking.patching
@override_flag("organization_feature", active=True)
@less_console_noise_decorator
@patch("registrar.views.domain.send_portfolio_invitation_email")
@patch("registrar.views.domain.send_domain_invitation_email")
def test_domain_user_add_form_sends_portfolio_invitation(self, mock_send_domain_email, mock_send_portfolio_email):
"""Adding an existing user works and sends portfolio invitation when
user is not member of portfolio."""
get_user_model().objects.get_or_create(email="mayor@igorville.gov")
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "mayor@igorville.gov"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_result = add_page.form.submit()
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
)
# Verify that the invitation emails were sent
mock_send_portfolio_email.assert_called_once_with(
email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
)
mock_send_domain_email.assert_called_once()
call_args = mock_send_domain_email.call_args.kwargs
self.assertEqual(call_args["email"], "mayor@igorville.gov")
self.assertEqual(call_args["requestor"], self.user)
self.assertEqual(call_args["domain"], self.domain)
self.assertIsNone(call_args.get("is_member_of_different_org"))
# Assert that the PortfolioInvitation is created
portfolio_invitation = PortfolioInvitation.objects.filter(
email="mayor@igorville.gov", portfolio=self.portfolio
).first()
self.assertIsNotNone(portfolio_invitation, "Portfolio invitation should be created.")
self.assertEqual(portfolio_invitation.email, "mayor@igorville.gov")
self.assertEqual(portfolio_invitation.portfolio, self.portfolio)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
self.assertContains(success_page, "mayor@igorville.gov")
@boto3_mocking.patching
@override_flag("organization_feature", active=True)
@less_console_noise_decorator
@patch("registrar.views.domain.send_portfolio_invitation_email")
@patch("registrar.views.domain.send_domain_invitation_email")
def test_domain_user_add_form_doesnt_send_portfolio_invitation_if_already_member(
self, mock_send_domain_email, mock_send_portfolio_email
):
"""Adding an existing user works and sends portfolio invitation when
user is not member of portfolio."""
other_user, _ = get_user_model().objects.get_or_create(email="mayor@igorville.gov")
UserPortfolioPermission.objects.get_or_create(
user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "mayor@igorville.gov"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_result = add_page.form.submit()
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
)
# Verify that the invitation emails were sent
mock_send_portfolio_email.assert_not_called()
mock_send_domain_email.assert_called_once()
call_args = mock_send_domain_email.call_args.kwargs
self.assertEqual(call_args["email"], "mayor@igorville.gov")
self.assertEqual(call_args["requestor"], self.user)
self.assertEqual(call_args["domain"], self.domain)
self.assertIsNone(call_args.get("is_member_of_different_org"))
# Assert that no PortfolioInvitation is created
portfolio_invitation_exists = PortfolioInvitation.objects.filter(
email="mayor@igorville.gov", portfolio=self.portfolio
).exists()
self.assertFalse(
portfolio_invitation_exists, "Portfolio invitation should not be created when the user is already a member."
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
self.assertContains(success_page, "mayor@igorville.gov")
@boto3_mocking.patching
@override_flag("organization_feature", active=True)
@less_console_noise_decorator
@patch("registrar.views.domain.send_portfolio_invitation_email")
@patch("registrar.views.domain.send_domain_invitation_email")
def test_domain_user_add_form_sends_portfolio_invitation_raises_email_sending_error(
self, mock_send_domain_email, mock_send_portfolio_email
):
"""Adding an existing user works and attempts to send portfolio invitation when
user is not member of portfolio and send raises an error."""
mock_send_portfolio_email.side_effect = EmailSendingError("Failed to send email.")
get_user_model().objects.get_or_create(email="mayor@igorville.gov")
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = "mayor@igorville.gov"
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_result = add_page.form.submit()
self.assertEqual(success_result.status_code, 302)
self.assertEqual(
success_result["Location"],
reverse("domain-users", kwargs={"pk": self.domain.id}),
)
# Verify that the invitation emails were sent
mock_send_portfolio_email.assert_called_once_with(
email="mayor@igorville.gov", requestor=self.user, portfolio=self.portfolio
)
mock_send_domain_email.assert_not_called()
# Assert that no PortfolioInvitation is created
portfolio_invitation_exists = PortfolioInvitation.objects.filter(
email="mayor@igorville.gov", portfolio=self.portfolio
).exists()
self.assertFalse(
portfolio_invitation_exists, "Portfolio invitation should not be created when email fails to send."
)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
success_page = success_result.follow()
self.assertContains(success_page, "Could not send email invitation.")
@boto3_mocking.patching
@less_console_noise_decorator
def test_domain_invitation_created(self):
@ -960,39 +1106,20 @@ class TestDomainManagers(TestDomainOverview):
self.assertNotIn("Last", email_content)
self.assertNotIn("First Last", email_content)
@boto3_mocking.patching
@less_console_noise_decorator
def test_domain_invitation_email_displays_error_non_existent(self):
"""Inviting a non existent user sends them an email, with email as the name."""
# make sure there is no user with this email
email_address = "mayor@igorville.gov"
User.objects.filter(email=email_address).delete()
# Give the user who is sending the email an invalid email address
self.user.email = ""
self.user.save()
def test_domain_invitation_email_validation_blocks_bad_email(self):
"""Inviting a bad email blocks at validation."""
email_address = "mayor"
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
mock_client = MagicMock()
mock_error_message = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with patch("django.contrib.messages.error") as mock_error_message:
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit().follow()
response = add_page.form.submit()
expected_message_content = "Can't send invitation email. No email is associated with your account."
self.assertContains(response, "Enter an email address in the required format, like name@example.com.")
# Grab the message content
returned_error_message = mock_error_message.call_args[0][1]
# Check that the message content is what we expect
self.assertEqual(expected_message_content, returned_error_message)
@boto3_mocking.patching
@less_console_noise_decorator
def test_domain_invitation_email_displays_error(self):
"""When the requesting user has no email, an error is displayed"""
@ -1003,28 +1130,25 @@ class TestDomainManagers(TestDomainOverview):
# Give the user who is sending the email an invalid email address
self.user.email = ""
self.user.is_staff = False
self.user.save()
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
mock_client = MagicMock()
mock_error_message = MagicMock()
with boto3_mocking.clients.handler_for("sesv2", mock_client):
with patch("django.contrib.messages.error") as mock_error_message:
with patch("django.contrib.messages.error") as mock_error:
add_page = self.app.get(reverse("domain-users-add", kwargs={"pk": self.domain.id}))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
add_page.form["email"] = email_address
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
add_page.form.submit().follow()
add_page.form.submit()
expected_message_content = "Can't send invitation email. No email is associated with your account."
expected_message_content = "Can't send invitation email. No email is associated with your user account."
# Grab the message content
returned_error_message = mock_error_message.call_args[0][1]
# Check that the message content is what we expect
self.assertEqual(expected_message_content, returned_error_message)
# Assert that the error message was called with the correct argument
mock_error.assert_called_once_with(
ANY,
expected_message_content,
)
@less_console_noise_decorator
def test_domain_invitation_cancel(self):

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,17 +14,21 @@ 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
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.tests.test_views import TestWithUser
from registrar.utility.email import EmailSendingError
from registrar.utility.errors import MissingEmailError
from .common import MockSESClient, completed_domain_request, create_test_user, create_user
from waffle.testutils import override_flag
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__)
@ -1927,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],
@ -2106,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)
@ -2162,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)
@ -2222,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
@ -2268,6 +2576,46 @@ class TestRequestingEntity(WebTest):
User.objects.all().delete()
super().tearDown()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_form_validates_duplicate_suborganization(self):
"""Tests that form validation prevents duplicate suborganization names within the same portfolio"""
# Create an existing suborganization
suborganization = Suborganization.objects.create(name="Existing Suborg", portfolio=self.portfolio)
# Start the domain request process
response = self.app.get(reverse("domain-request:start"))
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
# Navigate past the intro page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
form = response.forms[0]
response = form.submit().follow()
# Fill out the requesting entity form
form = response.forms[0]
form["portfolio_requesting_entity-requesting_entity_is_suborganization"] = "True"
form["portfolio_requesting_entity-is_requesting_new_suborganization"] = "True"
form["portfolio_requesting_entity-requested_suborganization"] = suborganization.name.lower()
form["portfolio_requesting_entity-suborganization_city"] = "Eggnog"
form["portfolio_requesting_entity-suborganization_state_territory"] = DomainRequest.StateTerritoryChoices.OHIO
# Submit form and verify error
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
response = form.submit()
self.assertContains(response, "This suborganization already exists")
# Test that a different name is allowed
form["portfolio_requesting_entity-requested_suborganization"] = "New Suborg"
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
response = form.submit().follow()
# Verify successful submission by checking we're on the next page
self.assertContains(response, "Current websites")
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
@less_console_noise_decorator
@ -2531,7 +2879,9 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
],
)
cls.new_member_email = "new_user@example.com"
cls.new_member_email = "davekenn4242@gmail.com"
AllowedEmail.objects.get_or_create(email=cls.new_member_email)
# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
@ -2550,8 +2900,10 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
AllowedEmail.objects.all().delete()
super().tearDownClass()
@boto3_mocking.patching
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@ -2563,18 +2915,22 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
mock_client_class = MagicMock()
mock_client = mock_client_class.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client_class):
# Simulate submission of member invite for new user
final_response = self.client.post(
reverse("new-member"),
{
"member_access_level": "basic",
"basic_org_domain_request_permissions": "view_only",
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.new_member_email,
},
)
# Ensure the final submission is successful
self.assertEqual(final_response.status_code, 302) # redirects after success
self.assertEqual(final_response.status_code, 302) # Redirects
# Validate Database Changes
portfolio_invite = PortfolioInvitation.objects.filter(
@ -2583,10 +2939,216 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
self.assertIsNotNone(portfolio_invite)
self.assertEqual(portfolio_invite.email, self.new_member_email)
# Check that an email was sent
self.assertTrue(mock_client.send_email.called)
@boto3_mocking.patching
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_invite_for_previously_invited_member(self):
def test_member_invite_for_new_users_initial_ajax_call_passes(self):
"""Tests the member invitation flow for new users."""
self.client.force_login(self.user)
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
mock_client_class = MagicMock()
mock_client = mock_client_class.return_value
with boto3_mocking.clients.handler_for("sesv2", mock_client_class):
# Simulate submission of member invite for new user
final_response = self.client.post(
reverse("new-member"),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.new_member_email,
},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
# Ensure the prep ajax submission is successful
self.assertEqual(final_response.status_code, 200)
# Check that the response is a JSON response with is_valid
json_response = final_response.json()
self.assertIn("is_valid", json_response)
self.assertTrue(json_response["is_valid"])
# assert that portfolio invitation is not created
self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
"Portfolio invitation should not be created when an Exception occurs.",
)
# Check that an email was not sent
self.assertFalse(mock_client.send_email.called)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
def test_member_invite_for_previously_invited_member_initial_ajax_call_fails(self, mock_send_email):
"""Tests the initial ajax call in the member invitation flow for existing portfolio member."""
self.client.force_login(self.user)
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
invite_count_before = PortfolioInvitation.objects.count()
# Simulate submission of member invite for user who has already been invited
response = self.client.post(
reverse("new-member"),
{
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.invited_member_email,
},
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
)
self.assertEqual(response.status_code, 200)
# Check that the response is a JSON response with is_valid == False
json_response = response.json()
self.assertIn("is_valid", json_response)
self.assertFalse(json_response["is_valid"])
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)
# assert that send_portfolio_invitation_email is not called
mock_send_email.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
def test_submit_new_member_raises_email_sending_error(self, mock_send_email):
"""Test when adding a new member and email_send method raises EmailSendingError."""
mock_send_email.side_effect = EmailSendingError("Failed to send email.")
self.client.force_login(self.user)
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
form_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.new_member_email,
}
# Act
with patch("django.contrib.messages.warning") as mock_warning:
response = self.client.post(reverse("new-member"), data=form_data)
# Assert
# assert that the send_portfolio_invitation_email called
mock_send_email.assert_called_once_with(
email=self.new_member_email, requestor=self.user, portfolio=self.portfolio
)
# assert that response is a redirect to reverse("members")
self.assertRedirects(response, reverse("members"))
# assert that messages contains message, "Could not send email invitation"
mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.")
# assert that portfolio invitation is not created
self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
"Portfolio invitation should not be created when an EmailSendingError occurs.",
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
def test_submit_new_member_raises_missing_email_error(self, mock_send_email):
"""Test when adding a new member and email_send method raises MissingEmailError."""
mock_send_email.side_effect = MissingEmailError()
self.client.force_login(self.user)
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
form_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.new_member_email,
}
# Act
with patch("django.contrib.messages.error") as mock_error:
response = self.client.post(reverse("new-member"), data=form_data)
# Assert
# assert that the send_portfolio_invitation_email called
mock_send_email.assert_called_once_with(
email=self.new_member_email, requestor=self.user, portfolio=self.portfolio
)
# assert that response is a redirect to reverse("members")
self.assertRedirects(response, reverse("members"))
# assert that messages contains message, "Could not send email invitation"
mock_error.assert_called_once_with(
response.wsgi_request,
"Can't send invitation email. No email is associated with your user account.",
)
# assert that portfolio invitation is not created
self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
"Portfolio invitation should not be created when a MissingEmailError occurs.",
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
def test_submit_new_member_raises_exception(self, mock_send_email):
"""Test when adding a new member and email_send method raises Exception."""
mock_send_email.side_effect = Exception("Generic exception")
self.client.force_login(self.user)
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
form_data = {
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.new_member_email,
}
# Act
with patch("django.contrib.messages.warning") as mock_warning:
response = self.client.post(reverse("new-member"), data=form_data)
# Assert
# assert that the send_portfolio_invitation_email called
mock_send_email.assert_called_once_with(
email=self.new_member_email, requestor=self.user, portfolio=self.portfolio
)
# assert that response is a redirect to reverse("members")
self.assertRedirects(response, reverse("members"))
# assert that messages contains message, "Could not send email invitation"
mock_warning.assert_called_once_with(response.wsgi_request, "Could not send email invitation.")
# assert that portfolio invitation is not created
self.assertFalse(
PortfolioInvitation.objects.filter(email=self.new_member_email, portfolio=self.portfolio).exists(),
"Portfolio invitation should not be created when an Exception occurs.",
)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
def test_member_invite_for_previously_invited_member(self, mock_send_email):
"""Tests the member invitation flow for existing portfolio member."""
self.client.force_login(self.user)
@ -2600,23 +3162,35 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
response = self.client.post(
reverse("new-member"),
{
"member_access_level": "basic",
"basic_org_domain_request_permissions": "view_only",
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.invited_member_email,
},
)
self.assertEqual(response.status_code, 302) # Redirects
self.assertEqual(response.status_code, 200)
# TODO: verify messages
# verify messages
self.assertContains(
response,
(
"This user is already assigned to a portfolio invitation. "
"Based on current waffle flag settings, users cannot be assigned "
"to multiple portfolios."
),
)
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)
# assert that send_portfolio_invitation_email is not called
mock_send_email.assert_not_called()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_invite_for_existing_member(self):
@patch("registrar.views.portfolios.send_portfolio_invitation_email")
def test_member_invite_for_existing_member(self, mock_send_email):
"""Tests the member invitation flow for existing portfolio member."""
self.client.force_login(self.user)
@ -2630,19 +3204,30 @@ class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
response = self.client.post(
reverse("new-member"),
{
"member_access_level": "basic",
"basic_org_domain_request_permissions": "view_only",
"role": UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value,
"domain_request_permission_member": UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
"email": self.user.email,
},
)
self.assertEqual(response.status_code, 302) # Redirects
self.assertEqual(response.status_code, 200)
# TODO: verify messages
# Verify messages
self.assertContains(
response,
(
"This user is already assigned to a portfolio. "
"Based on current waffle flag settings, users cannot be "
"assigned to multiple portfolios."
),
)
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)
# assert that send_portfolio_invitation_email is not called
mock_send_email.assert_not_called()
class TestEditPortfolioMemberView(WebTest):
"""Tests for the edit member page on portfolios"""
@ -2783,7 +3368,13 @@ class TestEditPortfolioMemberView(WebTest):
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_admin_removing_own_admin_role(self):
"""Tests an admin removing their own admin role redirects to home."""
"""Tests an admin removing their own admin role redirects to home.
Removing the admin role will remove both view and edit members permissions.
Note: The user can remove the edit members permissions but as long as they
stay in admin role, they will at least still have view members permissions.
"""
self.client.force_login(self.user)
# Get the user's admin permission

View file

@ -417,7 +417,7 @@ class MemberExport(BaseExport):
# Adding a order_by increases output predictability.
# Doesn't matter as much for normal use, but makes tests easier.
# We should also just be ordering by default anyway.
members = permissions.union(invitations).order_by("email_display")
members = permissions.union(invitations).order_by("email_display", "member_display", "first_name", "last_name")
return convert_queryset_to_dict(members, is_model=False)
@classmethod
@ -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

Some files were not shown because too many files have changed in this diff Show more