Merge branch 'main' of https://github.com/cisagov/manage.get.gov into rh/2779-update-suborg-text

This commit is contained in:
Rebecca Hsieh 2024-10-10 16:32:36 -07:00
commit c9267c5307
No known key found for this signature in database
15 changed files with 765 additions and 336 deletions

View file

@ -5,6 +5,11 @@ from django import forms
from django.db.models import Value, CharField, Q from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect 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.conf import settings
from django.shortcuts import redirect from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField 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 registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch 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.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
@ -237,6 +237,7 @@ class DomainRequestAdminForm(forms.ModelForm):
} }
labels = { labels = {
"action_needed_reason_email": "Email", "action_needed_reason_email": "Email",
"rejection_reason_email": "Email",
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1750,6 +1751,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"status_history", "status_history",
"status", "status",
"rejection_reason", "rejection_reason",
"rejection_reason_email",
"action_needed_reason", "action_needed_reason",
"action_needed_reason_email", "action_needed_reason_email",
"investigator", "investigator",
@ -1905,25 +1907,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Get the original domain request from the database. # Get the original domain request from the database.
original_obj = models.DomainRequest.objects.get(pk=obj.pk) original_obj = models.DomainRequest.objects.get(pk=obj.pk)
# == Handle action_needed_reason == # # == Handle action needed and rejected emails == #
# Edge case: this logic is handled by javascript, so contexts outside that must be handled
reason_changed = obj.action_needed_reason != original_obj.action_needed_reason obj = self._handle_custom_emails(obj)
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 allowed emails == #
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION: if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
self._check_for_valid_email(request, obj) self._check_for_valid_email(request, obj)
@ -1939,6 +1927,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if should_save: if should_save:
return super().save_model(request, obj, form, change) 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): def _check_for_valid_email(self, request, obj):
"""Certain emails are whitelisted in non-production environments, """Certain emails are whitelisted in non-production environments,
so we should display that information using this function. so we should display that information using this function.

View file

@ -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 /** 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. class CustomizableEmailBase {
* This shows the auto generated email on action needed reason.
*/ /**
document.addEventListener('DOMContentLoaded', function() { * @param {Object} config - must contain the following:
const dropdown = document.getElementById("id_action_needed_reason"); * @property {HTMLElement} dropdown - The dropdown element.
const textarea = document.getElementById("id_action_needed_reason_email") * @property {HTMLElement} textarea - The textarea element.
const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null * @property {HTMLElement} lastSentEmailContent - The last sent email content element.
const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder"); * @property {HTMLElement} textAreaFormGroup - The form group for the textarea.
const directEditButton = document.querySelector('.field-action_needed_reason_email__edit'); * @property {HTMLElement} dropdownFormGroup - The form group for the dropdown.
const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger'); * @property {HTMLElement} modalConfirm - The confirm button in the modal.
const modalConfirm = document.getElementById('confirm-edit-email'); * @property {string} apiUrl - The API URL for fetching email content.
const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]'); * @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
let lastSentEmailContent = document.getElementById("last-sent-email-content"); * @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
const initialDropdownValue = dropdown ? dropdown.value : null; * @property {string} apiErrorMessage - The error message that the ajax call returns.
let initialEmailValue; */
if (textarea) constructor(config) {
initialEmailValue = textarea.value 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; // Handle showing/hiding the related fields on page load.
const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value; initializeFormGroups() {
let isStatus = this.statusSelect.value == this.statusToCheck;
function updateUserInterface(reason) { // Initial handling of these groups.
if (!reason) { this.updateFormGroupVisibility(isStatus);
// 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);
if (isEmailAlreadySentConst) { // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
hideElement(directEditButton); this.statusSelect.addEventListener('change', () => {
showElement(modalTrigger); // Show the action needed field if the status is what we expect.
} else { // Then track if its shown or hidden in our session cache.
showElement(directEditButton); isStatus = this.statusSelect.value == this.statusToCheck;
hideElement(modalTrigger); this.updateFormGroupVisibility(isStatus);
} addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
if (isEmailAlreadySent()) { });
formLabel.innerHTML = "Email sent to creator:";
} else { // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
formLabel.innerHTML = "Email:"; // 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 initializeDropdown() {
updateUserInterface(dropdown.value); this.dropdown.addEventListener("change", () => {
let reason = this.dropdown.value;
dropdown.addEventListener("change", function() { if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
const reason = dropdown.value; let searchParams = new URLSearchParams(
// Update the UI {
updateUserInterface(reason); "reason": reason,
if (reason && reason !== "other") { "domain_request_id": this.domainRequestId,
// If it's not the initial value }
if (initialDropdownValue !== dropdown.value || initialEmailValue !== textarea.value) { );
// Replace the email content // Replace the email content
fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`) fetch(`${this.apiUrl}?${searchParams.toString()}`)
.then(response => { .then(response => {
return response.json().then(data => data); return response.json().then(data => data);
}) })
@ -588,30 +549,213 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.error) { if (data.error) {
console.error("Error in AJAX call: " + data.error); console.error("Error in AJAX call: " + data.error);
}else { }else {
textarea.value = data.action_needed_email; this.textarea.value = data.email;
} }
updateUserInterface(reason); this.updateUserInterface(reason);
}) })
.catch(error => { .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", () => { // Helper function that makes overriding the placeholder reason easy
textarea.removeAttribute('readonly'); showPlaceholderNoReason() {
textarea.focus(); this.showPlaceholder("Email:", "Select a reason to see email");
hideElement(directEditButton); }
hideElement(modalTrigger);
}); // Helper function that makes overriding the placeholder reason easy
directEditButton.addEventListener("click", () => { showPlaceholderOtherReason() {
textarea.removeAttribute('readonly'); this.showPlaceholder("Email:", "No email will be sent");
textarea.focus(); }
hideElement(directEditButton);
hideElement(modalTrigger); 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()
}); });

View file

@ -31,6 +31,7 @@ from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json, get_senior_official_from_federal_agency_json,
get_federal_and_portfolio_types_from_federal_agency_json, get_federal_and_portfolio_types_from_federal_agency_json,
get_action_needed_email_for_user_json, get_action_needed_email_for_user_json,
get_rejection_email_for_user_json,
) )
from registrar.views.domain_request import Step, PortfolioDomainRequestStep from registrar.views.domain_request import Step, PortfolioDomainRequestStep
@ -175,6 +176,11 @@ urlpatterns = [
get_action_needed_email_for_user_json, get_action_needed_email_for_user_json,
name="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("admin/", admin.site.urls),
path( path(
"reports/export_data_type_user/", "reports/export_data_type_user/",

View file

@ -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,
),
),
]

View file

@ -254,18 +254,18 @@ class DomainRequest(TimeStampedModel):
) )
class RejectionReasons(models.TextChoices): class RejectionReasons(models.TextChoices):
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met" DOMAIN_PURPOSE = "domain_purpose", "Purpose requirements not met"
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request" REQUESTOR_NOT_ELIGIBLE = "requestor_not_eligible", "Requestor not eligible to make request"
SECOND_DOMAIN_REASONING = ( ORG_HAS_DOMAIN = (
"org_has_domain", "org_has_domain",
"Org already has a .gov domain", "Org already has a .gov domain",
) )
CONTACTS_OR_ORGANIZATION_LEGITIMACY = ( CONTACTS_NOT_VERIFIED = (
"contacts_not_verified", "contacts_not_verified",
"Org contacts couldn't be verified", "Org contacts couldn't be verified",
) )
ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain" ORG_NOT_ELIGIBLE = "org_not_eligible", "Org not eligible for a .gov domain"
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met" NAMING_REQUIREMENTS = "naming_requirements", "Naming requirements not met"
OTHER = "other", "Other/Unspecified" OTHER = "other", "Other/Unspecified"
@classmethod @classmethod
@ -300,6 +300,11 @@ class DomainRequest(TimeStampedModel):
blank=True, blank=True,
) )
rejection_reason_email = models.TextField(
null=True,
blank=True,
)
action_needed_reason = models.TextField( action_needed_reason = models.TextField(
choices=ActionNeededReasons.choices, choices=ActionNeededReasons.choices,
null=True, null=True,
@ -635,15 +640,16 @@ class DomainRequest(TimeStampedModel):
# Actually updates the organization_type field # Actually updates the organization_type field
org_type_helper.create_or_update_organization_type() 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""" """Maintains a cache of properties so we can avoid a DB call"""
self._cached_action_needed_reason = self.action_needed_reason self._cached_action_needed_reason = self.action_needed_reason
self._cached_rejection_reason = self.rejection_reason
self._cached_status = self.status self._cached_status = self.status
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Store original values for caching purposes. Used to compare them on save. # 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): def save(self, *args, **kwargs):
"""Save override for custom properties""" """Save override for custom properties"""
@ -655,23 +661,63 @@ class DomainRequest(TimeStampedModel):
super().save(*args, **kwargs) super().save(*args, **kwargs)
# Handle the action needed email. # Handle custom status emails.
# An email is sent out when action_needed_reason is changed or added. # An email is sent out when a, for example, action_needed_reason is changed or added.
if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED: statuses_that_send_custom_emails = [self.DomainRequestStatus.ACTION_NEEDED, self.DomainRequestStatus.REJECTED]
self.sync_action_needed_reason() if self.status in statuses_that_send_custom_emails:
self.send_custom_status_update_email(self.status)
# Update the cached values after saving # 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): def send_custom_status_update_email(self, status):
"""Checks if we need to send another action needed email""" """Helper function to send out a second status email when the status remains the same,
was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED but the reason has changed."""
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 # Currently, we store all this information in three variables.
if was_already_action_needed and reason_exists and reason_changed: # When adding new reasons, this can be a lot to manage so we store it here
# We don't send emails out in state "other" # in a centralized location. However, this may need to change if this scales.
if self.action_needed_reason != self.ActionNeededReasons.OTHER: status_information = {
self._send_action_needed_reason_email(email_content=self.action_needed_reason_email) 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): def sync_yes_no_form_fields(self):
"""Some yes/no forms use a db field to track whether it was checked or not. """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, target=DomainRequestStatus.ACTION_NEEDED,
conditions=[domain_is_not_active, investigator_exists_and_is_staff], 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. """Send back an domain request that is under investigation or rejected.
This action is logged. This action is logged.
@ -909,43 +955,23 @@ class DomainRequest(TimeStampedModel):
This action cleans up the rejection status if moving away from rejected. This action cleans up the rejection status if moving away from rejected.
As side effects this will delete the domain and domain_information 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: 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: elif self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None self.rejection_reason = None
# Check if the tuple is setup correctly, then grab its value.
literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED 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" action_needed = literal if literal is not None else "Action Needed"
logger.info(f"A status change occurred. {self} was changed to '{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( @transition(
field="status", field="status",
source=[ source=[
@ -1039,18 +1065,20 @@ class DomainRequest(TimeStampedModel):
def reject(self): def reject(self):
"""Reject an domain request that has been submitted. """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 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: if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("reject") 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( @transition(
field="status", field="status",
source=[ source=[

View file

@ -10,6 +10,8 @@
<input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/> <input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/>
{% url 'get-action-needed-email-for-user-json' as url %} {% url 'get-action-needed-email-for-user-json' as url %}
<input id="get-action-needed-email-for-user-json" class="display-none" value="{{ url }}" /> <input id="get-action-needed-email-for-user-json" class="display-none" value="{{ url }}" />
{% url 'get-rejection-email-for-user-json' as url_2 %}
<input id="get-rejection-email-for-user-json" class="display-none" value="{{ url_2 }}" />
{% for fieldset in adminform %} {% for fieldset in adminform %}
{% comment %} {% comment %}
TODO: this will eventually need to be changed to something like this TODO: this will eventually need to be changed to something like this

View file

@ -137,29 +137,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% block field_other %} {% block field_other %}
{% if field.field.name == "action_needed_reason_email" %} {% if field.field.name == "action_needed_reason_email" %}
{{ field.field }}
<div class="margin-top-05 text-faded field-action_needed_reason_email__placeholder"> <div class="margin-top-05 text-faded custom-email-placeholder">
&ndash; &ndash;
</div> </div>
{{ field.field }}
<button <button
aria-label="Edit email in textarea" aria-label="Edit email in textarea"
type="button" type="button"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline field-action_needed_reason_email__edit flex-align-self-start" class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button ><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
> >
<a <a
href="#email-already-sent-modal" href="#action-needed-email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 field-action_needed_reason_email__modal-trigger flex-align-self-start" class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
aria-controls="email-already-sent-modal" aria-controls="action-needed-email-already-sent-modal"
data-open-modal data-open-modal
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a ><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
> >
<div <div
class="usa-modal" class="usa-modal"
id="email-already-sent-modal" id="action-needed-email-already-sent-modal"
aria-labelledby="Are you sure you want to edit this email?" aria-labelledby="Are you sure you want to edit this email?"
aria-describedby="The creator of this request already received an email" aria-describedby="The creator of this request already received an email"
> >
@ -187,8 +186,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<button <button
type="submit" type="submit"
id="action-needed-reason__confirm-edit-email"
class="usa-button" class="usa-button"
id="confirm-edit-email"
data-close-modal data-close-modal
> >
Yes, continue editing Yes, continue editing
@ -221,11 +220,99 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</div> </div>
{% if original_object.action_needed_reason_email %} {% if original_object.action_needed_reason_email %}
<input id="last-sent-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}"> <input id="last-sent-action-needed-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
{% else %} {% else %}
<input id="last-sent-email-content" class="display-none" value="None"> <input id="last-sent-action-needed-email-content" class="display-none" value="None">
{% endif %} {% endif %}
{% elif field.field.name == "rejection_reason_email" %}
{{ field.field }}
<div class="margin-top-05 text-faded custom-email-placeholder">
&ndash;
</div>
<button
aria-label="Edit email in textarea"
type="button"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
>
<a
href="#rejection-reason-email-already-sent-modal"
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
aria-controls="rejection-reason-email-already-sent-modal"
data-open-modal
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
>
<div
class="usa-modal"
id="rejection-reason-email-already-sent-modal"
aria-labelledby="Are you sure you want to edit this email?"
aria-describedby="The creator of this request already received an email"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading">
Are you sure you want to edit this email?
</h2>
<div class="usa-prose">
<p>
The creator of this request already received an email for this status/reason:
</p>
<ul>
<li class="font-body-sm">Status: <b>Rejected</b></li>
<li class="font-body-sm">Reason: <b>{{ original_object.get_rejection_reason_display }}</b></li>
</ul>
<p>
If you edit this email's text, <b>the system will send another email</b> to
the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
id="rejection-reason__confirm-edit-email"
class="usa-button"
data-close-modal
>
Yes, continue editing
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
name="_cancel_edit_email"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{% if original_object.rejection_reason_email %}
<input id="last-sent-rejection-email-content" class="display-none" value="{{original_object.rejection_reason_email}}">
{% else %}
<input id="last-sent-rejection-email-content" class="display-none" value="None">
{% endif %}
{% else %} {% else %}
{{ field.field }} {{ field.field }}
{% endif %} {% endif %}

View file

@ -8,8 +8,8 @@ REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Rejected STATUS: Rejected
---------------------------------------------------------------- ----------------------------------------------------------------
{% if domain_request.rejection_reason != 'other' %} {% if reason != domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
REJECTION REASON{% endif %}{% if domain_request.rejection_reason == 'purpose_not_met' %} REJECTION REASON{% endif %}{% if reason == domain_request.RejectionReasons.DOMAIN_PURPOSE %}
Your domain request was rejected because the purpose you provided did not meet our Your domain request was rejected because the purpose you provided did not meet our
requirements. You didnt provide enough information about how you intend to use the requirements. You didnt provide enough information about how you intend to use the
domain. domain.
@ -18,7 +18,7 @@ Learn more about:
- Eligibility for a .gov domain <https://get.gov/domains/eligibility> - Eligibility for a .gov domain <https://get.gov/domains/eligibility>
- What you can and cant do with .gov domains <https://get.gov/domains/requirements/> - What you can and cant do with .gov domains <https://get.gov/domains/requirements/>
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 dont believe youre eligible to request a Your domain request was rejected because we dont believe youre eligible to request a
.gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be .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. 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 DEMONSTRATE ELIGIBILITY
If you can provide more information that demonstrates your eligibility, or you want to 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 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 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 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 Read more about our practice of approving one domain per online service
<https://get.gov/domains/before/#one-domain-per-service>. <https://get.gov/domains/before/#one-domain-per-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 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 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 eligible for a .gov domain. .Gov domains are only available to official U.S.-based
government organizations. government organizations.
@ -46,7 +46,7 @@ Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>. <https://get.gov/domains/eligibility/>.
If you have questions or comments, reply to this email. 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. 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 Domains should uniquely identify a government organization and be clear to the
general public. Learn more about naming requirements for your type of organization 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 YOU CAN SUBMIT A NEW REQUEST
We encourage you to request a domain that meets our requirements. If you have 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 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. If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.

View file

@ -595,7 +595,12 @@ class TestDomainRequestAdmin(MockEppLib):
@less_console_noise_decorator @less_console_noise_decorator
def transition_state_and_send_email( 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.""" """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.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) 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 # Test the email sent out for bad_name
bad_name = DomainRequest.ActionNeededReasons.BAD_NAME bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=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 "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
self._reset_action_needed_email(domain_request)
# Test the email sent out for eligibility_unclear # Test the email sent out for eligibility_unclear
eligibility_unclear = DomainRequest.ActionNeededReasons.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 "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) 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 # Test that a custom email is sent out for questionable_so
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL 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 "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.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
self._reset_action_needed_email(domain_request)
# Assert that no other emails are sent on OTHER # Assert that no other emails are sent on OTHER
other = DomainRequest.ActionNeededReasons.OTHER other = DomainRequest.ActionNeededReasons.OTHER
@ -717,6 +729,7 @@ class TestDomainRequestAdmin(MockEppLib):
# Should be unchanged from before # Should be unchanged from before
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) 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 # Tests if an analyst can override existing email content
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
@ -730,6 +743,7 @@ class TestDomainRequestAdmin(MockEppLib):
domain_request.refresh_from_db() domain_request.refresh_from_db()
self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL) 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.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. # 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. # 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.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
self._reset_action_needed_email(domain_request)
# Set the request back to in review # Set the request back to in review
domain_request.in_review() domain_request.in_review()
@ -757,55 +772,53 @@ class TestDomainRequestAdmin(MockEppLib):
) )
self.assertEqual(len(self.mock_client.EMAILS_SENT), 6) self.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
# def test_action_needed_sends_reason_email_prod_bcc(self): def _reset_action_needed_email(self, domain_request):
# """When an action needed reason is set, an email is sent out and help@get.gov """Sets the given action needed email back to none"""
# is BCC'd in production""" domain_request.action_needed_reason_email = None
# # Ensure there is no user with this email domain_request.save()
# EMAIL = "mayor@igorville.gov" domain_request.refresh_from_db()
# BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
# User.objects.filter(email=EMAIL).delete()
# in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
# action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED
# # Create a sample domain request @override_settings(IS_PRODUCTION=True)
# domain_request = completed_domain_request(status=in_review) @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 _creator = User.objects.create(
# already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS username="MrMeoward",
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) first_name="Meoward",
# self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) last_name="Jones",
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) email=EMAIL,
phone="(555) 123 12345",
title="Treat inspector",
)
# # Test the email sent out for bad_name BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
# bad_name = DomainRequest.ActionNeededReasons.BAD_NAME in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name) rejected = DomainRequest.DomainRequestStatus.REJECTED
# 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)
# # Test the email sent out for eligibility_unclear # Create a sample domain request
# eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR domain_request = completed_domain_request(status=in_review, user=_creator)
# 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)
# # Test the email sent out for questionable_so expected_emails = {
# questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL DomainRequest.RejectionReasons.DOMAIN_PURPOSE: "You didnt provide enough information about how",
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so) DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE: "You must be a government employee, or be",
# self.assert_email_is_accurate( DomainRequest.RejectionReasons.ORG_HAS_DOMAIN: "practice is to approve one domain",
# "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL 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",
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) DomainRequest.RejectionReasons.NAMING_REQUIREMENTS: "does not meet our naming requirements",
DomainRequest.RejectionReasons.OTHER: "YOU CAN SUBMIT A NEW REQUEST",
# # Assert that no other emails are sent on OTHER }
# other = DomainRequest.ActionNeededReasons.OTHER for i, (reason, email_content) in enumerate(expected_emails.items()):
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other) with self.subTest(reason=reason):
self.transition_state_and_send_email(domain_request, status=rejected, rejection_reason=reason)
# # Should be unchanged from before self.assert_email_is_accurate(email_content, i, EMAIL, bcc_email_address=BCC_EMAIL)
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) 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 @less_console_noise_decorator
def test_save_model_sends_submitted_email(self): 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 # Reject for reason REQUESTOR and test email including dynamic organization name
self.transition_state_and_send_email( 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( self.assert_email_is_accurate(
"Your domain request was rejected because we dont believe youre eligible to request a \n.gov " "Your domain request was rejected because we dont believe youre eligible to request a \n.gov "
@ -1072,7 +1087,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email( self.transition_state_and_send_email(
domain_request, domain_request,
DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING, DomainRequest.RejectionReasons.ORG_HAS_DOMAIN,
) )
self.assert_email_is_accurate( self.assert_email_is_accurate(
"Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email "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( self.transition_state_and_send_email(
domain_request, domain_request,
DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
) )
self.assert_email_is_accurate( self.assert_email_is_accurate(
"Your domain request was rejected because we could not verify the organizational \n" "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( self.transition_state_and_send_email(
domain_request, domain_request,
DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.ORGANIZATION_ELIGIBILITY, DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE,
) )
self.assert_email_is_accurate( self.assert_email_is_accurate(
"Your domain request was rejected because we determined that Testorg is not \neligible for " "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, "error"))
stack.enter_context(patch.object(messages, "warning")) stack.enter_context(patch.object(messages, "warning"))
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED 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) self.admin.save_model(request, domain_request, None, True)
@ -1621,6 +1636,7 @@ class TestDomainRequestAdmin(MockEppLib):
"updated_at", "updated_at",
"status", "status",
"rejection_reason", "rejection_reason",
"rejection_reason_email",
"action_needed_reason", "action_needed_reason",
"action_needed_reason_email", "action_needed_reason_email",
"federal_agency", "federal_agency",
@ -1840,7 +1856,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.trigger_saving_approved_to_another_state( self.trigger_saving_approved_to_another_state(
False, False,
DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY, DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
) )
def test_side_effects_when_saving_approved_to_ineligible(self): def test_side_effects_when_saving_approved_to_ineligible(self):

View file

@ -143,8 +143,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
self.assertIn("action_needed_email", data) self.assertIn("email", data)
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"]) self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
@less_console_noise_decorator @less_console_noise_decorator
def test_get_action_needed_email_for_user_json_analyst(self): def test_get_action_needed_email_for_user_json_analyst(self):
@ -160,8 +160,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
self.assertIn("action_needed_email", data) self.assertIn("email", data)
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"]) self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
@less_console_noise_decorator @less_console_noise_decorator
def test_get_action_needed_email_for_user_json_regular(self): def test_get_action_needed_email_for_user_json_regular(self):
@ -176,3 +176,71 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
}, },
) )
self.assertEqual(response.status_code, 302) 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)

View file

@ -267,7 +267,6 @@ class TestDomainRequest(TestCase):
domain_request.submit() domain_request.submit()
self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED) self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED)
@less_console_noise_decorator
def check_email_sent( def check_email_sent(
self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com" 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 # Perform the specified action
action_method = getattr(domain_request, action) action_method = getattr(domain_request, action)
action_method() action_method()
domain_request.save()
# Check if an email was sent # Check if an email was sent
sent_emails = [ sent_emails = [
@ -337,12 +337,30 @@ class TestDomainRequest(TestCase):
domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email
) )
@less_console_noise_decorator
def test_reject_sends_email(self): 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") user, _ = User.objects.get_or_create(username="testy")
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user) 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 @less_console_noise_decorator
def test_reject_with_prejudice_does_not_send_email(self): def test_reject_with_prejudice_does_not_send_email(self):

View file

@ -6,36 +6,39 @@ from django.utils.html import escape
from registrar.models.utility.generic_helper import value_of_attribute from registrar.models.utility.generic_helper import value_of_attribute
def get_all_action_needed_reason_emails(request, domain_request): def get_action_needed_reason_default_email(domain_request, action_needed_reason):
"""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):
"""Returns the default email associated with the given 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 return None
recipient = domain_request.creator recipient = domain_request.creator
# Return the context of the rendered views # 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 email_body_text = get_template(file_path).render(context=context)
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt" email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None
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")
return email_body_text_cleaned return email_body_text_cleaned

View file

@ -4,7 +4,7 @@ from django.forms.models import model_to_dict
from registrar.models import FederalAgency, SeniorOfficial, DomainRequest from registrar.models import FederalAgency, SeniorOfficial, DomainRequest
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_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.models.portfolio import Portfolio
from registrar.utility.constants import BranchChoices 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) return JsonResponse({"error": "No domain_request_id specified"}, status=404)
domain_request = DomainRequest.objects.filter(id=domain_request_id).first() 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)