diff --git a/src/registrar/admin.py b/src/registrar/admin.py index ca51e8b72..0b96b4c48 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -5,6 +5,11 @@ from django import forms from django.db.models import Value, CharField, Q from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect +from registrar.utility.admin_helpers import ( + get_action_needed_reason_default_email, + get_rejection_reason_default_email, + get_field_links_as_list, +) from django.conf import settings from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField @@ -20,11 +25,6 @@ from epplibwrapper.errors import ErrorCode, RegistryError from registrar.models.user_domain_role import UserDomainRole from waffle.admin import FlagAdmin from waffle.models import Sample, Switch -from registrar.utility.admin_helpers import ( - get_all_action_needed_reason_emails, - get_action_needed_reason_default_email, - get_field_links_as_list, -) from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial from registrar.utility.constants import BranchChoices from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes @@ -190,11 +190,11 @@ class PortfolioInvitationAdminForm(UserChangeForm): model = models.PortfolioInvitation fields = "__all__" widgets = { - "portfolio_roles": FilteredSelectMultipleArrayWidget( - "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices + "roles": FilteredSelectMultipleArrayWidget( + "roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices ), - "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( - "portfolio_additional_permissions", + "additional_permissions": FilteredSelectMultipleArrayWidget( + "additional_permissions", is_stacked=False, choices=UserPortfolioPermissionChoices.choices, ), @@ -237,6 +237,7 @@ class DomainRequestAdminForm(forms.ModelForm): } labels = { "action_needed_reason_email": "Email", + "rejection_reason_email": "Email", } def __init__(self, *args, **kwargs): @@ -1408,8 +1409,8 @@ class PortfolioInvitationAdmin(ListHeaderAdmin): list_display = [ "email", "portfolio", - "portfolio_roles", - "portfolio_additional_permissions", + "roles", + "additional_permissions", "status", ] @@ -1750,6 +1751,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "status_history", "status", "rejection_reason", + "rejection_reason_email", "action_needed_reason", "action_needed_reason_email", "investigator", @@ -1905,25 +1907,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Get the original domain request from the database. original_obj = models.DomainRequest.objects.get(pk=obj.pk) - # == Handle action_needed_reason == # - - reason_changed = obj.action_needed_reason != original_obj.action_needed_reason - if reason_changed: - # Track the fact that we sent out an email - request.session["action_needed_email_sent"] = True - - # Set the action_needed_reason_email to the default if nothing exists. - # Since this check occurs after save, if the user enters a value then we won't update. - - default_email = get_action_needed_reason_default_email(request, obj, obj.action_needed_reason) - if obj.action_needed_reason_email: - emails = get_all_action_needed_reason_emails(request, obj) - is_custom_email = obj.action_needed_reason_email not in emails.values() - if not is_custom_email: - obj.action_needed_reason_email = default_email - else: - obj.action_needed_reason_email = default_email + # == Handle action needed and rejected emails == # + # Edge case: this logic is handled by javascript, so contexts outside that must be handled + obj = self._handle_custom_emails(obj) + # == Handle allowed emails == # if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION: self._check_for_valid_email(request, obj) @@ -1939,6 +1927,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if should_save: return super().save_model(request, obj, form, change) + def _handle_custom_emails(self, obj): + if obj.status == DomainRequest.DomainRequestStatus.ACTION_NEEDED: + if obj.action_needed_reason and not obj.action_needed_reason_email: + obj.action_needed_reason_email = get_action_needed_reason_default_email(obj, obj.action_needed_reason) + elif obj.status == DomainRequest.DomainRequestStatus.REJECTED: + if obj.rejection_reason and not obj.rejection_reason_email: + obj.rejection_reason_email = get_rejection_reason_default_email(obj, obj.rejection_reason) + return obj + def _check_for_valid_email(self, request, obj): """Certain emails are whitelisted in non-production environments, so we should display that information using this function. @@ -1976,18 +1973,30 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # If the status is not mapped properly, saving could cause # weird issues down the line. Instead, we should block this. + # NEEDS A UNIT TEST should_proceed = False - return should_proceed + return (obj, should_proceed) - request_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED - if request_is_not_approved and not obj.domain_is_not_active(): - # If an admin tried to set an approved domain request to - # another status and the related domain is already - # active, shortcut the action and throw a friendly - # error message. This action would still not go through - # shortcut or not as the rules are duplicated on the model, - # but the error would be an ugly Django error screen. + obj_is_not_approved = obj.status != models.DomainRequest.DomainRequestStatus.APPROVED + if obj_is_not_approved and not obj.domain_is_not_active(): + # REDUNDANT CHECK / ERROR SCREEN AVOIDANCE: + # This action (moving a request from approved to + # another status) when the domain is already active (READY), + # would still not go through even without this check as the rules are + # duplicated in the model and the error is raised from the model. + # This avoids an ugly Django error screen. error_message = "This action is not permitted. The domain is already active." + elif ( + original_obj.status != models.DomainRequest.DomainRequestStatus.APPROVED + and obj.status == models.DomainRequest.DomainRequestStatus.APPROVED + and original_obj.requested_domain is not None + and Domain.objects.filter(name=original_obj.requested_domain.name).exists() + ): + # REDUNDANT CHECK: + # This action (approving a request when the domain exists) + # would still not go through even without this check as the rules are + # duplicated in the model and the error is raised from the model. + error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.APPROVE_DOMAIN_IN_USE) elif obj.status == models.DomainRequest.DomainRequestStatus.REJECTED and not obj.rejection_reason: # This condition should never be triggered. # The opposite of this condition is acceptable (rejected -> other status and rejection_reason) @@ -2464,7 +2473,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore def federal_agency(self, obj): - return obj.domain_info.federal_agency if obj.domain_info else None + if obj.domain_info: + return obj.domain_info.federal_agency + else: + return None federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 73f3dded1..fd50fbb0c 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -344,69 +344,6 @@ function initializeWidgetOnList(list, parentId) { } } -/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request - * status select and to show/hide the rejection reason -*/ -(function (){ - let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') - // This is the "action needed reason" field - let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason'); - // This is the "Email" field - let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email') - - if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) { - let statusSelect = document.getElementById('id_status') - let isRejected = statusSelect.value == "rejected" - let isActionNeeded = statusSelect.value == "action needed" - - // Initial handling of rejectionReasonFormGroup display - showOrHideObject(rejectionReasonFormGroup, show=isRejected) - showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) - showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded) - - // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage - statusSelect.addEventListener('change', function() { - // Show the rejection reason field if the status is rejected. - // Then track if its shown or hidden in our session cache. - isRejected = statusSelect.value == "rejected" - showOrHideObject(rejectionReasonFormGroup, show=isRejected) - addOrRemoveSessionBoolean("showRejectionReason", add=isRejected) - - isActionNeeded = statusSelect.value == "action needed" - showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded) - showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded) - addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded) - }); - - // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage - - // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the - // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide - // accurately for this edge case, we use cache and test for the back/forward navigation. - const observer = new PerformanceObserver((list) => { - list.getEntries().forEach((entry) => { - if (entry.type === "back_forward") { - let showRejectionReason = sessionStorage.getItem("showRejectionReason") !== null - showOrHideObject(rejectionReasonFormGroup, show=showRejectionReason) - - let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null - showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason) - showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded) - } - }); - }); - observer.observe({ type: "navigation" }); - } - - // Adds or removes the display-none class to object depending on the value of boolean show - function showOrHideObject(object, show){ - if (show){ - object.classList.remove("display-none"); - }else { - object.classList.add("display-none"); - } - } -})(); /** An IIFE for toggling the submit bar on domain request forms */ @@ -501,82 +438,110 @@ function initializeWidgetOnList(list, parentId) { })(); -/** An IIFE that hooks to the show/hide button underneath action needed reason. - * This shows the auto generated email on action needed reason. -*/ -document.addEventListener('DOMContentLoaded', function() { - const dropdown = document.getElementById("id_action_needed_reason"); - const textarea = document.getElementById("id_action_needed_reason_email") - const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null - const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder"); - const directEditButton = document.querySelector('.field-action_needed_reason_email__edit'); - const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger'); - const modalConfirm = document.getElementById('confirm-edit-email'); - const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]'); - let lastSentEmailContent = document.getElementById("last-sent-email-content"); - const initialDropdownValue = dropdown ? dropdown.value : null; - const initialEmailValue = textarea.value; +class CustomizableEmailBase { + + /** + * @param {Object} config - must contain the following: + * @property {HTMLElement} dropdown - The dropdown element. + * @property {HTMLElement} textarea - The textarea element. + * @property {HTMLElement} lastSentEmailContent - The last sent email content element. + * @property {HTMLElement} textAreaFormGroup - The form group for the textarea. + * @property {HTMLElement} dropdownFormGroup - The form group for the dropdown. + * @property {HTMLElement} modalConfirm - The confirm button in the modal. + * @property {string} apiUrl - The API URL for fetching email content. + * @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup. + * @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup. + * @property {string} apiErrorMessage - The error message that the ajax call returns. + */ + constructor(config) { + this.config = config; + this.dropdown = config.dropdown; + this.textarea = config.textarea; + this.lastSentEmailContent = config.lastSentEmailContent; + this.apiUrl = config.apiUrl; + this.apiErrorMessage = config.apiErrorMessage; + this.modalConfirm = config.modalConfirm; + + // These fields are hidden/shown on pageload depending on the current status + this.textAreaFormGroup = config.textAreaFormGroup; + this.dropdownFormGroup = config.dropdownFormGroup; + this.statusToCheck = config.statusToCheck; + this.sessionVariableName = config.sessionVariableName; + + // Non-configurable variables + this.statusSelect = document.getElementById("id_status"); + this.domainRequestId = this.dropdown ? document.getElementById("domain_request_id").value : null + this.initialDropdownValue = this.dropdown ? this.dropdown.value : null; + this.initialEmailValue = this.textarea ? this.textarea.value : null; + + // Find other fields near the textarea + const parentDiv = this.textarea ? this.textarea.closest(".flex-container") : null; + this.directEditButton = parentDiv ? parentDiv.querySelector(".edit-email-button") : null; + this.modalTrigger = parentDiv ? parentDiv.querySelector(".edit-button-modal-trigger") : null; + + this.textareaPlaceholder = parentDiv ? parentDiv.querySelector(".custom-email-placeholder") : null; + this.formLabel = this.textarea ? document.querySelector(`label[for="${this.textarea.id}"]`) : null; + + this.isEmailAlreadySentConst; + if (this.lastSentEmailContent && this.textarea) { + this.isEmailAlreadySentConst = this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, ''); + } - // We will use the const to control the modal - let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); - // We will use the function to control the label and help - function isEmailAlreadySent() { - return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, ''); } - if (!dropdown || !textarea || !domainRequestId || !formLabel || !modalConfirm) return; - const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value; + // Handle showing/hiding the related fields on page load. + initializeFormGroups() { + let isStatus = this.statusSelect.value == this.statusToCheck; - function updateUserInterface(reason) { - if (!reason) { - // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text - formLabel.innerHTML = "Email:"; - textareaPlaceholder.innerHTML = "Select an action needed reason to see email"; - showElement(textareaPlaceholder); - hideElement(directEditButton); - hideElement(modalTrigger); - hideElement(textarea); - } else if (reason === 'other') { - // 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text - formLabel.innerHTML = "Email:"; - textareaPlaceholder.innerHTML = "No email will be sent"; - showElement(textareaPlaceholder); - hideElement(directEditButton); - hideElement(modalTrigger); - hideElement(textarea); - } else { - // A triggering selection is selected, all hands on board: - textarea.setAttribute('readonly', true); - showElement(textarea); - hideElement(textareaPlaceholder); + // Initial handling of these groups. + this.updateFormGroupVisibility(isStatus); - if (isEmailAlreadySentConst) { - hideElement(directEditButton); - showElement(modalTrigger); - } else { - showElement(directEditButton); - hideElement(modalTrigger); - } - if (isEmailAlreadySent()) { - formLabel.innerHTML = "Email sent to creator:"; - } else { - formLabel.innerHTML = "Email:"; - } + // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage + this.statusSelect.addEventListener('change', () => { + // Show the action needed field if the status is what we expect. + // Then track if its shown or hidden in our session cache. + isStatus = this.statusSelect.value == this.statusToCheck; + this.updateFormGroupVisibility(isStatus); + addOrRemoveSessionBoolean(this.sessionVariableName, isStatus); + }); + + // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage + // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the + // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide + // accurately for this edge case, we use cache and test for the back/forward navigation. + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.type === "back_forward") { + let showTextAreaFormGroup = sessionStorage.getItem(this.sessionVariableName) !== null; + this.updateFormGroupVisibility(showTextAreaFormGroup); + } + }); + }); + observer.observe({ type: "navigation" }); + } + + updateFormGroupVisibility(showFormGroups) { + if (showFormGroups) { + showElement(this.textAreaFormGroup); + showElement(this.dropdownFormGroup); + }else { + hideElement(this.textAreaFormGroup); + hideElement(this.dropdownFormGroup); } } - // Initialize UI - updateUserInterface(dropdown.value); - - dropdown.addEventListener("change", function() { - const reason = dropdown.value; - // Update the UI - updateUserInterface(reason); - if (reason && reason !== "other") { - // If it's not the initial value - if (initialDropdownValue !== dropdown.value || initialEmailValue !== textarea.value) { + initializeDropdown() { + this.dropdown.addEventListener("change", () => { + let reason = this.dropdown.value; + if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) { + let searchParams = new URLSearchParams( + { + "reason": reason, + "domain_request_id": this.domainRequestId, + } + ); // Replace the email content - fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`) + fetch(`${this.apiUrl}?${searchParams.toString()}`) .then(response => { return response.json().then(data => data); }) @@ -584,30 +549,213 @@ document.addEventListener('DOMContentLoaded', function() { if (data.error) { console.error("Error in AJAX call: " + data.error); }else { - textarea.value = data.action_needed_email; + this.textarea.value = data.email; } - updateUserInterface(reason); + this.updateUserInterface(reason); }) .catch(error => { - console.error("Error action needed email: ", error) + console.error(this.apiErrorMessage, error) }); } + }); + } + + initializeModalConfirm() { + this.modalConfirm.addEventListener("click", () => { + this.textarea.removeAttribute('readonly'); + this.textarea.focus(); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); + }); + } + + initializeDirectEditButton() { + this.directEditButton.addEventListener("click", () => { + this.textarea.removeAttribute('readonly'); + this.textarea.focus(); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); + }); + } + + isEmailAlreadySent() { + return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, ''); + } + + updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) { + if (!reason) { + // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text + this.showPlaceholderNoReason(); + } else if (excluded_reasons.includes(reason)) { + // 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text + this.showPlaceholderOtherReason(); + } else { + this.showReadonlyTextarea(); + } + } + + // Helper function that makes overriding the readonly textarea easy + showReadonlyTextarea() { + // A triggering selection is selected, all hands on board: + this.textarea.setAttribute('readonly', true); + showElement(this.textarea); + hideElement(this.textareaPlaceholder); + + if (this.isEmailAlreadySentConst) { + hideElement(this.directEditButton); + showElement(this.modalTrigger); + } else { + showElement(this.directEditButton); + hideElement(this.modalTrigger); } - }); + if (this.isEmailAlreadySent()) { + this.formLabel.innerHTML = "Email sent to creator:"; + } else { + this.formLabel.innerHTML = "Email:"; + } + } - modalConfirm.addEventListener("click", () => { - textarea.removeAttribute('readonly'); - textarea.focus(); - hideElement(directEditButton); - hideElement(modalTrigger); - }); - directEditButton.addEventListener("click", () => { - textarea.removeAttribute('readonly'); - textarea.focus(); - hideElement(directEditButton); - hideElement(modalTrigger); - }); + // Helper function that makes overriding the placeholder reason easy + showPlaceholderNoReason() { + this.showPlaceholder("Email:", "Select a reason to see email"); + } + + // Helper function that makes overriding the placeholder reason easy + showPlaceholderOtherReason() { + this.showPlaceholder("Email:", "No email will be sent"); + } + + showPlaceholder(formLabelText, placeholderText) { + this.formLabel.innerHTML = formLabelText; + this.textareaPlaceholder.innerHTML = placeholderText; + showElement(this.textareaPlaceholder); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); + hideElement(this.textarea); + } +} + + + +class customActionNeededEmail extends CustomizableEmailBase { + constructor() { + const emailConfig = { + dropdown: document.getElementById("id_action_needed_reason"), + textarea: document.getElementById("id_action_needed_reason_email"), + lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"), + modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"), + apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null, + textAreaFormGroup: document.querySelector('.field-action_needed_reason'), + dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'), + statusToCheck: "action needed", + sessionVariableName: "showActionNeededReason", + apiErrorMessage: "Error when attempting to grab action needed email: " + } + super(emailConfig); + } + + loadActionNeededEmail() { + // Hide/show the email fields depending on the current status + this.initializeFormGroups(); + // Setup the textarea, edit button, helper text + this.updateUserInterface(); + this.initializeDropdown(); + this.initializeModalConfirm(); + this.initializeDirectEditButton(); + } + + // Overrides the placeholder text when no reason is selected + showPlaceholderNoReason() { + this.showPlaceholder("Email:", "Select an action needed reason to see email"); + } + + // Overrides the placeholder text when the reason other is selected + showPlaceholderOtherReason() { + this.showPlaceholder("Email:", "No email will be sent"); + } +} + +/** An IIFE that hooks to the show/hide button underneath action needed reason. + * This shows the auto generated email on action needed reason. +*/ +document.addEventListener('DOMContentLoaded', function() { + const domainRequestForm = document.getElementById("domainrequest_form"); + if (!domainRequestForm) { + return; + } + + // Initialize UI + const customEmail = new customActionNeededEmail(); + + // Check that every variable was setup correctly + const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); + if (nullItems.length > 0) { + console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`) + return; + } + customEmail.loadActionNeededEmail() +}); + + +class customRejectedEmail extends CustomizableEmailBase { + constructor() { + const emailConfig = { + dropdown: document.getElementById("id_rejection_reason"), + textarea: document.getElementById("id_rejection_reason_email"), + lastSentEmailContent: document.getElementById("last-sent-rejection-email-content"), + modalConfirm: document.getElementById("rejection-reason__confirm-edit-email"), + apiUrl: document.getElementById("get-rejection-email-for-user-json")?.value || null, + textAreaFormGroup: document.querySelector('.field-rejection_reason'), + dropdownFormGroup: document.querySelector('.field-rejection_reason_email'), + statusToCheck: "rejected", + sessionVariableName: "showRejectionReason", + errorMessage: "Error when attempting to grab rejected email: " + }; + super(emailConfig); + } + + loadRejectedEmail() { + this.initializeFormGroups(); + this.updateUserInterface(); + this.initializeDropdown(); + this.initializeModalConfirm(); + this.initializeDirectEditButton(); + } + + // Overrides the placeholder text when no reason is selected + showPlaceholderNoReason() { + this.showPlaceholder("Email:", "Select a rejection reason to see email"); + } + + updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) { + super.updateUserInterface(reason, excluded_reasons); + } + // Overrides the placeholder text when the reason other is selected + // showPlaceholderOtherReason() { + // this.showPlaceholder("Email:", "No email will be sent"); + // } +} + + +/** An IIFE that hooks to the show/hide button underneath rejected reason. + * This shows the auto generated email on action needed reason. +*/ +document.addEventListener('DOMContentLoaded', function() { + const domainRequestForm = document.getElementById("domainrequest_form"); + if (!domainRequestForm) { + return; + } + + // Initialize UI + const customEmail = new customRejectedEmail(); + // Check that every variable was setup correctly + const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); + if (nullItems.length > 0) { + console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`) + return; + } + customEmail.loadRejectedEmail() }); @@ -706,18 +854,6 @@ document.addEventListener('DOMContentLoaded', function() { } return ''; } - // Extract the submitter name, title, email, and phone number - const submitterDiv = document.querySelector('.form-row.field-submitter'); - const submitterNameElement = document.getElementById('id_submitter'); - // We have to account for different superuser and analyst markups - const submitterName = submitterNameElement - ? submitterNameElement.options[submitterNameElement.selectedIndex].text - : submitterDiv.querySelector('a').text; - const submitterTitle = extractTextById('contact_info_title', submitterDiv); - const submitterEmail = extractTextById('contact_info_email', submitterDiv); - const submitterPhone = extractTextById('contact_info_phone', submitterDiv); - let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`; - //------ Senior Official const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); @@ -734,7 +870,6 @@ document.addEventListener('DOMContentLoaded', function() { `Current Websites: ${existingWebsites.join(', ')}
` + `Rationale:
` + `Alternative Domains: ${alternativeDomains.join(', ')}
` + - `Submitter: ${submitterInfo}
` + `Senior Official: ${seniorOfficialInfo}
` + `Other Employees: ${otherContactsSummary}
`; diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 8a07b3f27..8281aa50a 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1880,11 +1880,10 @@ class MembersTable extends LoadTableBase { * @param {*} sortBy - the sort column option * @param {*} order - the sort order {asc, desc} * @param {*} scroll - control for the scrollToElement functionality - * @param {*} status - control for the status filter * @param {*} searchTerm - the search term * @param {*} portfolio - the portfolio id */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { // --------- SEARCH let searchParams = new URLSearchParams( @@ -1892,7 +1891,6 @@ class MembersTable extends LoadTableBase { "page": page, "sort_by": sortBy, "order": order, - "status": status, "search_term": searchTerm } ); @@ -1928,11 +1926,40 @@ class MembersTable extends LoadTableBase { const memberList = document.querySelector('.members__table tbody'); memberList.innerHTML = ''; + const invited = 'Invited'; + data.members.forEach(member => { - // const actionUrl = domain.action_url; const member_name = member.name; - const member_email = member.email; - const last_active = member.last_active; + const member_display = member.member_display; + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + + // Handle last_active values + let last_active = member.last_active; + let last_active_formatted = ''; + let last_active_sort_value = ''; + + // Handle 'Invited' or null/empty values differently from valid dates + if (last_active && last_active !== invited) { + try { + // Try to parse the last_active as a valid date + last_active = new Date(last_active); + if (!isNaN(last_active)) { + last_active_formatted = last_active.toLocaleDateString('en-US', options); + last_active_sort_value = last_active.getTime(); // For sorting purposes + } else { + last_active_formatted='Invalid date' + } + } catch (e) { + console.error(`Error parsing date: ${last_active}. Error: ${e}`); + last_active_formatted='Invalid date' + } + } else { + // Handle 'Invited' or null + last_active = invited; + last_active_formatted = invited; + last_active_sort_value = invited; // Keep 'Invited' as a sortable string + } + const action_url = member.action_url; const action_label = member.action_label; const svg_icon = member.svg_icon; @@ -1945,10 +1972,10 @@ class MembersTable extends LoadTableBase { row.innerHTML = ` - ${member_email ? member_email : member_name} ${admin_tagHTML} + ${member_display} ${admin_tagHTML} - - ${last_active} + + ${last_active_formatted} diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 5cea72c4c..b6bc0d296 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -385,6 +385,7 @@ a.button, font-kerning: auto; font-family: inherit; font-weight: normal; + text-decoration: none !important; } .button svg, .button span, @@ -392,6 +393,9 @@ a.button, .usa-button--dja span { vertical-align: middle; } +.usa-button--dja.usa-button--unstyled { + color: var(--link-fg); +} .usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) { background: var(--button-bg); } @@ -421,11 +425,34 @@ input[type=submit].button--dja-toolbar { input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover { border-color: var(--body-quiet-color); } -// Targets the DJA buttom with a nested icon -button .usa-icon, -.button .usa-icon, -.button--clipboard .usa-icon { - vertical-align: middle; +.admin-icon-group { + position: relative; + display: inline; + align-items: center; + + input { + // Allow for padding around the copy button + padding-right: 35px !important; + } + + button { + width: max-content; + } + + @media (max-width: 1000px) { + button { + display: block; + } + } + + span { + padding-left: 0.05rem; + } + +} +.usa-button__small-text, +.usa-button__small-text span { + font-size: 13px; } .module--custom { @@ -673,71 +700,10 @@ address.dja-address-contact-list { } } -// Make the clipboard button "float" inside of the input box -.admin-icon-group { - position: relative; - display: inline; - align-items: center; - - input { - // Allow for padding around the copy button - padding-right: 35px !important; - // Match the height of other inputs - min-height: 2.25rem !important; - } - - button { - line-height: 14px; - width: max-content; - font-size: unset; - text-decoration: none !important; - } - - @media (max-width: 1000px) { - button { - display: block; - padding-top: 8px; - } - } - - span { - padding-left: 0.1rem; - } - -} - -.admin-icon-group.admin-icon-group__clipboard-link { - position: relative; - display: inline; - align-items: center; - - - .usa-button--icon { - position: absolute; - right: auto; - left: 4px; - height: 100%; - top: -1px; - } - button { - font-size: unset !important; - display: inline-flex; - padding-top: 4px; - line-height: 14px; - width: max-content; - font-size: unset; - text-decoration: none !important; - } -} - .no-outline-on-click:focus { outline: none !important; } -.usa-button__small-text { - font-size: small; -} - // Get rid of padding on all help texts form .aligned p.help, form .aligned div.help { padding-left: 0px !important; @@ -887,6 +853,9 @@ div.dja__model-description{ padding-top: 0 !important; } +.padding-bottom-0 { + padding-bottom: 0 !important; +} .flex-container { @media screen and (min-width: 700px) and (max-width: 1150px) { diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index 2e4469e12..ff5ffb386 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -254,6 +254,7 @@ a .usa-icon, // Note: Can be simplified by adding text-secondary to delete anchors in tables button.text-secondary, button.text-secondary:hover, -.dotgov-table a.text-secondary { +a.text-secondary, +a.text-secondary:hover { color: $theme-color-error; } diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 436ca3ae0..ee923aac6 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -31,9 +31,10 @@ from registrar.views.utility.api_views import ( get_senior_official_from_federal_agency_json, get_federal_and_portfolio_types_from_federal_agency_json, get_action_needed_email_for_user_json, + get_rejection_email_for_user_json, ) -from registrar.views.domain_request import Step +from registrar.views.domain_request import Step, PortfolioDomainRequestStep from registrar.views.transfer_user import TransferUserView from registrar.views.utility import always_404 from api.views import available, rdap, get_current_federal, get_current_full @@ -61,6 +62,9 @@ for step, view in [ (Step.ADDITIONAL_DETAILS, views.AdditionalDetails), (Step.REQUIREMENTS, views.Requirements), (Step.REVIEW, views.Review), + # Portfolio steps + (PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity), + (PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails), ]: domain_request_urls.append(path(f"{step}/", view.as_view(), name=step)) @@ -82,6 +86,26 @@ urlpatterns = [ views.PortfolioMembersView.as_view(), name="members", ), + path( + "member/", + views.PortfolioMemberView.as_view(), + name="member", + ), + path( + "member//permissions", + views.PortfolioMemberEditView.as_view(), + name="member-permissions", + ), + path( + "invitedmember/", + views.PortfolioInvitedMemberView.as_view(), + name="invitedmember", + ), + path( + "invitedmember//permissions", + views.PortfolioInvitedMemberEditView.as_view(), + name="invitedmember-permissions", + ), # path( # "no-organization-members/", # views.PortfolioNoMembersView.as_view(), @@ -172,6 +196,11 @@ urlpatterns = [ get_action_needed_email_for_user_json, name="get-action-needed-email-for-user-json", ), + path( + "admin/api/get-rejection-email-for-user-json/", + get_rejection_email_for_user_json, + name="get-rejection-email-for-user-json", + ), path("admin/", admin.site.urls), path( "reports/export_data_type_user/", @@ -184,7 +213,12 @@ urlpatterns = [ name="export_data_type_requests", ), path( - "domain-request//edit/", + "reports/export_data_type_requests/", + ExportDataTypeRequests.as_view(), + name="export_data_type_requests", + ), + path( + "domain-request//edit/", views.DomainRequestWizard.as_view(), name=views.DomainRequestWizard.EDIT_URL_NAME, ), diff --git a/src/registrar/fixtures/fixtures_user_portfolio_permissions.py b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py index 6b6e137cd..22d6a0c0e 100644 --- a/src/registrar/fixtures/fixtures_user_portfolio_permissions.py +++ b/src/registrar/fixtures/fixtures_user_portfolio_permissions.py @@ -8,7 +8,7 @@ from registrar.fixtures.fixtures_users import UserFixture from registrar.models import User from registrar.models.portfolio import Portfolio from registrar.models.user_portfolio_permission import UserPortfolioPermission -from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices fake = Faker() logger = logging.getLogger(__name__) @@ -60,6 +60,7 @@ class UserPortfolioPermissionFixture: user=user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS], ) user_portfolio_permissions_to_create.append(user_portfolio_permission) else: diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py index 033e955ed..121e2b3f7 100644 --- a/src/registrar/forms/__init__.py +++ b/src/registrar/forms/__init__.py @@ -13,4 +13,5 @@ from .domain import ( ) from .portfolio import ( PortfolioOrgAddressForm, + PortfolioMemberForm, ) diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 84fcbe973..b43d91a58 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -4,7 +4,7 @@ import logging from django import forms from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator from django.forms import formset_factory -from registrar.models import DomainRequest +from registrar.models import DomainRequest, FederalAgency from phonenumber_field.widgets import RegionalPhoneNumberWidget from registrar.models.suborganization import Suborganization from registrar.models.utility.domain_helper import DomainHelper @@ -35,7 +35,10 @@ class DomainAddUserForm(forms.Form): email = forms.EmailField( label="Email", max_length=None, - error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")}, + 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, @@ -285,7 +288,7 @@ class UserForm(forms.ModelForm): "required": "Enter your title or role in your organization (e.g., Chief Information Officer)" } self.fields["email"].error_messages = { - "required": "Enter your email address in the required format, like name@example.com." + "required": "Enter an email address in the required format, like name@example.com." } self.fields["phone"].error_messages["required"] = "Enter your phone number." self.domainInfo = None @@ -342,7 +345,7 @@ class ContactForm(forms.ModelForm): "required": "Enter your title or role in your organization (e.g., Chief Information Officer)" } self.fields["email"].error_messages = { - "required": "Enter your email address in the required format, like name@example.com." + "required": "Enter an email address in the required format, like name@example.com." } self.fields["phone"].error_messages["required"] = "Enter your phone number." self.domainInfo = None @@ -458,9 +461,12 @@ class DomainOrgNameAddressForm(forms.ModelForm): validators=[ RegexValidator( "^[0-9]{5}(?:-[0-9]{4})?$|^$", - message="Enter a zip code in the required format, like 12345 or 12345-6789.", + message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.", ) ], + error_messages={ + "required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.", + }, ) class Meta: @@ -529,17 +535,25 @@ class DomainOrgNameAddressForm(forms.ModelForm): def save(self, commit=True): """Override the save() method of the BaseModelForm.""" + if self.has_changed(): # This action should be blocked by the UI, as the text fields are readonly. # If they get past this point, we forbid it this way. # This could be malicious, so lets reserve information for the backend only. - if self.is_federal and not self._field_unchanged("federal_agency"): - raise ValueError("federal_agency cannot be modified when the generic_org_type is federal") + + if self.is_federal: + if not self._field_unchanged("federal_agency"): + raise ValueError("federal_agency cannot be modified when the generic_org_type is federal") + elif self.is_tribal and not self._field_unchanged("organization_name"): raise ValueError("organization_name cannot be modified when the generic_org_type is tribal") - super().save() + else: # If this error that means Non-Federal Agency is missing + non_federal_agency_instance = FederalAgency.get_non_federal_agency() + self.instance.federal_agency = non_federal_agency_instance + + return super().save(commit=commit) def _field_unchanged(self, field_name) -> bool: """ diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index f2fdd32bc..f8ac85140 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -21,6 +21,13 @@ from registrar.utility.constants import BranchChoices logger = logging.getLogger(__name__) +class RequestingEntityForm(RegistrarForm): + organization_name = forms.CharField( + label="Organization name", + error_messages={"required": "Enter the name of your organization."}, + ) + + class OrganizationTypeForm(RegistrarForm): generic_org_type = forms.ChoiceField( # use the long names in the domain request form @@ -137,10 +144,10 @@ class OrganizationContactForm(RegistrarForm): validators=[ RegexValidator( "^[0-9]{5}(?:-[0-9]{4})?$|^$", - message="Enter a zip code in the form of 12345 or 12345-6789.", + message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.", ) ], - error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")}, + error_messages={"required": ("Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.")}, ) urbanization = forms.CharField( required=False, @@ -226,7 +233,10 @@ class SeniorOfficialForm(RegistrarForm): email = forms.EmailField( label="Email", max_length=None, - error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")}, + 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, @@ -603,7 +613,8 @@ class CisaRepresentativeForm(BaseDeletableRegistrarForm): max_length=None, required=False, error_messages={ - "invalid": ("Enter your representative’s email address in the required format, like name@example.com."), + "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( diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 14a45f6ae..7c8d2f171 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -4,7 +4,14 @@ import logging from django import forms from django.core.validators import RegexValidator -from ..models import DomainInformation, Portfolio, SeniorOfficial +from registrar.models import ( + PortfolioInvitation, + UserPortfolioPermission, + DomainInformation, + Portfolio, + SeniorOfficial, +) +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices logger = logging.getLogger(__name__) @@ -17,9 +24,12 @@ class PortfolioOrgAddressForm(forms.ModelForm): validators=[ RegexValidator( "^[0-9]{5}(?:-[0-9]{4})?$|^$", - message="Enter a zip code in the required format, like 12345 or 12345-6789.", + message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.", ) ], + error_messages={ + "required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.", + }, ) class Meta: @@ -38,6 +48,7 @@ class PortfolioOrgAddressForm(forms.ModelForm): "state_territory": { "required": "Select the state, territory, or military post where your organization is located." }, + "zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."}, } widgets = { # We need to set the required attributed for State/territory @@ -95,3 +106,57 @@ class PortfolioSeniorOfficialForm(forms.ModelForm): cleaned_data = super().clean() cleaned_data.pop("full_name", None) 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", + ] diff --git a/src/registrar/forms/user_profile.py b/src/registrar/forms/user_profile.py index 2a6ed4a47..8d8e2973d 100644 --- a/src/registrar/forms/user_profile.py +++ b/src/registrar/forms/user_profile.py @@ -58,7 +58,7 @@ class UserProfileForm(forms.ModelForm): "required": "Enter your title or role in your organization (e.g., Chief Information Officer)" } self.fields["email"].error_messages = { - "required": "Enter your email address in the required format, like name@example.com." + "required": "Enter an email address in the required format, like name@example.com." } self.fields["phone"].error_messages["required"] = "Enter your phone number." diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index ba3c37e1e..eedf5839b 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -279,11 +279,11 @@ class BaseYesNoForm(RegistrarForm): return initial_value -def request_step_list(request_wizard): +def request_step_list(request_wizard, step_enum): """Dynamically generated list of steps in the form wizard.""" step_list = [] - for step in request_wizard.StepEnum: - condition = request_wizard.WIZARD_CONDITIONS.get(step, True) + for step in step_enum: + condition = request_wizard.wizard_conditions.get(step, True) if callable(condition): condition = condition(request_wizard) if condition: diff --git a/src/registrar/migrations/0133_domainrequest_rejection_reason_email_and_more.py b/src/registrar/migrations/0133_domainrequest_rejection_reason_email_and_more.py new file mode 100644 index 000000000..383c3ebfa --- /dev/null +++ b/src/registrar/migrations/0133_domainrequest_rejection_reason_email_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.10 on 2024-10-08 18:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0132_alter_domaininformation_portfolio_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="domainrequest", + name="rejection_reason_email", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="rejection_reason", + field=models.TextField( + blank=True, + choices=[ + ("domain_purpose", "Purpose requirements not met"), + ("requestor_not_eligible", "Requestor not eligible to make request"), + ("org_has_domain", "Org already has a .gov domain"), + ("contacts_not_verified", "Org contacts couldn't be verified"), + ("org_not_eligible", "Org not eligible for a .gov domain"), + ("naming_requirements", "Naming requirements not met"), + ("other", "Other/Unspecified"), + ], + null=True, + ), + ), + ] diff --git a/src/registrar/migrations/0134_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py b/src/registrar/migrations/0134_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py new file mode 100644 index 000000000..9a24438df --- /dev/null +++ b/src/registrar/migrations/0134_rename_portfolio_additional_permissions_portfolioinvitation_additional_permissions_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-10-11 19:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0133_domainrequest_rejection_reason_email_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="portfolioinvitation", + old_name="portfolio_additional_permissions", + new_name="additional_permissions", + ), + migrations.RenameField( + model_name="portfolioinvitation", + old_name="portfolio_roles", + new_name="roles", + ), + ] diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 79bc223e9..b9e3315d5 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -254,18 +254,18 @@ class DomainRequest(TimeStampedModel): ) class RejectionReasons(models.TextChoices): - DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met" - REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request" - SECOND_DOMAIN_REASONING = ( + DOMAIN_PURPOSE = "domain_purpose", "Purpose requirements not met" + REQUESTOR_NOT_ELIGIBLE = "requestor_not_eligible", "Requestor not eligible to make request" + ORG_HAS_DOMAIN = ( "org_has_domain", "Org already has a .gov domain", ) - CONTACTS_OR_ORGANIZATION_LEGITIMACY = ( + CONTACTS_NOT_VERIFIED = ( "contacts_not_verified", "Org contacts couldn't be verified", ) - ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain" - NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met" + ORG_NOT_ELIGIBLE = "org_not_eligible", "Org not eligible for a .gov domain" + NAMING_REQUIREMENTS = "naming_requirements", "Naming requirements not met" OTHER = "other", "Other/Unspecified" @classmethod @@ -300,6 +300,11 @@ class DomainRequest(TimeStampedModel): blank=True, ) + rejection_reason_email = models.TextField( + null=True, + blank=True, + ) + action_needed_reason = models.TextField( choices=ActionNeededReasons.choices, null=True, @@ -635,15 +640,16 @@ class DomainRequest(TimeStampedModel): # Actually updates the organization_type field org_type_helper.create_or_update_organization_type() - def _cache_status_and_action_needed_reason(self): + def _cache_status_and_status_reasons(self): """Maintains a cache of properties so we can avoid a DB call""" self._cached_action_needed_reason = self.action_needed_reason + self._cached_rejection_reason = self.rejection_reason self._cached_status = self.status def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Store original values for caching purposes. Used to compare them on save. - self._cache_status_and_action_needed_reason() + self._cache_status_and_status_reasons() def save(self, *args, **kwargs): """Save override for custom properties""" @@ -655,23 +661,63 @@ class DomainRequest(TimeStampedModel): super().save(*args, **kwargs) - # Handle the action needed email. - # An email is sent out when action_needed_reason is changed or added. - if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED: - self.sync_action_needed_reason() + # Handle custom status emails. + # An email is sent out when a, for example, action_needed_reason is changed or added. + statuses_that_send_custom_emails = [self.DomainRequestStatus.ACTION_NEEDED, self.DomainRequestStatus.REJECTED] + if self.status in statuses_that_send_custom_emails: + self.send_custom_status_update_email(self.status) # Update the cached values after saving - self._cache_status_and_action_needed_reason() + self._cache_status_and_status_reasons() - def sync_action_needed_reason(self): - """Checks if we need to send another action needed email""" - was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED - reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None - reason_changed = self._cached_action_needed_reason != self.action_needed_reason - if was_already_action_needed and reason_exists and reason_changed: - # We don't send emails out in state "other" - if self.action_needed_reason != self.ActionNeededReasons.OTHER: - self._send_action_needed_reason_email(email_content=self.action_needed_reason_email) + 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.""" + + # Currently, we store all this information in three variables. + # When adding new reasons, this can be a lot to manage so we store it here + # in a centralized location. However, this may need to change if this scales. + status_information = { + self.DomainRequestStatus.ACTION_NEEDED: { + "cached_reason": self._cached_action_needed_reason, + "reason": self.action_needed_reason, + "email": self.action_needed_reason_email, + "excluded_reasons": [DomainRequest.ActionNeededReasons.OTHER], + "wrap_email": True, + }, + self.DomainRequestStatus.REJECTED: { + "cached_reason": self._cached_rejection_reason, + "reason": self.rejection_reason, + "email": self.rejection_reason_email, + "excluded_reasons": [], + # "excluded_reasons": [DomainRequest.RejectionReasons.OTHER], + "wrap_email": False, + }, + } + status_info = status_information.get(status) + + # Don't send an email if there is nothing to send. + if status_info.get("email") is None: + logger.warning("send_custom_status_update_email() => Tried sending an empty email.") + return + + # We should never send an email if no reason was specified. + # Additionally, Don't send out emails for reasons that shouldn't send them. + if status_info.get("reason") is None or status_info.get("reason") in status_info.get("excluded_reasons"): + logger.warning("send_custom_status_update_email() => Tried sending a status email without a reason.") + return + + # Only send out an email if the underlying reason itself changed or if no email was sent previously. + if status_info.get("cached_reason") != status_info.get("reason") or status_info.get("cached_reason") is None: + bcc_address = settings.DEFAULT_FROM_EMAIL if settings.IS_PRODUCTION else "" + self._send_status_update_email( + new_status=status, + email_template="emails/includes/custom_email.txt", + email_template_subject="emails/status_change_subject.txt", + bcc_address=bcc_address, + custom_email_content=status_info.get("email"), + wrap_email=status_information.get("wrap_email"), + ) def sync_yes_no_form_fields(self): """Some yes/no forms use a db field to track whether it was checked or not. @@ -901,7 +947,7 @@ class DomainRequest(TimeStampedModel): target=DomainRequestStatus.ACTION_NEEDED, conditions=[domain_is_not_active, investigator_exists_and_is_staff], ) - def action_needed(self, send_email=True): + def action_needed(self): """Send back an domain request that is under investigation or rejected. This action is logged. @@ -909,43 +955,23 @@ class DomainRequest(TimeStampedModel): This action cleans up the rejection status if moving away from rejected. As side effects this will delete the domain and domain_information - (will cascade) when they exist.""" + (will cascade) when they exist. + + Afterwards, we send out an email for action_needed in def save(). + See the function send_custom_status_update_email. + """ if self.status == self.DomainRequestStatus.APPROVED: - self.delete_and_clean_up_domain("reject_with_prejudice") + self.delete_and_clean_up_domain("action_needed") elif self.status == self.DomainRequestStatus.REJECTED: self.rejection_reason = None + # Check if the tuple is setup correctly, then grab its value. + literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED - # Check if the tuple is setup correctly, then grab its value action_needed = literal if literal is not None else "Action Needed" logger.info(f"A status change occurred. {self} was changed to '{action_needed}'") - # Send out an email if an action needed reason exists - if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER: - email_content = self.action_needed_reason_email - self._send_action_needed_reason_email(send_email, email_content) - - def _send_action_needed_reason_email(self, send_email=True, email_content=None): - """Sends out an automatic email for each valid action needed reason provided""" - - email_template_name = "custom_email.txt" - email_template_subject_name = f"{self.action_needed_reason}_subject.txt" - - bcc_address = "" - if settings.IS_PRODUCTION: - bcc_address = settings.DEFAULT_FROM_EMAIL - - self._send_status_update_email( - new_status="action needed", - email_template=f"emails/action_needed_reasons/{email_template_name}", - email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}", - send_email=send_email, - bcc_address=bcc_address, - custom_email_content=email_content, - wrap_email=True, - ) - @transition( field="status", source=[ @@ -1039,18 +1065,20 @@ class DomainRequest(TimeStampedModel): def reject(self): """Reject an domain request that has been submitted. + This action is logged. + + This action cleans up the action needed status if moving away from action needed. + As side effects this will delete the domain and domain_information - (will cascade), and send an email notification.""" + (will cascade) when they exist. + + Afterwards, we send out an email for reject in def save(). + See the function send_custom_status_update_email. + """ if self.status == self.DomainRequestStatus.APPROVED: self.delete_and_clean_up_domain("reject") - self._send_status_update_email( - "action needed", - "emails/status_change_rejected.txt", - "emails/status_change_rejected_subject.txt", - ) - @transition( field="status", source=[ diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 46d7bf124..b1f22ae83 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -4,6 +4,7 @@ import logging from django.contrib.auth import get_user_model from django.db import models from django_fsm import FSMField, transition +from registrar.models.domain_invitation import DomainInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore from .utility.time_stamped_model import TimeStampedModel @@ -38,7 +39,7 @@ class PortfolioInvitation(TimeStampedModel): related_name="portfolios", ) - portfolio_roles = ArrayField( + roles = ArrayField( models.CharField( max_length=50, choices=UserPortfolioRoleChoices.choices, @@ -48,7 +49,7 @@ class PortfolioInvitation(TimeStampedModel): help_text="Select one or more roles.", ) - portfolio_additional_permissions = ArrayField( + additional_permissions = ArrayField( models.CharField( max_length=50, choices=UserPortfolioPermissionChoices.choices, @@ -67,6 +68,31 @@ class PortfolioInvitation(TimeStampedModel): def __str__(self): return f"Invitation for {self.email} on {self.portfolio} is {self.status}" + def get_managed_domains_count(self): + """Return the count of domain invitations managed by the invited user for this portfolio.""" + # Filter the UserDomainRole model to get domains where the user has a manager role + managed_domains = DomainInvitation.objects.filter( + email=self.email, domain__domain_info__portfolio=self.portfolio + ).count() + return managed_domains + + def get_portfolio_permissions(self): + """ + Retrieve the permissions for the user's portfolio roles from the invite. + This is similar logic to _get_portfolio_permissions in user_portfolio_permission + """ + # Use a set to avoid duplicate permissions + portfolio_permissions = set() + + if self.roles: + for role in self.roles: + portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) + + if self.additional_permissions: + portfolio_permissions.update(self.additional_permissions) + + return list(portfolio_permissions) + @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) def retrieve(self): """When an invitation is retrieved, create the corresponding permission. @@ -88,8 +114,8 @@ class PortfolioInvitation(TimeStampedModel): user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create( portfolio=self.portfolio, user=user ) - if self.portfolio_roles and len(self.portfolio_roles) > 0: - user_portfolio_permission.roles = self.portfolio_roles - if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0: - user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions + if self.roles and len(self.roles) > 0: + user_portfolio_permission.roles = self.roles + if self.additional_permissions and len(self.additional_permissions) > 0: + user_portfolio_permission.additional_permissions = self.additional_permissions user_portfolio_permission.save() diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 241afd328..c95a3f26b 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -1,5 +1,6 @@ from django.db import models from django.forms import ValidationError +from registrar.models.user_domain_role import UserDomainRole from registrar.utility.waffle import flag_is_active_for_user from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .utility.time_stamped_model import TimeStampedModel @@ -79,6 +80,14 @@ class UserPortfolioPermission(TimeStampedModel): ) return readable_roles + def get_managed_domains_count(self): + """Return the count of domains managed by the user for this portfolio.""" + # Filter the UserDomainRole model to get domains where the user has a manager role + managed_domains = UserDomainRole.objects.filter( + user=self.user, role=UserDomainRole.Roles.MANAGER, domain__domain_info__portfolio=self.portfolio + ).count() + return managed_domains + def _get_portfolio_permissions(self): """ Retrieve the permissions for the user's portfolio roles. diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html index 198140c19..66011a3c4 100644 --- a/src/registrar/templates/admin/change_form_object_tools.html +++ b/src/registrar/templates/admin/change_form_object_tools.html @@ -20,10 +20,11 @@ {% if opts.model_name == 'domainrequest' %}
  • - + + {% translate "Copy request summary" %}
  • diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index 5ad2b27f7..d6a016fd5 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -8,7 +8,7 @@ Template for an input field with a clipboard
    {{ field }}
    {% else %} -