diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 485a1b07d..ac4a5c07f 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 @@ -237,6 +237,7 @@ class DomainRequestAdminForm(forms.ModelForm): } labels = { "action_needed_reason_email": "Email", + "rejection_reason_email": "Email", } def __init__(self, *args, **kwargs): @@ -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. diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index f44211c6d..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,86 +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; - let initialEmailValue; - if (textarea) - 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; - if (lastSentEmailContent) - 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); }) @@ -588,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() }); diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 3c9e185b5..4d1be6f31 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -31,6 +31,7 @@ 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, PortfolioDomainRequestStep @@ -175,6 +176,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/", 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/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/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index afdd9e6c2..8d58bc696 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -10,6 +10,8 @@ {% url 'get-action-needed-email-for-user-json' as url %} + {% url 'get-rejection-email-for-user-json' as url_2 %} + {% for fieldset in adminform %} {% comment %} TODO: this will eventually need to be changed to something like this diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 2369f235b..317604c5e 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -137,29 +137,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% block field_other %} {% if field.field.name == "action_needed_reason_email" %} + {{ field.field }} -
+ The creator of this request already received an email for this status/reason: +
++ If you edit this email's text, the system will send another email to + the creator after you “save” your changes. If you do not want to send another email, click “cancel” below. +
+