diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 52e214bb9..88c064379 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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 diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index a815a59a1..b3d14839e 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -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(); } } diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js index 0e5946c23..9a60e1684 100644 --- a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js +++ b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js @@ -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); + if (suborganizationStateTerritory) hideElement(suborganizationStateTerritory); + + // == LOGIC FOR THE DOMAIN REQUEST PAGE == // + if (rejectSuborganizationButton) hideElement(rejectSuborganizationButton); } } diff --git a/src/registrar/assets/src/js/getgov/helpers.js b/src/registrar/assets/src/js/getgov/helpers.js index 93c25ee45..7d1449bac 100644 --- a/src/registrar/assets/src/js/getgov/helpers.js +++ b/src/registrar/assets/src/js/getgov/helpers.js @@ -1,9 +1,17 @@ export function hideElement(element) { - element.classList.add('display-none'); + if (element) { + element.classList.add('display-none'); + } else { + throw new Error('hideElement expected a passed DOM element as an argument, but none was provided.'); + } }; export function showElement(element) { - element.classList.remove('display-none'); + if (element) { + element.classList.remove('display-none'); + } else { + throw new Error('showElement expected a passed DOM element as an argument, but none was provided.'); + } }; /** diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index 02d927438..cfb83badc 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -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' } ); } diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js index 97c256734..e1d5c11ce 100644 --- a/src/registrar/assets/src/js/getgov/table-base.js +++ b/src/registrar/assets/src/js/getgov/table-base.js @@ -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 = ''; diff --git a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js index 95492d46f..86aa39c37 100644 --- a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js @@ -1,5 +1,6 @@ import { BaseTable } from './table-base.js'; +import { hideElement, showElement } from './helpers.js'; /** * EditMemberDomainsTable is used for PortfolioMember and PortfolioInvitedMember @@ -18,8 +19,14 @@ export class EditMemberDomainsTable extends BaseTable { this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains which are readonly this.addedDomains = []; // list of domains added to member this.removedDomains = []; // list of domains removed from member + this.editModeContainer = document.getElementById('domain-assignments-edit-view'); + this.readonlyModeContainer = document.getElementById('domain-assignments-readonly-view'); + this.reviewButton = document.getElementById('review-domain-assignments'); + this.backButton = document.getElementById('back-to-edit-domain-assignments'); + this.saveButton = document.getElementById('save-domain-assignments'); this.initializeDomainAssignments(); this.initCancelEditDomainAssignmentButton(); + this.initEventListeners(); } getBaseUrl() { return document.getElementById("get_member_domains_json_url"); @@ -55,6 +62,14 @@ export class EditMemberDomainsTable extends BaseTable { getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { let searchParams = super.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); // Add checkedDomains to searchParams + let checkedDomains = this.getCheckedDomains(); + // Append updated checkedDomain IDs to searchParams + if (checkedDomains.length > 0) { + searchParams.append("checkedDomainIds", checkedDomains.join(",")); + } + return searchParams; + } + getCheckedDomains() { // Clone the initial domains to avoid mutating them let checkedDomains = [...this.initialDomainAssignments]; // Add IDs from addedDomains that are not already in checkedDomains @@ -70,11 +85,7 @@ export class EditMemberDomainsTable extends BaseTable { checkedDomains.splice(index, 1); } }); - // Append updated checkedDomain IDs to searchParams - if (checkedDomains.length > 0) { - searchParams.append("checkedDomainIds", checkedDomains.join(",")); - } - return searchParams; + return checkedDomains } addRow(dataObject, tbody, customTableOptions) { const domain = dataObject; @@ -217,7 +228,123 @@ export class EditMemberDomainsTable extends BaseTable { } }); } + + updateReadonlyDisplay() { + let totalAssignedDomains = this.getCheckedDomains().length; + + // Create unassigned domains list + const unassignedDomainsList = document.createElement('ul'); + unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled'); + this.removedDomains.forEach(removedDomain => { + const removedDomainListItem = document.createElement('li'); + removedDomainListItem.textContent = removedDomain.name; // Use textContent for security + unassignedDomainsList.appendChild(removedDomainListItem); + }); + + // Create assigned domains list + const assignedDomainsList = document.createElement('ul'); + assignedDomainsList.classList.add('usa-list', 'usa-list--unstyled'); + this.addedDomains.forEach(addedDomain => { + const addedDomainListItem = document.createElement('li'); + addedDomainListItem.textContent = addedDomain.name; // Use textContent for security + assignedDomainsList.appendChild(addedDomainListItem); + }); + + // Get the summary container + const domainAssignmentSummary = document.getElementById('domain-assignments-summary'); + // Clear existing content + domainAssignmentSummary.innerHTML = ''; + + // Append unassigned domains section + if (this.removedDomains.length) { + const unassignedHeader = document.createElement('h3'); + unassignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + unassignedHeader.textContent = 'Unassigned domains'; + domainAssignmentSummary.appendChild(unassignedHeader); + domainAssignmentSummary.appendChild(unassignedDomainsList); + } + + // Append assigned domains section + if (this.addedDomains.length) { + const assignedHeader = document.createElement('h3'); + assignedHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + assignedHeader.textContent = 'Assigned domains'; + domainAssignmentSummary.appendChild(assignedHeader); + domainAssignmentSummary.appendChild(assignedDomainsList); + } + + // Append total assigned domains section + const totalHeader = document.createElement('h3'); + totalHeader.classList.add('header--body', 'text-primary', 'margin-bottom-1'); + totalHeader.textContent = 'Total assigned domains'; + domainAssignmentSummary.appendChild(totalHeader); + const totalCount = document.createElement('p'); + totalCount.classList.add('margin-y-0'); + totalCount.textContent = totalAssignedDomains; + domainAssignmentSummary.appendChild(totalCount); + } + + showReadonlyMode() { + this.updateReadonlyDisplay(); + hideElement(this.editModeContainer); + showElement(this.readonlyModeContainer); + } + + showEditMode() { + hideElement(this.readonlyModeContainer); + showElement(this.editModeContainer); + } + + submitChanges() { + let memberDomainsEditForm = document.getElementById("member-domains-edit-form"); + if (memberDomainsEditForm) { + // Serialize data to send + const addedDomainIds = this.addedDomains.map(domain => domain.id); + const addedDomainsInput = document.createElement('input'); + addedDomainsInput.type = 'hidden'; + addedDomainsInput.name = 'added_domains'; // Backend will use this key to retrieve data + addedDomainsInput.value = JSON.stringify(addedDomainIds); // Stringify the array + + const removedDomainsIds = this.removedDomains.map(domain => domain.id); + const removedDomainsInput = document.createElement('input'); + removedDomainsInput.type = 'hidden'; + removedDomainsInput.name = 'removed_domains'; // Backend will use this key to retrieve data + removedDomainsInput.value = JSON.stringify(removedDomainsIds); // Stringify the array + + // Append input to the form + memberDomainsEditForm.appendChild(addedDomainsInput); + memberDomainsEditForm.appendChild(removedDomainsInput); + + memberDomainsEditForm.submit(); + } + } + + initEventListeners() { + if (this.reviewButton) { + this.reviewButton.addEventListener('click', () => { + this.showReadonlyMode(); + }); + } else { + console.warn('Missing DOM element. Expected element with id review-domain-assignments'); + } + + if (this.backButton) { + this.backButton.addEventListener('click', () => { + this.showEditMode(); + }); + } else { + console.warn('Missing DOM element. Expected element with id back-to-edit-domain-assignments'); + } + + if (this.saveButton) { + this.saveButton.addEventListener('click', () => { + this.submitChanges(); + }); + } else { + console.warn('Missing DOM element. Expected element with id save-domain-assignments'); + } + } } export function initEditMemberDomainsTable() { diff --git a/src/registrar/assets/src/js/getgov/table-member-domains.js b/src/registrar/assets/src/js/getgov/table-member-domains.js index 54e9d1212..7f89eee52 100644 --- a/src/registrar/assets/src/js/getgov/table-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-member-domains.js @@ -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() { diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss index df4f686d8..762618415 100644 --- a/src/registrar/assets/src/sass/_theme/_accordions.scss +++ b/src/registrar/assets/src/sass/_theme/_accordions.scss @@ -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; diff --git a/src/registrar/assets/src/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss index 58ce1e4df..5bb523cac 100644 --- a/src/registrar/assets/src/sass/_theme/_admin.scss +++ b/src/registrar/assets/src/sass/_theme/_admin.scss @@ -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, diff --git a/src/registrar/assets/src/sass/_theme/_alerts.scss b/src/registrar/assets/src/sass/_theme/_alerts.scss index 9579cc057..3164358b7 100644 --- a/src/registrar/assets/src/sass/_theme/_alerts.scss +++ b/src/registrar/assets/src/sass/_theme/_alerts.scss @@ -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; +} diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 62f9f436e..d73becd75 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -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; } @@ -265,4 +277,8 @@ abbr[title] { margin: 0; height: 1.5em; width: 1.5em; +} + +.maxw-fit-content { + max-width: fit-content; } \ No newline at end of file diff --git a/src/registrar/assets/src/sass/_theme/_containers.scss b/src/registrar/assets/src/sass/_theme/_containers.scss index 7473615ad..24ad480f2 100644 --- a/src/registrar/assets/src/sass/_theme/_containers.scss +++ b/src/registrar/assets/src/sass/_theme/_containers.scss @@ -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; +} \ No newline at end of file diff --git a/src/registrar/assets/src/sass/_theme/_forms.scss b/src/registrar/assets/src/sass/_theme/_forms.scss index 9158de174..4138c5878 100644 --- a/src/registrar/assets/src/sass/_theme/_forms.scss +++ b/src/registrar/assets/src/sass/_theme/_forms.scss @@ -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'); +} diff --git a/src/registrar/assets/src/sass/_theme/_header.scss b/src/registrar/assets/src/sass/_theme/_header.scss index 53eab90d8..ffb880a7b 100644 --- a/src/registrar/assets/src/sass/_theme/_header.scss +++ b/src/registrar/assets/src/sass/_theme/_header.scss @@ -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, diff --git a/src/registrar/assets/src/sass/_theme/_register-form.scss b/src/registrar/assets/src/sass/_theme/_register-form.scss index 41d2980e3..fcc5b5ae6 100644 --- a/src/registrar/assets/src/sass/_theme/_register-form.scss +++ b/src/registrar/assets/src/sass/_theme/_register-form.scss @@ -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; diff --git a/src/registrar/assets/src/sass/_theme/_typography.scss b/src/registrar/assets/src/sass/_theme/_typography.scss index 7c7639a83..db19a595b 100644 --- a/src/registrar/assets/src/sass/_theme/_typography.scss +++ b/src/registrar/assets/src/sass/_theme/_typography.scss @@ -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; diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 050950c9b..0111245a1 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -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", ], }, }, diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 66708c571..ef5f7f40a 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -146,7 +146,7 @@ urlpatterns = [ # ), path( "members/new-member/", - views.NewMemberView.as_view(), + views.PortfolioAddMemberView.as_view(), name="new-member", ), path( diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index 7230b04c6..b3d9c3727 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -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} diff --git a/src/registrar/fixtures/fixtures_user_portfolio_permissions.py b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py index 15265cfa8..5f9fd64ef 100644 --- a/src/registrar/fixtures/fixtures_user_portfolio_permissions.py +++ b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py @@ -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: diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 289b3da0b..3b4e3d2ab 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -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.") diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index eaa885a85..0a8c4d623 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -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
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):
- """
- 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
+class PortfolioMemberForm(BasePortfolioMemberForm):
+ """
+ Form for updating a portfolio member.
+ """
- Returns:
- instance: Updated instance
- """
- role = cleaned_data.get("role")
+ class Meta:
+ model = UserPortfolioPermission
+ fields = ["roles", "additional_permissions"]
- # Handle roles
- instance.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)}
+class PortfolioInvitedMemberForm(BasePortfolioMemberForm):
+ """
+ Form for updating a portfolio invited member.
+ """
- # Handle EDIT permissions (should be accompanied with a view permission)
- if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions:
- additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS)
+ class Meta:
+ model = PortfolioInvitation
+ fields = ["roles", "additional_permissions"]
- 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(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"]
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index 44d8511b0..3d3aac769 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -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.
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index 25abb6748..03a01b80d 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -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
diff --git a/src/registrar/templates/401.html b/src/registrar/templates/401.html
index d7c7f83ae..7698c4f82 100644
--- a/src/registrar/templates/401.html
+++ b/src/registrar/templates/401.html
@@ -5,8 +5,8 @@
{% block title %}{% translate "Unauthorized | " %}{% endblock %}
{% block content %}
-
{% translate "You are not authorized to view this page" %}
diff --git a/src/registrar/templates/403.html b/src/registrar/templates/403.html
index 999d5f98e..a04453fe9 100644
--- a/src/registrar/templates/403.html
+++ b/src/registrar/templates/403.html
@@ -5,8 +5,8 @@
{% block title %}{% translate "Forbidden | " %}{% endblock %}
{% block content %}
-
{% translate "You're not authorized to view this page." %}
diff --git a/src/registrar/templates/404.html b/src/registrar/templates/404.html
index 471575558..2bf9ecf02 100644
--- a/src/registrar/templates/404.html
+++ b/src/registrar/templates/404.html
@@ -5,8 +5,8 @@
{% block title %}{% translate "Page not found | " %}{% endblock %}
{% block content %}
-
{% translate "We couldn’t find that page" %}
diff --git a/src/registrar/templates/500.html b/src/registrar/templates/500.html
index a0663816b..fad909ddb 100644
--- a/src/registrar/templates/500.html
+++ b/src/registrar/templates/500.html
@@ -5,8 +5,8 @@
{% block title %}{% translate "Server error | " %}{% endblock %}
{% block content %}
-
{% translate "We're having some trouble." %}
diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html
index 49fb59e79..aaf3dc423 100644
--- a/src/registrar/templates/admin/app_list.html
+++ b/src/registrar/templates/admin/app_list.html
@@ -39,7 +39,7 @@
{% for model in app.models %}
{% if model.admin_url %}
- {{ model.name }}
+ {{ model.name }}
{% else %}
{{ model.name }}
{% endif %}
diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html
index 40cd98ca8..20b76217b 100644
--- a/src/registrar/templates/admin/fieldset.html
+++ b/src/registrar/templates/admin/fieldset.html
@@ -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 %}
-