From 61b1ceeb9ce6c68d38de4e3ee7f31dccb1e33755 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Tue, 24 Sep 2024 14:22:25 -0600
Subject: [PATCH 01/34] Refactor
---
src/registrar/admin.py | 67 +++--
src/registrar/assets/js/get-gov-admin.js | 280 ++++++++++++------
src/registrar/models/domain_request.py | 49 +--
.../custom_email.txt | 0
.../emails/includes/email_footer.txt | 10 +
.../status_change_rejected_header.txt | 8 +
.../contacts_not_verified.txt | 8 +
.../rejection_reasons/naming_not_met.txt | 15 +
.../rejection_reasons/org_has_domain.txt | 15 +
.../rejection_reasons/org_not_eligible.txt | 14 +
.../emails/rejection_reasons/other.txt | 15 +
.../rejection_reasons/purpose_not_met.txt | 15 +
.../requestor_not_eligible.txt | 14 +
..._subject.txt => status_change_subject.txt} | 0
src/registrar/utility/admin_helpers.py | 65 +++-
src/registrar/views/utility/api_views.py | 2 +-
16 files changed, 437 insertions(+), 140 deletions(-)
rename src/registrar/templates/emails/{action_needed_reasons => includes}/custom_email.txt (100%)
create mode 100644 src/registrar/templates/emails/includes/email_footer.txt
create mode 100644 src/registrar/templates/emails/includes/status_change_rejected_header.txt
create mode 100644 src/registrar/templates/emails/rejection_reasons/contacts_not_verified.txt
create mode 100644 src/registrar/templates/emails/rejection_reasons/naming_not_met.txt
create mode 100644 src/registrar/templates/emails/rejection_reasons/org_has_domain.txt
create mode 100644 src/registrar/templates/emails/rejection_reasons/org_not_eligible.txt
create mode 100644 src/registrar/templates/emails/rejection_reasons/other.txt
create mode 100644 src/registrar/templates/emails/rejection_reasons/purpose_not_met.txt
create mode 100644 src/registrar/templates/emails/rejection_reasons/requestor_not_eligible.txt
rename src/registrar/templates/emails/{status_change_rejected_subject.txt => status_change_subject.txt} (100%)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 15f1ccb79..b524f9d0a 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -21,7 +21,12 @@
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
+from registrar.utility.admin_helpers import (
+ get_all_action_needed_reason_emails,
+ get_action_needed_reason_default_email,
+ get_all_rejection_reason_emails,
+ get_rejection_reason_default_email,
+)
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
@@ -234,6 +239,7 @@ class Meta:
}
labels = {
"action_needed_reason_email": "Email",
+ "rejection_reason_email": "Email",
}
def __init__(self, *args, **kwargs):
@@ -1755,6 +1761,7 @@ def status_history(self, obj):
"status_history",
"status",
"rejection_reason",
+ "rejection_reason_email",
"action_needed_reason",
"action_needed_reason_email",
"investigator",
@@ -1938,24 +1945,16 @@ def save_model(self, request, obj, form, change):
original_obj = models.DomainRequest.objects.get(pk=obj.pk)
# == Handle action_needed_reason == #
+ action_needed_reason_changed = obj.action_needed_reason != original_obj.action_needed_reason
+ if action_needed_reason_changed:
+ obj = self._handle_action_needed_reason(request, obj, original_obj)
- 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 rejection_reason == #
+ rejection_reason_changed = obj.rejection_reason != original_obj.rejection_reason
+ if rejection_reason_changed:
+ obj = self._handle_rejection_reason(request, obj, original_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)
@@ -1971,6 +1970,40 @@ def save_model(self, request, obj, form, change):
if should_save:
return super().save_model(request, obj, form, change)
+ def _handle_action_needed_reason(self, request, obj, original_obj):
+ # 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(obj, obj.action_needed_reason)
+ if obj.action_needed_reason_email:
+ emails = get_all_action_needed_reason_emails(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
+ return obj
+
+ def _handle_rejection_reason(self, request, obj, original_obj):
+ # Track the fact that we sent out an email
+ request.session["rejection_reason_email_sent"] = True
+
+ # Set the rejection_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_rejection_reason_default_email(obj, obj.action_needed_reason)
+ if obj.rejection_reason_email:
+ emails = get_all_rejection_reason_emails(obj)
+ is_custom_email = obj.rejection_reason_email not in emails.values()
+ if not is_custom_email:
+ obj.rejection_reason_email = default_email
+ else:
+ obj.rejection_reason_email = default_email
+ 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 7e3c086c4..7a7b63ba4 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -500,83 +500,41 @@ function initializeWidgetOnList(list, parentId) {
}
})();
-
-/** An IIFE that hooks to the show/hide button underneath action needed reason.
- * This shows the auto generated email on action needed reason.
-*/
-document.addEventListener('DOMContentLoaded', function() {
- const dropdown = document.getElementById("id_action_needed_reason");
- const textarea = document.getElementById("id_action_needed_reason_email")
- const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null
- const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder");
- const directEditButton = document.querySelector('.field-action_needed_reason_email__edit');
- const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger');
- const modalConfirm = document.getElementById('confirm-edit-email');
- const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]');
- let lastSentEmailContent = document.getElementById("last-sent-email-content");
- const initialDropdownValue = dropdown ? dropdown.value : null;
- const initialEmailValue = textarea.value;
-
- // We will use the const to control the modal
- let isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
- // We will use the function to control the label and help
- function isEmailAlreadySent() {
- return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
- }
-
- if (!dropdown || !textarea || !domainRequestId || !formLabel || !modalConfirm) return;
- const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value;
-
- 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);
-
- if (isEmailAlreadySentConst) {
- hideElement(directEditButton);
- showElement(modalTrigger);
- } else {
- showElement(directEditButton);
- hideElement(modalTrigger);
- }
- if (isEmailAlreadySent()) {
- formLabel.innerHTML = "Email sent to creator:";
- } else {
- formLabel.innerHTML = "Email:";
- }
+class CustomizableEmailBase {
+ constructor(dropdown, textarea, textareaPlaceholder, directEditButton, modalTrigger, modalConfirm, formLabel, lastSentEmailContent, apiUrl) {
+ this.dropdown = dropdown;
+ this.textarea = textarea;
+ this.textareaPlaceholder = textareaPlaceholder;
+ this.directEditButton = directEditButton;
+ this.modalTrigger = modalTrigger;
+ this.modalConfirm = modalConfirm;
+ this.formLabel = formLabel;
+ this.lastSentEmailContent = lastSentEmailContent;
+ this.apiUrl = apiUrl;
+
+ 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;
+
+ this.isEmailAlreadySentConst;
+ if (lastSentEmailContent && textarea) {
+ this.isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
}
}
- // 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(errorMessage) {
+ this.dropdown.addEventListener("change", () => {
+ console.log(this.dropdown)
+ let reason = this.dropdown.value;
+ if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
+ let searchParams = new URLSearchParams(
+ {
+ "reason": reason,
+ "domain_request_id": this.domainRequestId,
+ }
+ );
// Replace the email content
- fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`)
+ fetch(`${this.apiUrl}?${searchParams.toString()}`)
.then(response => {
return response.json().then(data => data);
})
@@ -584,30 +542,174 @@ 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.action_needed_email;
}
- updateUserInterface(reason);
+ this.updateUserInterface(reason);
})
.catch(error => {
- console.error("Error action needed email: ", error)
+ console.error(errorMessage, 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) {
+ 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.showPlaceholder("Email:", "Select an action needed reason to see email");
+ } 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
+ this.showPlaceholder("Email:", "No email will be sent");
+ } else {
+ // 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:";
+ }
}
+ }
- });
+ showPlaceholder(formLabelText, placeholderText) {
+ this.formLabel.innerHTML = formLabelText;
+ this.textareaPlaceholder.innerHTML = placeholderText;
+ showElement(this.textareaPlaceholder);
+ hideElement(this.directEditButton);
+ hideElement(this.modalTrigger);
+ hideElement(this.textarea);
+ }
+}
- modalConfirm.addEventListener("click", () => {
- textarea.removeAttribute('readonly');
- textarea.focus();
- hideElement(directEditButton);
- hideElement(modalTrigger);
- });
- directEditButton.addEventListener("click", () => {
- textarea.removeAttribute('readonly');
- textarea.focus();
- hideElement(directEditButton);
- hideElement(modalTrigger);
- });
+
+
+class customActionNeededEmail extends CustomizableEmailBase {
+ constructor() {
+ const dropdown = document.getElementById("id_action_needed_reason");
+ const textarea = document.getElementById("id_action_needed_reason_email")
+ 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"]');
+ const lastSentEmailContent = document.getElementById("last-sent-email-content");
+
+ let apiContainer = document.getElementById("get-action-needed-email-for-user-json")
+ const apiUrl = apiContainer ? apiContainer.value : null;
+ super(
+ dropdown,
+ textarea,
+ textareaPlaceholder,
+ directEditButton,
+ modalTrigger,
+ modalConfirm,
+ formLabel,
+ lastSentEmailContent,
+ apiUrl
+ );
+ }
+
+ loadActionNeededEmail() {
+ this.updateUserInterface(this.dropdown.value);
+ this.initializeDropdown("Error when attempting to grab action needed email: ")
+ this.initializeModalConfirm()
+ this.initializeDirectEditButton()
+ }
+}
+
+/** 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 customEmail = new customActionNeededEmail();
+ if (!customEmail.dropdown || !customEmail.textarea || !customEmail.domainRequestId || !customEmail.formLabel || !customEmail.modalConfirm){
+ return;
+ }
+
+ // Initialize UI
+ customEmail.loadActionNeededEmail()
+});
+
+
+class customRejectedEmail extends CustomizableEmailBase {
+ constructor() {
+ const dropdown = document.getElementById("id_rejection_reason");
+ const textarea = document.getElementById("id_rejection_reason_email")
+ const textareaPlaceholder = document.querySelector(".field-rejection_reason_email__placeholder");
+ const directEditButton = document.querySelector('.field-rejection_reason_email__edit');
+ const modalTrigger = document.querySelector('.field-rejection_reason_email__modal-trigger');
+ const modalConfirm = document.getElementById('confirm-edit-email');
+ const formLabel = document.querySelector('label[for="id_rejection_reason_email"]');
+ const lastSentEmailContent = document.getElementById("last-sent-email-content");
+
+ let apiContainer = document.getElementById("get-rejection-reason-email-for-user-json")
+ const apiUrl = apiContainer ? apiContainer.value : null;
+ super(
+ dropdown,
+ textarea,
+ textareaPlaceholder,
+ directEditButton,
+ modalTrigger,
+ modalConfirm,
+ formLabel,
+ lastSentEmailContent,
+ apiUrl
+ );
+ }
+
+ loadRejectedEmail() {
+ this.updateUserInterface(this.dropdown.value);
+ this.initializeDropdown("Error when attempting to grab rejected email: ")
+ this.initializeModalConfirm()
+ this.initializeDirectEditButton()
+ }
+}
+
+
+/** 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 customEmail = new customRejectedEmail();
+ if (!customEmail.dropdown || !customEmail.textarea || !customEmail.domainRequestId || !customEmail.formLabel || !customEmail.modalConfirm){
+ return;
+ }
+
+ // Initialize UI
+ customEmail.loadRejectedEmail()
});
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index bb8693ac1..b1cc00bde 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -300,6 +300,11 @@ def get_action_needed_reason_label(cls, action_needed_reason: str):
blank=True,
)
+ rejection_reason_email = models.TextField(
+ null=True,
+ blank=True,
+ )
+
action_needed_reason = models.TextField(
choices=ActionNeededReasons.choices,
null=True,
@@ -798,6 +803,21 @@ def _send_status_update_email(
except EmailSendingError:
logger.warning("Failed to send confirmation email", exc_info=True)
+ def _send_custom_status_update_email(self, email_content):
+ """Wrapper for `_send_status_update_email` that bcc's help@get.gov
+ and sends an email equivalent to the 'email_content' variable."""
+ if settings.IS_PRODUCTION:
+ bcc_address = settings.DEFAULT_FROM_EMAIL
+
+ self._send_status_update_email(
+ new_status="action needed",
+ email_template=f"emails/includes/custom_email.txt",
+ email_template_subject=f"emails/status_change_subject.txt",
+ bcc_address=bcc_address,
+ custom_email_content=email_content,
+ wrap_email=True,
+ )
+
def investigator_exists_and_is_staff(self):
"""Checks if the current investigator is in a valid state for a state transition"""
is_valid = True
@@ -901,7 +921,7 @@ def in_review(self):
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.
@@ -924,27 +944,7 @@ def action_needed(self, send_email=True):
# 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,
- )
+ self._send_custom_status_update_email(email_content)
@transition(
field="status",
@@ -1051,6 +1051,11 @@ def reject(self):
"emails/status_change_rejected_subject.txt",
)
+ # Send out an email if a rejection reason exists
+ if self.rejection_reason:
+ email_content = self.rejection_reason_email
+ self._send_custom_status_update_email(email_content)
+
@transition(
field="status",
source=[
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/includes/email_footer.txt b/src/registrar/templates/emails/includes/email_footer.txt
new file mode 100644
index 000000000..f10d82a91
--- /dev/null
+++ b/src/registrar/templates/emails/includes/email_footer.txt
@@ -0,0 +1,10 @@
+THANK YOU
+.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
+
+----------------------------------------------------------------
+
+The .gov team
+Contact us:
+Learn about .gov
+
+The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA)
\ No newline at end of file
diff --git a/src/registrar/templates/emails/includes/status_change_rejected_header.txt b/src/registrar/templates/emails/includes/status_change_rejected_header.txt
new file mode 100644
index 000000000..16b7c73a9
--- /dev/null
+++ b/src/registrar/templates/emails/includes/status_change_rejected_header.txt
@@ -0,0 +1,8 @@
+Hi, {{ recipient.first_name }}.
+
+Your .gov domain request has been rejected.
+
+DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
+REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
+STATUS: Rejected
+----------------------------------------------------------------
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/contacts_not_verified.txt b/src/registrar/templates/emails/rejection_reasons/contacts_not_verified.txt
new file mode 100644
index 000000000..c35c82c2b
--- /dev/null
+++ b/src/registrar/templates/emails/rejection_reasons/contacts_not_verified.txt
@@ -0,0 +1,8 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+{% include "emails/includes/status_change_rejected_header.html" %}
+REJECTION REASON
+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.
+
+{% include "emails/includes/email_footer.html" %}
+{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/naming_not_met.txt b/src/registrar/templates/emails/rejection_reasons/naming_not_met.txt
new file mode 100644
index 000000000..3e57d579d
--- /dev/null
+++ b/src/registrar/templates/emails/rejection_reasons/naming_not_met.txt
@@ -0,0 +1,15 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+{% include "emails/includes/status_change_rejected_header.html" %}
+REJECTION REASON
+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
+.
+
+
+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.
+
+{% include "emails/includes/email_footer.html" %}
+{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/org_has_domain.txt b/src/registrar/templates/emails/rejection_reasons/org_has_domain.txt
new file mode 100644
index 000000000..26757efd6
--- /dev/null
+++ b/src/registrar/templates/emails/rejection_reasons/org_has_domain.txt
@@ -0,0 +1,15 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+{% include "emails/includes/status_change_rejected_header.html" %}
+REJECTION REASON
+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
+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.
+
+{% include "emails/includes/email_footer.html" %}
+{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/org_not_eligible.txt b/src/registrar/templates/emails/rejection_reasons/org_not_eligible.txt
new file mode 100644
index 000000000..3c7de3f42
--- /dev/null
+++ b/src/registrar/templates/emails/rejection_reasons/org_not_eligible.txt
@@ -0,0 +1,14 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+{% include "emails/includes/status_change_rejected_header.html" %}
+REJECTION REASON
+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.
+
+Learn more about eligibility for .gov domains
+.
+
+If you have questions or comments, reply to this email.
+
+{% include "emails/includes/email_footer.html" %}
+{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/other.txt b/src/registrar/templates/emails/rejection_reasons/other.txt
new file mode 100644
index 000000000..6835a45e0
--- /dev/null
+++ b/src/registrar/templates/emails/rejection_reasons/other.txt
@@ -0,0 +1,15 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+{% include "emails/includes/status_change_rejected_header.html" %}
+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.
+
+Learn more about:
+- Eligibility for a .gov domain
+- Choosing a .gov domain name
+
+
+NEED ASSISTANCE?
+If you have questions about this domain request or need help choosing a new domain name, reply to this email.
+
+{% include "emails/includes/email_footer.html" %}
+{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/purpose_not_met.txt b/src/registrar/templates/emails/rejection_reasons/purpose_not_met.txt
new file mode 100644
index 000000000..57bce78f0
--- /dev/null
+++ b/src/registrar/templates/emails/rejection_reasons/purpose_not_met.txt
@@ -0,0 +1,15 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+{% include "emails/includes/status_change_rejected_header.html" %}
+REJECTION REASON
+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.
+
+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.
+
+{% include "emails/includes/email_footer.html" %}
+{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/requestor_not_eligible.txt b/src/registrar/templates/emails/rejection_reasons/requestor_not_eligible.txt
new file mode 100644
index 000000000..7974c1690
--- /dev/null
+++ b/src/registrar/templates/emails/rejection_reasons/requestor_not_eligible.txt
@@ -0,0 +1,14 @@
+{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
+{% include "emails/includes/status_change_rejected_header.html" %}
+REJECTION REASON
+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.
+
+
+DEMONSTRATE ELIGIBILITY
+If you can provide more information that demonstrates your eligibility, or you want to
+discuss further, reply to this email.
+
+{% include "emails/includes/email_footer.html" %}
+{% endautoescape %}
\ No newline at end of file
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/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py
index 0b99bba13..87edc2106 100644
--- a/src/registrar/utility/admin_helpers.py
+++ b/src/registrar/utility/admin_helpers.py
@@ -2,23 +2,66 @@
from django.template.loader import get_template
-def get_all_action_needed_reason_emails(request, domain_request):
+def get_all_action_needed_reason_emails(domain_request):
"""Returns a dictionary of every action needed reason and its associated email
for this particular domain request."""
+ return _get_all_default_emails(
+ reasons=DomainRequest.ActionNeededReasons,
+ # Where the emails are stored. This assumes that it contains a list of .txt files with the reason.
+ path_root="emails/action_needed_reasons",
+ # What reasons don't send out emails (none is handled automagically)
+ excluded_reasons=[DomainRequest.ActionNeededReasons.OTHER],
+ # Who to send it to, and from what domain request to reference
+ domain_request=domain_request
+ )
+
+def get_action_needed_reason_default_email(domain_request, action_needed_reason):
+ """Returns the default email associated with the given action needed reason"""
+ return _get_default_email(
+ domain_request,
+ path_root="emails/rejection_reasons",
+ reason=action_needed_reason,
+ excluded_reasons=[DomainRequest.ActionNeededReasons.OTHER]
+ )
+
+
+def get_all_rejection_reason_emails(domain_request):
+ """Returns a dictionary of every rejection reason and its associated email
+ for this particular domain request."""
+ return _get_all_default_emails(
+ reasons=DomainRequest.RejectionReasons,
+ # Where the emails are stored. This assumes that it contains a list of .txt files with the reason.
+ path_root="emails/rejection_reasons",
+ # What reasons don't send out emails (none is handled automagically)
+ excluded_reasons=[DomainRequest.RejectionReasons.OTHER],
+ # Who to send it to, and from what domain request to reference
+ domain_request=domain_request
+ )
+
+
+def get_rejection_reason_default_email(domain_request, action_needed_reason):
+ """Returns the default email associated with the given rejection reason"""
+ return _get_default_email(
+ domain_request,
+ path_root="emails/rejection_reasons",
+ reason=action_needed_reason,
+ excluded_reasons=[DomainRequest.RejectionReasons.OTHER]
+ )
+
+def _get_all_default_emails(reasons, path_root, excluded_reasons, 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
+ for reason in reasons:
+ # Map the reason to its default email
+ emails[reason.value] = _get_default_email(
+ domain_request, path_root, reason, excluded_reasons
)
- return emails
-
+def _get_default_email(domain_request, path_root, reason, excluded_reasons=None):
+ if not reason:
+ return None
-def get_action_needed_reason_default_email(request, 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:
+ if excluded_reasons and reason in excluded_reasons:
return None
recipient = domain_request.creator
@@ -26,7 +69,7 @@ def get_action_needed_reason_default_email(request, domain_request, action_neede
context = {"domain_request": domain_request, "recipient": recipient}
# Get the email body
- template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
+ template_path = f"{path_root}/{reason}.txt"
email_body_text = get_template(template_path).render(context=context)
email_body_text_cleaned = None
diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py
index 6a6269baa..973f85855 100644
--- a/src/registrar/views/utility/api_views.py
+++ b/src/registrar/views/utility/api_views.py
@@ -90,5 +90,5 @@ 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)
+ emails = get_all_action_needed_reason_emails(domain_request)
return JsonResponse({"action_needed_email": emails.get(reason)}, status=200)
From 48b9206ffc2ff992361c49f9a549defd37471057 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 27 Sep 2024 09:07:15 -0600
Subject: [PATCH 02/34] migration
---
...129_domainrequest_rejection_reason_email.py | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
create mode 100644 src/registrar/migrations/0129_domainrequest_rejection_reason_email.py
diff --git a/src/registrar/migrations/0129_domainrequest_rejection_reason_email.py b/src/registrar/migrations/0129_domainrequest_rejection_reason_email.py
new file mode 100644
index 000000000..6aaef7f87
--- /dev/null
+++ b/src/registrar/migrations/0129_domainrequest_rejection_reason_email.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.10 on 2024-09-26 21:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0128_alter_domaininformation_state_territory_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="domainrequest",
+ name="rejection_reason_email",
+ field=models.TextField(blank=True, null=True),
+ ),
+ ]
From 9b23262d61c094e307e2106e41e7f664f9fc0732 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 27 Sep 2024 11:34:45 -0600
Subject: [PATCH 03/34] Initial architecture for rejection reason
---
src/registrar/admin.py | 2 +-
src/registrar/assets/js/get-gov-admin.js | 149 +++++++++++++++---
src/registrar/config/urls.py | 6 +
src/registrar/models/domain_request.py | 56 ++++---
.../admin/domain_request_change_form.html | 2 +
.../admin/includes/detail_table_fieldset.html | 88 +++++++++++
.../contacts_not_verified.txt | 4 +-
.../rejection_reasons/naming_not_met.txt | 4 +-
.../rejection_reasons/org_has_domain.txt | 4 +-
.../rejection_reasons/org_not_eligible.txt | 4 +-
.../emails/rejection_reasons/other.txt | 15 --
.../rejection_reasons/purpose_not_met.txt | 4 +-
.../requestor_not_eligible.txt | 4 +-
src/registrar/utility/admin_helpers.py | 15 +-
src/registrar/views/utility/api_views.py | 31 +++-
15 files changed, 306 insertions(+), 82 deletions(-)
delete mode 100644 src/registrar/templates/emails/rejection_reasons/other.txt
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index b524f9d0a..a5066d3b0 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1994,7 +1994,7 @@ def _handle_rejection_reason(self, request, obj, original_obj):
# Set the rejection_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_rejection_reason_default_email(obj, obj.action_needed_reason)
+ default_email = get_rejection_reason_default_email(obj, obj.rejection_reason)
if obj.rejection_reason_email:
emails = get_all_rejection_reason_emails(obj)
is_custom_email = obj.rejection_reason_email not in emails.values()
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 7a7b63ba4..95b28660f 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -348,7 +348,7 @@ function initializeWidgetOnList(list, parentId) {
* status select and to show/hide the rejection reason
*/
(function (){
- let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
+ 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
@@ -501,7 +501,7 @@ function initializeWidgetOnList(list, parentId) {
})();
class CustomizableEmailBase {
- constructor(dropdown, textarea, textareaPlaceholder, directEditButton, modalTrigger, modalConfirm, formLabel, lastSentEmailContent, apiUrl) {
+ constructor(dropdown, textarea, textareaPlaceholder, directEditButton, modalTrigger, modalConfirm, formLabel, lastSentEmailContent, apiUrl, textAreaFormGroup, dropdownFormGroup) {
this.dropdown = dropdown;
this.textarea = textarea;
this.textareaPlaceholder = textareaPlaceholder;
@@ -512,6 +512,11 @@ class CustomizableEmailBase {
this.lastSentEmailContent = lastSentEmailContent;
this.apiUrl = apiUrl;
+ // These fields are hidden on pageload
+ this.textAreaFormGroup = textAreaFormGroup;
+ this.dropdownFormGroup = dropdownFormGroup;
+ 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;
@@ -520,11 +525,47 @@ class CustomizableEmailBase {
if (lastSentEmailContent && textarea) {
this.isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
}
+
+ }
+
+ // Handle showing/hiding the related fields on page load.
+ initializeFormGroups(statusToCheck, sessionVariableName) {
+ let isStatus = statusSelect.value == statusToCheck;
+
+ // Initial handling of these groups.
+ updateFormGroupVisibility(isStatus, isStatus);
+
+ // 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 = statusSelect.value == statusToCheck;
+ updateFormGroupVisibility(isStatus, isStatus);
+ addOrRemoveSessionBoolean(sessionVariableName, add=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(sessionVariableName) !== null;
+ updateFormGroupVisibility(showTextAreaFormGroup, isStatus);
+ }
+ });
+ });
+ observer.observe({ type: "navigation" });
+ }
+
+ updateFormGroupVisibility(showTextAreaFormGroup, showdropDownFormGroup) {
+ showTextAreaFormGroup ? showElement(this.textAreaFormGroup) : hideElement(this.textAreaFormGroup);
+ showdropDownFormGroup ? showElement(this.dropdownFormGroup) : hideElement(this.dropdownFormGroup);
}
initializeDropdown(errorMessage) {
this.dropdown.addEventListener("change", () => {
- console.log(this.dropdown)
let reason = this.dropdown.value;
if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
let searchParams = new URLSearchParams(
@@ -542,7 +583,7 @@ class CustomizableEmailBase {
if (data.error) {
console.error("Error in AJAX call: " + data.error);
}else {
- this.textarea.value = data.action_needed_email;
+ this.textarea.value = data.email;
}
this.updateUserInterface(reason);
})
@@ -578,32 +619,47 @@ class CustomizableEmailBase {
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
- this.showPlaceholder("Email:", "Select an action needed reason to see email");
+ this.showPlaceholderNoReason();
} 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
- this.showPlaceholder("Email:", "No email will be sent");
+ this.showPlaceholderOtherReason();
} else {
- // 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);
- }
+ this.showReadonlyTextarea();
+ }
+ }
- if (this.isEmailAlreadySent()) {
- this.formLabel.innerHTML = "Email sent to creator:";
- } else {
- this.formLabel.innerHTML = "Email:";
- }
+ // 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:";
}
}
+ // 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;
@@ -629,6 +685,11 @@ class customActionNeededEmail extends CustomizableEmailBase {
let apiContainer = document.getElementById("get-action-needed-email-for-user-json")
const apiUrl = apiContainer ? apiContainer.value : null;
+
+ // These fields are hidden on pageload
+ const textAreaFormGroup = document.querySelector('.field-action_needed_reason');
+ const dropdownFormGroup = document.querySelector('.field-action_needed_reason_email');
+
super(
dropdown,
textarea,
@@ -638,16 +699,32 @@ class customActionNeededEmail extends CustomizableEmailBase {
modalConfirm,
formLabel,
lastSentEmailContent,
- apiUrl
+ apiUrl,
+ textAreaFormGroup,
+ dropdownFormGroup,
);
+
}
loadActionNeededEmail() {
+ if (this.textAreaFormGroup && this.dropdownFormGroup) {
+ this.initializeFormGroups("action needed", "showActionNeededReason");
+ }
this.updateUserInterface(this.dropdown.value);
this.initializeDropdown("Error when attempting to grab action needed email: ")
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.
@@ -675,8 +752,13 @@ class customRejectedEmail extends CustomizableEmailBase {
const formLabel = document.querySelector('label[for="id_rejection_reason_email"]');
const lastSentEmailContent = document.getElementById("last-sent-email-content");
- let apiContainer = document.getElementById("get-rejection-reason-email-for-user-json")
+ let apiContainer = document.getElementById("get-rejection-email-for-user-json");
const apiUrl = apiContainer ? apiContainer.value : null;
+
+ // These fields are hidden on pageload
+ const textAreaFormGroup = document.querySelector('.field-rejection_reason');
+ const dropdownFormGroup = document.querySelector('.field-rejection_reason_email');
+
super(
dropdown,
textarea,
@@ -686,16 +768,31 @@ class customRejectedEmail extends CustomizableEmailBase {
modalConfirm,
formLabel,
lastSentEmailContent,
- apiUrl
+ apiUrl,
+ textAreaFormGroup,
+ dropdownFormGroup,
);
}
loadRejectedEmail() {
+ if (this.textAreaFormGroup && this.dropdownFormGroup) {
+ this.initializeFormGroups("rejected", "showRejectionReason");
+ }
this.updateUserInterface(this.dropdown.value);
this.initializeDropdown("Error when attempting to grab rejected email: ")
this.initializeModalConfirm()
this.initializeDirectEditButton()
}
+
+ // Overrides the placeholder text when no reason is selected
+ showPlaceholderNoReason() {
+ this.showPlaceholder("Email:", "Select a rejection reason to see email");
+ }
+
+ // Overrides the placeholder text when the reason other is selected
+ showPlaceholderOtherReason() {
+ this.showPlaceholder("Email:", "No email will be sent");
+ }
}
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index 76c77955f..eace14d4a 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -29,6 +29,7 @@
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.domains_json import get_domains_json
from registrar.views.utility import always_404
@@ -159,6 +160,11 @@
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/models/domain_request.py b/src/registrar/models/domain_request.py
index b1cc00bde..c00953736 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -640,15 +640,16 @@ def sync_organization_type(self):
# 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"""
@@ -662,21 +663,42 @@ def save(self, *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()
+ if self.status == self.DomainRequestStatus.ACTION_NEEDED:
+ self.send_another_status_reason_email(
+ checked_status=self.DomainRequestStatus.ACTION_NEEDED,
+ old_reason=self._cached_action_needed_reason,
+ new_reason=self.action_needed_reason,
+ excluded_reasons=[DomainRequest.ActionNeededReasons.OTHER],
+ email_to_send=self.action_needed_reason_email
+ )
+ elif self.status == self.DomainRequestStatus.REJECTED:
+ self.send_another_status_reason_email(
+ checked_status=self.DomainRequestStatus.REJECTED,
+ old_reason=self._cached_rejection_reason,
+ new_reason=self.rejection_reason,
+ excluded_reasons=[DomainRequest.RejectionReasons.OTHER],
+ email_to_send=self.rejection_reason_email,
+ )
# Update the cached values after saving
- self._cache_status_and_action_needed_reason()
-
- 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)
+ self._cache_status_and_status_reasons()
+
+ def send_another_status_reason_email(self, checked_status, old_reason, new_reason, excluded_reasons, email_to_send):
+ """Helper function to send out a second status email when the status remains the same,
+ but the reason has changed."""
+
+ # If the status itself changed, then we already sent out an email
+ if self._cached_status != checked_status or old_reason is None:
+ 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 new_reason is None or self.action_needed_reason in excluded_reasons:
+ return
+
+ # Only send out an email if the underlying email itself changed
+ if old_reason != new_reason:
+ self._send_custom_status_update_email(email_content=email_to_send)
def sync_yes_no_form_fields(self):
"""Some yes/no forms use a db field to track whether it was checked or not.
@@ -806,9 +828,7 @@ def _send_status_update_email(
def _send_custom_status_update_email(self, email_content):
"""Wrapper for `_send_status_update_email` that bcc's help@get.gov
and sends an email equivalent to the 'email_content' variable."""
- if settings.IS_PRODUCTION:
- bcc_address = settings.DEFAULT_FROM_EMAIL
-
+ bcc_address = settings.DEFAULT_FROM_EMAIL if settings.IS_PRODUCTION else ""
self._send_status_update_email(
new_status="action needed",
email_template=f"emails/includes/custom_email.txt",
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 0540a7b60..8f4e65ddc 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -226,6 +226,94 @@
+ 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_reason_email %}
+
+ {% else %}
+
+ {% endif %}
{% else %}
{{ field.field }}
{% endif %}
diff --git a/src/registrar/templates/emails/rejection_reasons/contacts_not_verified.txt b/src/registrar/templates/emails/rejection_reasons/contacts_not_verified.txt
index c35c82c2b..525a3a00a 100644
--- a/src/registrar/templates/emails/rejection_reasons/contacts_not_verified.txt
+++ b/src/registrar/templates/emails/rejection_reasons/contacts_not_verified.txt
@@ -1,8 +1,8 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
-{% include "emails/includes/status_change_rejected_header.html" %}
+{% include "emails/includes/status_change_rejected_header.txt" %}
REJECTION REASON
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.
-{% include "emails/includes/email_footer.html" %}
+{% include "emails/includes/email_footer.txt" %}
{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/naming_not_met.txt b/src/registrar/templates/emails/rejection_reasons/naming_not_met.txt
index 3e57d579d..eb2e5e4c0 100644
--- a/src/registrar/templates/emails/rejection_reasons/naming_not_met.txt
+++ b/src/registrar/templates/emails/rejection_reasons/naming_not_met.txt
@@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
-{% include "emails/includes/status_change_rejected_header.html" %}
+{% include "emails/includes/status_change_rejected_header.txt" %}
REJECTION REASON
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
@@ -11,5 +11,5 @@ 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.
-{% include "emails/includes/email_footer.html" %}
+{% include "emails/includes/email_footer.txt" %}
{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/org_has_domain.txt b/src/registrar/templates/emails/rejection_reasons/org_has_domain.txt
index 26757efd6..175518ac3 100644
--- a/src/registrar/templates/emails/rejection_reasons/org_has_domain.txt
+++ b/src/registrar/templates/emails/rejection_reasons/org_has_domain.txt
@@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
-{% include "emails/includes/status_change_rejected_header.html" %}
+{% include "emails/includes/status_change_rejected_header.txt" %}
REJECTION REASON
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
@@ -11,5 +11,5 @@ Read more about our practice of approving one domain per online service
If you have questions or comments, reply to this email.
-{% include "emails/includes/email_footer.html" %}
+{% include "emails/includes/email_footer.txt" %}
{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/org_not_eligible.txt b/src/registrar/templates/emails/rejection_reasons/org_not_eligible.txt
index 3c7de3f42..606184706 100644
--- a/src/registrar/templates/emails/rejection_reasons/org_not_eligible.txt
+++ b/src/registrar/templates/emails/rejection_reasons/org_not_eligible.txt
@@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
-{% include "emails/includes/status_change_rejected_header.html" %}
+{% include "emails/includes/status_change_rejected_header.txt" %}
REJECTION REASON
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
@@ -10,5 +10,5 @@ Learn more about eligibility for .gov domains
If you have questions or comments, reply to this email.
-{% include "emails/includes/email_footer.html" %}
+{% include "emails/includes/email_footer.txt" %}
{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/other.txt b/src/registrar/templates/emails/rejection_reasons/other.txt
deleted file mode 100644
index 6835a45e0..000000000
--- a/src/registrar/templates/emails/rejection_reasons/other.txt
+++ /dev/null
@@ -1,15 +0,0 @@
-{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
-{% include "emails/includes/status_change_rejected_header.html" %}
-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.
-
-Learn more about:
-- Eligibility for a .gov domain
-- Choosing a .gov domain name
-
-
-NEED ASSISTANCE?
-If you have questions about this domain request or need help choosing a new domain name, reply to this email.
-
-{% include "emails/includes/email_footer.html" %}
-{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/purpose_not_met.txt b/src/registrar/templates/emails/rejection_reasons/purpose_not_met.txt
index 57bce78f0..d95a9e0b3 100644
--- a/src/registrar/templates/emails/rejection_reasons/purpose_not_met.txt
+++ b/src/registrar/templates/emails/rejection_reasons/purpose_not_met.txt
@@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
-{% include "emails/includes/status_change_rejected_header.html" %}
+{% include "emails/includes/status_change_rejected_header.txt" %}
REJECTION REASON
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
@@ -11,5 +11,5 @@ Learn more about:
If you have questions or comments, reply to this email.
-{% include "emails/includes/email_footer.html" %}
+{% include "emails/includes/email_footer.txt" %}
{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/templates/emails/rejection_reasons/requestor_not_eligible.txt b/src/registrar/templates/emails/rejection_reasons/requestor_not_eligible.txt
index 7974c1690..deeb2d9da 100644
--- a/src/registrar/templates/emails/rejection_reasons/requestor_not_eligible.txt
+++ b/src/registrar/templates/emails/rejection_reasons/requestor_not_eligible.txt
@@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
-{% include "emails/includes/status_change_rejected_header.html" %}
+{% include "emails/includes/status_change_rejected_header.txt" %}
REJECTION REASON
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
@@ -10,5 +10,5 @@ DEMONSTRATE ELIGIBILITY
If you can provide more information that demonstrates your eligibility, or you want to
discuss further, reply to this email.
-{% include "emails/includes/email_footer.html" %}
+{% include "emails/includes/email_footer.txt" %}
{% endautoescape %}
\ No newline at end of file
diff --git a/src/registrar/utility/admin_helpers.py b/src/registrar/utility/admin_helpers.py
index 87edc2106..20760164e 100644
--- a/src/registrar/utility/admin_helpers.py
+++ b/src/registrar/utility/admin_helpers.py
@@ -19,9 +19,9 @@ def get_all_action_needed_reason_emails(domain_request):
def get_action_needed_reason_default_email(domain_request, action_needed_reason):
"""Returns the default email associated with the given action needed reason"""
return _get_default_email(
- domain_request,
- path_root="emails/rejection_reasons",
- reason=action_needed_reason,
+ domain_request,
+ path_root="emails/action_needed_reasons",
+ reason=action_needed_reason,
excluded_reasons=[DomainRequest.ActionNeededReasons.OTHER]
)
@@ -40,12 +40,12 @@ def get_all_rejection_reason_emails(domain_request):
)
-def get_rejection_reason_default_email(domain_request, action_needed_reason):
+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,
- path_root="emails/rejection_reasons",
- reason=action_needed_reason,
+ domain_request,
+ path_root="emails/rejection_reasons",
+ reason=rejection_reason,
excluded_reasons=[DomainRequest.RejectionReasons.OTHER]
)
@@ -56,6 +56,7 @@ def _get_all_default_emails(reasons, path_root, excluded_reasons, domain_request
emails[reason.value] = _get_default_email(
domain_request, path_root, reason, excluded_reasons
)
+ return emails
def _get_default_email(domain_request, path_root, reason, excluded_reasons=None):
if not reason:
diff --git a/src/registrar/views/utility/api_views.py b/src/registrar/views/utility/api_views.py
index 973f85855..bef6e5017 100644
--- a/src/registrar/views/utility/api_views.py
+++ b/src/registrar/views/utility/api_views.py
@@ -4,7 +4,7 @@
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
@@ -90,5 +90,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(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)
From 1ce0724d3401358dbb94765c6be2d2d21e6d0171 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Fri, 27 Sep 2024 11:52:47 -0600
Subject: [PATCH 04/34] Cleanup + send email logic
---
src/registrar/assets/js/get-gov-admin.js | 78 ++-----------------
src/registrar/models/domain_request.py | 6 --
.../admin/includes/detail_table_fieldset.html | 8 +-
src/registrar/utility/email.py | 2 +-
4 files changed, 13 insertions(+), 81 deletions(-)
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 95b28660f..17304ce97 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
*/
@@ -530,17 +467,17 @@ class CustomizableEmailBase {
// Handle showing/hiding the related fields on page load.
initializeFormGroups(statusToCheck, sessionVariableName) {
- let isStatus = statusSelect.value == statusToCheck;
+ let isStatus = this.statusSelect.value == statusToCheck;
// Initial handling of these groups.
- updateFormGroupVisibility(isStatus, isStatus);
+ this.updateFormGroupVisibility(isStatus, isStatus);
// 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 = statusSelect.value == statusToCheck;
- updateFormGroupVisibility(isStatus, isStatus);
+ isStatus = this.statusSelect.value == statusToCheck;
+ this.updateFormGroupVisibility(isStatus, isStatus);
addOrRemoveSessionBoolean(sessionVariableName, add=isStatus);
});
@@ -552,7 +489,7 @@ class CustomizableEmailBase {
list.getEntries().forEach((entry) => {
if (entry.type === "back_forward") {
let showTextAreaFormGroup = sessionStorage.getItem(sessionVariableName) !== null;
- updateFormGroupVisibility(showTextAreaFormGroup, isStatus);
+ this.updateFormGroupVisibility(showTextAreaFormGroup, isStatus);
}
});
});
@@ -681,7 +618,7 @@ class customActionNeededEmail extends CustomizableEmailBase {
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"]');
- const lastSentEmailContent = document.getElementById("last-sent-email-content");
+ const lastSentEmailContent = document.getElementById("last-sent-action-needed-email-content");
let apiContainer = document.getElementById("get-action-needed-email-for-user-json")
const apiUrl = apiContainer ? apiContainer.value : null;
@@ -750,7 +687,7 @@ class customRejectedEmail extends CustomizableEmailBase {
const modalTrigger = document.querySelector('.field-rejection_reason_email__modal-trigger');
const modalConfirm = document.getElementById('confirm-edit-email');
const formLabel = document.querySelector('label[for="id_rejection_reason_email"]');
- const lastSentEmailContent = document.getElementById("last-sent-email-content");
+ const lastSentEmailContent = document.getElementById("last-sent-rejection-email-content");
let apiContainer = document.getElementById("get-rejection-email-for-user-json");
const apiUrl = apiContainer ? apiContainer.value : null;
@@ -776,6 +713,7 @@ class customRejectedEmail extends CustomizableEmailBase {
loadRejectedEmail() {
if (this.textAreaFormGroup && this.dropdownFormGroup) {
+ // TODO: fix this for rejected
this.initializeFormGroups("rejected", "showRejectionReason");
}
this.updateUserInterface(this.dropdown.value);
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index c00953736..72e0e4773 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -1065,12 +1065,6 @@ def reject(self):
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",
- )
-
# Send out an email if a rejection reason exists
if self.rejection_reason:
email_content = self.rejection_reason_email
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 8f4e65ddc..0bef5d10d 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -221,9 +221,9 @@