Initial architecture for rejection reason

This commit is contained in:
zandercymatics 2024-09-27 11:34:45 -06:00
parent 48b9206ffc
commit 9b23262d61
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
15 changed files with 306 additions and 82 deletions

View file

@ -1994,7 +1994,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# 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()

View file

@ -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,11 +619,17 @@ 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 {
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);
@ -602,6 +649,15 @@ class CustomizableEmailBase {
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) {
@ -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");
}
}

View file

@ -29,6 +29,7 @@ from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json,
get_federal_and_portfolio_types_from_federal_agency_json,
get_action_needed_email_for_user_json,
get_rejection_email_for_user_json,
)
from registrar.views.domains_json import get_domains_json
from registrar.views.utility import always_404
@ -159,6 +160,11 @@ urlpatterns = [
get_action_needed_email_for_user_json,
name="get-action-needed-email-for-user-json",
),
path(
"admin/api/get-rejection-email-for-user-json/",
get_rejection_email_for_user_json,
name="get-rejection-email-for-user-json",
),
path("admin/", admin.site.urls),
path(
"reports/export_data_type_user/",

View file

@ -640,15 +640,16 @@ class DomainRequest(TimeStampedModel):
# Actually updates the organization_type field
org_type_helper.create_or_update_organization_type()
def _cache_status_and_action_needed_reason(self):
def _cache_status_and_status_reasons(self):
"""Maintains a cache of properties so we can avoid a DB call"""
self._cached_action_needed_reason = self.action_needed_reason
self._cached_rejection_reason = self.rejection_reason
self._cached_status = self.status
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Store original values for caching purposes. Used to compare them on save.
self._cache_status_and_action_needed_reason()
self._cache_status_and_status_reasons()
def save(self, *args, **kwargs):
"""Save override for custom properties"""
@ -662,21 +663,42 @@ class DomainRequest(TimeStampedModel):
# 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()
self._cache_status_and_status_reasons()
def sync_action_needed_reason(self):
"""Checks if we need to send another action needed email"""
was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED
reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None
reason_changed = self._cached_action_needed_reason != self.action_needed_reason
if was_already_action_needed and reason_exists and reason_changed:
# We don't send emails out in state "other"
if self.action_needed_reason != self.ActionNeededReasons.OTHER:
self._send_action_needed_reason_email(email_content=self.action_needed_reason_email)
def send_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 @@ class DomainRequest(TimeStampedModel):
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",

View file

@ -10,6 +10,8 @@
<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 %}
<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 %}
{% comment %}
TODO: this will eventually need to be changed to something like this

View file

@ -226,6 +226,94 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<input id="last-sent-email-content" class="display-none" value="None">
{% endif %}
{% elif field.field.name == "rejection_reason_email" %}
<div class="margin-top-05 text-faded field-rejection_reason_email__placeholder">
&ndash;
</div>
{{ field.field }}
<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 field-rejection_reason_email__edit flex-align-self-start"
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
>
<a
href="#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-rejection_reason_email__modal-trigger flex-align-self-start"
aria-controls="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="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"
class="usa-button"
id="confirm-edit-email"
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_reason_email %}
<input id="last-sent-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
{% else %}
<input id="last-sent-email-content" class="display-none" value="None">
{% endif %}
{% else %}
{{ field.field }}
{% endif %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 <https://get.gov/domains/eligibility>
- Choosing a .gov domain name <https://get.gov/domains/choosing>
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 %}

View file

@ -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 didnt 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 %}

View file

@ -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 dont believe youre 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 %}

View file

@ -20,7 +20,7 @@ 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",
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,
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:

View file

@ -4,7 +4,7 @@ from django.forms.models import model_to_dict
from registrar.models import FederalAgency, SeniorOfficial, DomainRequest
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_required
from registrar.utility.admin_helpers import get_all_action_needed_reason_emails
from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email
from registrar.models.portfolio import Portfolio
from registrar.utility.constants import BranchChoices
@ -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)