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 }} -
+
- {{ field.field }} - Change Edit email
@@ -187,8 +186,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
  • {% if original_object.action_needed_reason_email %} - + {% else %} - + {% endif %} + {% elif field.field.name == "rejection_reason_email" %} + {{ field.field }} + +
    + – +
    + + + Change Edit email +
    +
    +
    +

    + Are you sure you want to edit this email? +

    +
    +

    + The creator of this request already received an email for this status/reason: +

    +
      +
    • Status: Rejected
    • +
    • Reason: {{ original_object.get_rejection_reason_display }}
    • +
    +

    + 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. +

    +
    + + +
    + +
    +
    + + {% if original_object.rejection_reason_email %} + + {% else %} + + {% endif %} {% else %} {{ field.field }} {% endif %} diff --git a/src/registrar/templates/emails/action_needed_reasons/custom_email.txt b/src/registrar/templates/emails/includes/custom_email.txt similarity index 100% rename from src/registrar/templates/emails/action_needed_reasons/custom_email.txt rename to src/registrar/templates/emails/includes/custom_email.txt diff --git a/src/registrar/templates/emails/status_change_rejected.txt b/src/registrar/templates/emails/status_change_rejected.txt index 4e5250162..b1d989bf1 100644 --- a/src/registrar/templates/emails/status_change_rejected.txt +++ b/src/registrar/templates/emails/status_change_rejected.txt @@ -8,8 +8,8 @@ REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} STATUS: Rejected ---------------------------------------------------------------- -{% if domain_request.rejection_reason != 'other' %} -REJECTION REASON{% endif %}{% if domain_request.rejection_reason == 'purpose_not_met' %} +{% if reason != domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %} +REJECTION REASON{% endif %}{% if reason == domain_request.RejectionReasons.DOMAIN_PURPOSE %} Your domain request was rejected because the purpose you provided did not meet our requirements. You didn’t provide enough information about how you intend to use the domain. @@ -18,7 +18,7 @@ Learn more about: - Eligibility for a .gov domain - What you can and can’t do with .gov domains -If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'requestor_not_eligible' %} +If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.REQUESTOR_NOT_ELIGIBLE %} Your domain request was rejected because we don’t believe you’re eligible to request a .gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be working on behalf of a government organization, to request a .gov domain. @@ -26,7 +26,7 @@ working on behalf of a government organization, to request a .gov domain. DEMONSTRATE ELIGIBILITY If you can provide more information that demonstrates your eligibility, or you want to -discuss further, reply to this email.{% elif domain_request.rejection_reason == 'org_has_domain' %} +discuss further, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_HAS_DOMAIN %} Your domain request was rejected because {{ domain_request.organization_name }} has a .gov domain. Our practice is to approve one domain per online service per government organization. We evaluate additional requests on a case-by-case basis. You did not provide sufficient @@ -35,9 +35,9 @@ justification for an additional domain. Read more about our practice of approving one domain per online service . -If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'contacts_not_verified' %} +If you have questions or comments, reply to this email.{% elif reason == 'contacts_not_verified' %} Your domain request was rejected because we could not verify the organizational -contacts you provided. If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'org_not_eligible' %} +contacts you provided. If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_NOT_ELIGIBLE %} Your domain request was rejected because we determined that {{ domain_request.organization_name }} is not eligible for a .gov domain. .Gov domains are only available to official U.S.-based government organizations. @@ -46,7 +46,7 @@ Learn more about eligibility for .gov domains . If you have questions or comments, reply to this email. -{% elif domain_request.rejection_reason == 'naming_not_met' %} +{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.NAMING_REQUIREMENTS %} Your domain request was rejected because it does not meet our naming requirements. Domains should uniquely identify a government organization and be clear to the general public. Learn more about naming requirements for your type of organization @@ -55,7 +55,7 @@ general public. Learn more about naming requirements for your type of organizati YOU CAN SUBMIT A NEW REQUEST We encourage you to request a domain that meets our requirements. If you have -questions or want to discuss potential domain names, reply to this email.{% elif domain_request.rejection_reason == 'other' %} +questions or want to discuss potential domain names, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %} YOU CAN SUBMIT A NEW REQUEST If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request. diff --git a/src/registrar/templates/emails/status_change_rejected_subject.txt b/src/registrar/templates/emails/status_change_subject.txt similarity index 100% rename from src/registrar/templates/emails/status_change_rejected_subject.txt rename to src/registrar/templates/emails/status_change_subject.txt diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index f19008ca1..217756359 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -595,7 +595,12 @@ class TestDomainRequestAdmin(MockEppLib): @less_console_noise_decorator def transition_state_and_send_email( - self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None + self, + domain_request, + status, + rejection_reason=None, + action_needed_reason=None, + action_needed_reason_email=None, ): """Helper method for the email test cases.""" @@ -687,6 +692,10 @@ class TestDomainRequestAdmin(MockEppLib): self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + # We use javascript to reset the content of this. It is only automatically set + # if the email itself is somehow None. + self._reset_action_needed_email(domain_request) + # Test the email sent out for bad_name bad_name = DomainRequest.ActionNeededReasons.BAD_NAME self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name) @@ -694,6 +703,7 @@ class TestDomainRequestAdmin(MockEppLib): "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + self._reset_action_needed_email(domain_request) # Test the email sent out for eligibility_unclear eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR @@ -702,6 +712,7 @@ class TestDomainRequestAdmin(MockEppLib): "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + self._reset_action_needed_email(domain_request) # Test that a custom email is sent out for questionable_so questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL @@ -710,6 +721,7 @@ class TestDomainRequestAdmin(MockEppLib): "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, _creator.email, bcc_email_address=BCC_EMAIL ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) + self._reset_action_needed_email(domain_request) # Assert that no other emails are sent on OTHER other = DomainRequest.ActionNeededReasons.OTHER @@ -717,6 +729,7 @@ class TestDomainRequestAdmin(MockEppLib): # Should be unchanged from before self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) + self._reset_action_needed_email(domain_request) # Tests if an analyst can override existing email content questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL @@ -730,6 +743,7 @@ class TestDomainRequestAdmin(MockEppLib): domain_request.refresh_from_db() self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL) self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) + self._reset_action_needed_email(domain_request) # Tests if a new email gets sent when just the email is changed. # An email should NOT be sent out if we just modify the email content. @@ -741,6 +755,7 @@ class TestDomainRequestAdmin(MockEppLib): ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) + self._reset_action_needed_email(domain_request) # Set the request back to in review domain_request.in_review() @@ -757,55 +772,53 @@ class TestDomainRequestAdmin(MockEppLib): ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 6) - # def test_action_needed_sends_reason_email_prod_bcc(self): - # """When an action needed reason is set, an email is sent out and help@get.gov - # is BCC'd in production""" - # # Ensure there is no user with this email - # EMAIL = "mayor@igorville.gov" - # BCC_EMAIL = settings.DEFAULT_FROM_EMAIL - # User.objects.filter(email=EMAIL).delete() - # in_review = DomainRequest.DomainRequestStatus.IN_REVIEW - # action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED + def _reset_action_needed_email(self, domain_request): + """Sets the given action needed email back to none""" + domain_request.action_needed_reason_email = None + domain_request.save() + domain_request.refresh_from_db() - # # Create a sample domain request - # domain_request = completed_domain_request(status=in_review) + @override_settings(IS_PRODUCTION=True) + @less_console_noise_decorator + def test_rejected_sends_reason_email_prod_bcc(self): + """When a rejection reason is set, an email is sent out and help@get.gov + is BCC'd in production""" + # Create fake creator + EMAIL = "meoward.jones@igorville.gov" - # # Test the email sent out for already_has_domains - # already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS - # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) - # self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) - # self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) + _creator = User.objects.create( + username="MrMeoward", + first_name="Meoward", + last_name="Jones", + email=EMAIL, + phone="(555) 123 12345", + title="Treat inspector", + ) - # # Test the email sent out for bad_name - # bad_name = DomainRequest.ActionNeededReasons.BAD_NAME - # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name) - # self.assert_email_is_accurate( - # "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL - # ) - # self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + BCC_EMAIL = settings.DEFAULT_FROM_EMAIL + in_review = DomainRequest.DomainRequestStatus.IN_REVIEW + rejected = DomainRequest.DomainRequestStatus.REJECTED - # # Test the email sent out for eligibility_unclear - # eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR - # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear) - # self.assert_email_is_accurate( - # "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL - # ) - # self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) + # Create a sample domain request + domain_request = completed_domain_request(status=in_review, user=_creator) - # # Test the email sent out for questionable_so - # questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL - # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so) - # self.assert_email_is_accurate( - # "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL - # ) - # self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) - - # # Assert that no other emails are sent on OTHER - # other = DomainRequest.ActionNeededReasons.OTHER - # self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other) - - # # Should be unchanged from before - # self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) + expected_emails = { + DomainRequest.RejectionReasons.DOMAIN_PURPOSE: "You didn’t provide enough information about how", + DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE: "You must be a government employee, or be", + DomainRequest.RejectionReasons.ORG_HAS_DOMAIN: "practice is to approve one domain", + DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED: "we could not verify the organizational", + DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE: ".Gov domains are only available to official U.S.-based", + DomainRequest.RejectionReasons.NAMING_REQUIREMENTS: "does not meet our naming requirements", + DomainRequest.RejectionReasons.OTHER: "YOU CAN SUBMIT A NEW REQUEST", + } + for i, (reason, email_content) in enumerate(expected_emails.items()): + with self.subTest(reason=reason): + self.transition_state_and_send_email(domain_request, status=rejected, rejection_reason=reason) + self.assert_email_is_accurate(email_content, i, EMAIL, bcc_email_address=BCC_EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), i + 1) + domain_request.rejection_reason_email = None + domain_request.save() + domain_request.refresh_from_db() @less_console_noise_decorator def test_save_model_sends_submitted_email(self): @@ -1034,7 +1047,9 @@ class TestDomainRequestAdmin(MockEppLib): # Reject for reason REQUESTOR and test email including dynamic organization name self.transition_state_and_send_email( - domain_request, DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.REQUESTOR + domain_request, + DomainRequest.DomainRequestStatus.REJECTED, + DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE, ) self.assert_email_is_accurate( "Your domain request was rejected because we don’t believe you’re eligible to request a \n.gov " @@ -1072,7 +1087,7 @@ class TestDomainRequestAdmin(MockEppLib): self.transition_state_and_send_email( domain_request, DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING, + DomainRequest.RejectionReasons.ORG_HAS_DOMAIN, ) self.assert_email_is_accurate( "Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email @@ -1108,7 +1123,7 @@ class TestDomainRequestAdmin(MockEppLib): self.transition_state_and_send_email( domain_request, DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, + DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED, ) self.assert_email_is_accurate( "Your domain request was rejected because we could not verify the organizational \n" @@ -1146,7 +1161,7 @@ class TestDomainRequestAdmin(MockEppLib): self.transition_state_and_send_email( domain_request, DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.ORGANIZATION_ELIGIBILITY, + DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE, ) self.assert_email_is_accurate( "Your domain request was rejected because we determined that Testorg is not \neligible for " @@ -1275,7 +1290,7 @@ class TestDomainRequestAdmin(MockEppLib): stack.enter_context(patch.object(messages, "error")) stack.enter_context(patch.object(messages, "warning")) domain_request.status = DomainRequest.DomainRequestStatus.REJECTED - domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY + domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED self.admin.save_model(request, domain_request, None, True) @@ -1621,6 +1636,7 @@ class TestDomainRequestAdmin(MockEppLib): "updated_at", "status", "rejection_reason", + "rejection_reason_email", "action_needed_reason", "action_needed_reason_email", "federal_agency", @@ -1840,7 +1856,7 @@ class TestDomainRequestAdmin(MockEppLib): self.trigger_saving_approved_to_another_state( False, DomainRequest.DomainRequestStatus.REJECTED, - DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, + DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED, ) def test_side_effects_when_saving_approved_to_ineligible(self): diff --git a/src/registrar/tests/test_api.py b/src/registrar/tests/test_api.py index 218c63d4f..aba2d52e8 100644 --- a/src/registrar/tests/test_api.py +++ b/src/registrar/tests/test_api.py @@ -143,8 +143,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase): ) self.assertEqual(response.status_code, 200) data = response.json() - self.assertIn("action_needed_email", data) - self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"]) + self.assertIn("email", data) + self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["email"]) @less_console_noise_decorator def test_get_action_needed_email_for_user_json_analyst(self): @@ -160,8 +160,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase): ) self.assertEqual(response.status_code, 200) data = response.json() - self.assertIn("action_needed_email", data) - self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"]) + self.assertIn("email", data) + self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["email"]) @less_console_noise_decorator def test_get_action_needed_email_for_user_json_regular(self): @@ -176,3 +176,71 @@ class GetActionNeededEmailForUserJsonTest(TestCase): }, ) self.assertEqual(response.status_code, 302) + + +class GetRejectionEmailForUserJsonTest(TestCase): + def setUp(self): + self.client = Client() + self.superuser = create_superuser() + self.analyst_user = create_user() + self.agency = FederalAgency.objects.create(agency="Test Agency") + self.domain_request = completed_domain_request( + federal_agency=self.agency, + name="test.gov", + status=DomainRequest.DomainRequestStatus.REJECTED, + ) + + self.api_url = reverse("get-rejection-email-for-user-json") + + def tearDown(self): + DomainRequest.objects.all().delete() + User.objects.all().delete() + FederalAgency.objects.all().delete() + + @less_console_noise_decorator + def test_get_rejected_email_for_user_json_superuser(self): + """Test that a superuser can fetch the action needed email.""" + self.client.force_login(self.superuser) + + response = self.client.get( + self.api_url, + { + "reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED, + "domain_request_id": self.domain_request.id, + }, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("email", data) + self.assertIn("we could not verify the organizational", data["email"]) + + @less_console_noise_decorator + def test_get_rejected_email_for_user_json_analyst(self): + """Test that an analyst can fetch the action needed email.""" + self.client.force_login(self.analyst_user) + + response = self.client.get( + self.api_url, + { + "reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED, + "domain_request_id": self.domain_request.id, + }, + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("email", data) + self.assertIn("we could not verify the organizational", data["email"]) + + @less_console_noise_decorator + def test_get_rejected_email_for_user_json_regular(self): + """Test that a regular user receives a 403 with an error message.""" + p = "password" + self.client.login(username="testuser", password=p) + response = self.client.get( + self.api_url, + { + "reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED, + "domain_request_id": self.domain_request.id, + }, + ) + self.assertEqual(response.status_code, 302) diff --git a/src/registrar/tests/test_models_requests.py b/src/registrar/tests/test_models_requests.py index 9e86f5f9c..2d2c615d7 100644 --- a/src/registrar/tests/test_models_requests.py +++ b/src/registrar/tests/test_models_requests.py @@ -267,7 +267,6 @@ class TestDomainRequest(TestCase): domain_request.submit() self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED) - @less_console_noise_decorator def check_email_sent( self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com" ): @@ -278,6 +277,7 @@ class TestDomainRequest(TestCase): # Perform the specified action action_method = getattr(domain_request, action) action_method() + domain_request.save() # Check if an email was sent sent_emails = [ @@ -337,12 +337,30 @@ class TestDomainRequest(TestCase): domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email ) - @less_console_noise_decorator def test_reject_sends_email(self): - msg = "Create a domain request and reject it and see if email was sent." + "Create a domain request and reject it and see if email was sent." user, _ = User.objects.get_or_create(username="testy") domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user) - self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hi", expected_email=user.email) + expected_email = user.email + email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.reject() + domain_request.rejection_reason = domain_request.RejectionReasons.CONTACTS_NOT_VERIFIED + domain_request.rejection_reason_email = "test" + domain_request.save() + + # Check if an email was sent + sent_emails = [ + email + for email in MockSESClient.EMAILS_SENT + if expected_email in email["kwargs"]["Destination"]["ToAddresses"] + ] + self.assertEqual(len(sent_emails), 1) + + email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"] + self.assertIn("test", email_content) + + email_allowed.delete() @less_console_noise_decorator def test_reject_with_prejudice_does_not_send_email(self): diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py index 2af9d0b3c..37e0a0e00 100644 --- a/src/registrar/utility/admin_helpers.py +++ b/src/registrar/utility/admin_helpers.py @@ -6,36 +6,39 @@ from django.utils.html import escape from registrar.models.utility.generic_helper import value_of_attribute -def get_all_action_needed_reason_emails(request, domain_request): - """Returns a dictionary of every action needed reason and its associated email - for this particular domain request.""" - - emails = {} - for action_needed_reason in domain_request.ActionNeededReasons: - # Map the action_needed_reason to its default email - emails[action_needed_reason.value] = get_action_needed_reason_default_email( - request, domain_request, action_needed_reason.value - ) - - return emails - - -def get_action_needed_reason_default_email(request, domain_request, action_needed_reason): +def get_action_needed_reason_default_email(domain_request, action_needed_reason): """Returns the default email associated with the given action needed reason""" - if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER: + return _get_default_email( + domain_request, + file_path=f"emails/action_needed_reasons/{action_needed_reason}.txt", + reason=action_needed_reason, + excluded_reasons=[DomainRequest.ActionNeededReasons.OTHER], + ) + + +def get_rejection_reason_default_email(domain_request, rejection_reason): + """Returns the default email associated with the given rejection reason""" + return _get_default_email( + domain_request, + file_path="emails/status_change_rejected.txt", + reason=rejection_reason, + # excluded_reasons=[DomainRequest.RejectionReasons.OTHER] + ) + + +def _get_default_email(domain_request, file_path, reason, excluded_reasons=None): + if not reason: + return None + + if excluded_reasons and reason in excluded_reasons: return None recipient = domain_request.creator # Return the context of the rendered views - context = {"domain_request": domain_request, "recipient": recipient} + context = {"domain_request": domain_request, "recipient": recipient, "reason": reason} - # Get the email body - template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt" - - email_body_text = get_template(template_path).render(context=context) - email_body_text_cleaned = None - if email_body_text: - email_body_text_cleaned = email_body_text.strip().lstrip("\n") + email_body_text = get_template(file_path).render(context=context) + email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None return email_body_text_cleaned diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py index f9522e2e9..9d73b5197 100644 --- a/src/registrar/views/utility/api_views.py +++ b/src/registrar/views/utility/api_views.py @@ -4,7 +4,7 @@ from django.forms.models import model_to_dict from registrar.models import FederalAgency, SeniorOfficial, DomainRequest from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import login_required -from registrar.utility.admin_helpers import get_all_action_needed_reason_emails +from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email from registrar.models.portfolio import Portfolio from registrar.utility.constants import BranchChoices @@ -88,5 +88,30 @@ def get_action_needed_email_for_user_json(request): return JsonResponse({"error": "No domain_request_id specified"}, status=404) domain_request = DomainRequest.objects.filter(id=domain_request_id).first() - emails = get_all_action_needed_reason_emails(request, domain_request) - return JsonResponse({"action_needed_email": emails.get(reason)}, status=200) + + email = get_action_needed_reason_default_email(domain_request, reason) + return JsonResponse({"email": email}, status=200) + + +@login_required +@staff_member_required +def get_rejection_email_for_user_json(request): + """Returns a default rejection email for a given user""" + + # This API is only accessible to admins and analysts + superuser_perm = request.user.has_perm("registrar.full_access_permission") + analyst_perm = request.user.has_perm("registrar.analyst_access_permission") + if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]): + return JsonResponse({"error": "You do not have access to this resource"}, status=403) + + reason = request.GET.get("reason") + domain_request_id = request.GET.get("domain_request_id") + if not reason: + return JsonResponse({"error": "No reason specified"}, status=404) + + if not domain_request_id: + return JsonResponse({"error": "No domain_request_id specified"}, status=404) + + domain_request = DomainRequest.objects.filter(id=domain_request_id).first() + email = get_rejection_reason_default_email(domain_request, reason) + return JsonResponse({"email": email}, status=200)