Skip to content

Commit

Permalink
Merge pull request #2262 from cisagov/gd/1900-add-action-needed-reason
Browse files Browse the repository at this point in the history
(on getgov-gd) Ticket #1900: Add action needed reason
  • Loading branch information
zandercymatics authored Jun 12, 2024
2 parents 6108705 + 482ffc6 commit 512aa3a
Show file tree
Hide file tree
Showing 16 changed files with 455 additions and 46 deletions.
18 changes: 18 additions & 0 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ def clean(self):
status = cleaned_data.get("status")
investigator = cleaned_data.get("investigator")
rejection_reason = cleaned_data.get("rejection_reason")
action_needed_reason = cleaned_data.get("action_needed_reason")

# Get the old status
initial_status = self.initial.get("status", None)
Expand All @@ -240,6 +241,8 @@ def clean(self):
# If the status is rejected, a rejection reason must exist
if status == DomainRequest.DomainRequestStatus.REJECTED:
self._check_for_valid_rejection_reason(rejection_reason)
elif status == DomainRequest.DomainRequestStatus.ACTION_NEEDED:
self._check_for_valid_action_needed_reason(action_needed_reason)

return cleaned_data

Expand All @@ -263,6 +266,18 @@ def _check_for_valid_rejection_reason(self, rejection_reason) -> bool:

return is_valid

def _check_for_valid_action_needed_reason(self, action_needed_reason) -> bool:
"""
Checks if the action_needed_reason field is not none.
Adds form errors on failure.
"""
is_valid = action_needed_reason is not None and action_needed_reason != ""
if not is_valid:
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_ACTION_NEEDED_REASON)
self.add_error("action_needed_reason", error_message)

return is_valid

def _check_for_valid_investigator(self, investigator) -> bool:
"""
Checks if the investigator field is not none, and is staff.
Expand Down Expand Up @@ -1466,6 +1481,7 @@ def custom_election_board(self, obj):
"fields": [
"status",
"rejection_reason",
"action_needed_reason",
"investigator",
"creator",
"submitter",
Expand Down Expand Up @@ -1668,6 +1684,8 @@ def _handle_status_change(self, request, obj, original_obj):
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
# because we clean up the rejection reason in the transition in the model.
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_REJECTION_REASON)
elif obj.status == models.DomainRequest.DomainRequestStatus.ACTION_NEEDED and not obj.action_needed_reason:
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_ACTION_NEEDED_REASON)
else:
# This is an fsm in model which will throw an error if the
# transition condition is violated, so we roll back the
Expand Down
74 changes: 49 additions & 25 deletions src/registrar/assets/js/get-gov-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,42 +300,66 @@ function initializeWidgetOnList(list, parentId) {
*/
(function (){
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');

if (rejectionReasonFormGroup) {
if (rejectionReasonFormGroup && actionNeededReasonFormGroup) {
let statusSelect = document.getElementById('id_status')
let isRejected = statusSelect.value == "rejected"
let isActionNeeded = statusSelect.value == "action needed"

// Initial handling of rejectionReasonFormGroup display
if (statusSelect.value != 'rejected')
rejectionReasonFormGroup.style.display = 'none';
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)

// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
statusSelect.addEventListener('change', function() {
if (statusSelect.value == 'rejected') {
rejectionReasonFormGroup.style.display = 'block';
sessionStorage.removeItem('hideRejectionReason');
} else {
rejectionReasonFormGroup.style.display = 'none';
sessionStorage.setItem('hideRejectionReason', 'true');
// 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)
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)
}
});
});
observer.observe({ type: "navigation" });
}

// 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") {
if (sessionStorage.getItem('hideRejectionReason'))
document.querySelector('.field-rejection_reason').style.display = 'none';
else
document.querySelector('.field-rejection_reason').style.display = 'block';
}
});
});
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");
}
}

// Adds or removes a boolean from our session
function addOrRemoveSessionBoolean(name, add){
if (add) {
sessionStorage.setItem(name, "true");
}else {
sessionStorage.removeItem(name);
}
}
})();

/** An IIFE for toggling the submit bar on domain request forms
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 4.2.10 on 2024-06-12 14:46

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("registrar", "0099_federalagency_federal_type"),
]

operations = [
migrations.AddField(
model_name="domainrequest",
name="action_needed_reason",
field=models.TextField(
blank=True,
choices=[
("eligibility_unclear", "Unclear organization eligibility"),
("questionable_authorizing_official", "Questionable authorizing official"),
("already_has_domains", "Already has domains"),
("bad_name", "Doesn’t meet naming requirements"),
("other", "Other (no auto-email sent)"),
],
null=True,
),
),
]
98 changes: 91 additions & 7 deletions src/registrar/models/domain_request.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations
from typing import Union

import logging

from django.apps import apps
Expand Down Expand Up @@ -250,6 +249,15 @@ class RejectionReasons(models.TextChoices):
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
OTHER = "other", "Other/Unspecified"

class ActionNeededReasons(models.TextChoices):
"""Defines common action needed reasons for domain requests"""

ELIGIBILITY_UNCLEAR = ("eligibility_unclear", "Unclear organization eligibility")
QUESTIONABLE_AUTHORIZING_OFFICIAL = ("questionable_authorizing_official", "Questionable authorizing official")
ALREADY_HAS_DOMAINS = ("already_has_domains", "Already has domains")
BAD_NAME = ("bad_name", "Doesn’t meet naming requirements")
OTHER = ("other", "Other (no auto-email sent)")

# #### Internal fields about the domain request #####
status = FSMField(
choices=DomainRequestStatus.choices, # possible states as an array of constants
Expand All @@ -263,6 +271,12 @@ class RejectionReasons(models.TextChoices):
blank=True,
)

action_needed_reason = models.TextField(
choices=ActionNeededReasons.choices,
null=True,
blank=True,
)

federal_agency = models.ForeignKey(
"registrar.FederalAgency",
on_delete=models.PROTECT,
Expand Down Expand Up @@ -525,13 +539,40 @@ 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):
"""Maintains a cache of properties so we can avoid a DB call"""
self._cached_action_needed_reason = self.action_needed_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()

def save(self, *args, **kwargs):
"""Save override for custom properties"""
self.sync_organization_type()
self.sync_yes_no_form_fields()

super().save(*args, **kwargs)

# Handle the action needed email. We send one when moving to action_needed,
# but we don't send one when we are _already_ in the state and change the reason.
self.sync_action_needed_reason()

# 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()

def sync_yes_no_form_fields(self):
"""Some yes/no forms use a db field to track whether it was checked or not.
We handle that here for def save().
Expand Down Expand Up @@ -583,7 +624,7 @@ def delete_and_clean_up_domain(self, called_from):
logger.error(f"Can't query an approved domain while attempting {called_from}")

def _send_status_update_email(
self, new_status, email_template, email_template_subject, send_email=True, bcc_address=""
self, new_status, email_template, email_template_subject, send_email=True, bcc_address="", wrap_email=False
):
"""Send a status update email to the submitter.
Expand All @@ -610,6 +651,7 @@ def _send_status_update_email(
self.submitter.email,
context={"domain_request": self},
bcc_address=bcc_address,
wrap_email=wrap_email,
)
logger.info(f"The {new_status} email sent to: {self.submitter.email}")
except EmailSendingError:
Expand Down Expand Up @@ -693,9 +735,10 @@ def in_review(self):

if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("in_review")

if self.status == self.DomainRequestStatus.REJECTED:
elif self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None
elif self.status == self.DomainRequestStatus.ACTION_NEEDED:
self.action_needed_reason = None

literal = DomainRequest.DomainRequestStatus.IN_REVIEW
# Check if the tuple exists, then grab its value
Expand All @@ -713,7 +756,7 @@ def in_review(self):
target=DomainRequestStatus.ACTION_NEEDED,
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
)
def action_needed(self):
def action_needed(self, send_email=True):
"""Send back an domain request that is under investigation or rejected.
This action is logged.
Expand All @@ -725,15 +768,54 @@ def action_needed(self):

if self.status == self.DomainRequestStatus.APPROVED:
self.delete_and_clean_up_domain("reject_with_prejudice")

if self.status == self.DomainRequestStatus.REJECTED:
elif self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None

literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED
# Check if the tuple is setup correctly, then grab its value
action_needed = literal if literal is not None else "Action Needed"
logger.info(f"A status change occurred. {self} was changed to '{action_needed}'")

# Send out an email if an action needed reason exists
if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER:
self._send_action_needed_reason_email(send_email)

def _send_action_needed_reason_email(self, send_email=True):
"""Sends out an automatic email for each valid action needed reason provided"""

# Store the filenames of the template and template subject
email_template_name: str = ""
email_template_subject_name: str = ""

# Check for the "type" of action needed reason.
can_send_email = True
match self.action_needed_reason:
# Add to this match if you need to pass in a custom filename for these templates.
case self.ActionNeededReasons.OTHER, _:
# Unknown and other are default cases - do nothing
can_send_email = False

# Assumes that the template name matches the action needed reason if nothing is specified.
# This is so you can override if you need, or have this taken care of for you.
if not email_template_name and not email_template_subject_name:
email_template_name = f"{self.action_needed_reason}.txt"
email_template_subject_name = f"{self.action_needed_reason}_subject.txt"

bcc_address = ""
if settings.IS_PRODUCTION:
bcc_address = settings.DEFAULT_FROM_EMAIL

# If we can, try to send out an email as long as send_email=True
if can_send_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,
wrap_email=True,
)

@transition(
field="status",
source=[
Expand Down Expand Up @@ -782,6 +864,8 @@ def approve(self, send_email=True):

if self.status == self.DomainRequestStatus.REJECTED:
self.rejection_reason = None
elif self.status == self.DomainRequestStatus.ACTION_NEEDED:
self.action_needed_reason = None

# == Send out an email == #
self._send_status_update_email(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@

{% block after_help_text %}
{% if field.field.name == "status" and original_object.history.count > 0 %}
<div class="flex-container">
<label aria-label="Submitter contact details"></label>
<div class="flex-container" id="dja-status-changelog">
<label aria-label="Status changelog"></label>
<div>
<div class="usa-table-container--scrollable collapse--dgsimple" tabindex="0">
<table class="usa-table usa-table--borderless">
Expand Down
Loading

0 comments on commit 512aa3a

Please sign in to comment.