Merge remote-tracking branch 'origin/main' into nl/2136-CISA-rep-additional-details

This commit is contained in:
CocoByte 2024-06-12 12:45:04 -06:00
commit f2f29d1492
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
16 changed files with 455 additions and 46 deletions

View file

@ -217,6 +217,7 @@ class DomainRequestAdminForm(forms.ModelForm):
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)
@ -240,6 +241,8 @@ class DomainRequestAdminForm(forms.ModelForm):
# 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
@ -263,6 +266,18 @@ class DomainRequestAdminForm(forms.ModelForm):
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.
@ -1466,6 +1481,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"fields": [
"status",
"rejection_reason",
"action_needed_reason",
"investigator",
"creator",
"submitter",
@ -1672,6 +1688,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# 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

View file

@ -300,25 +300,29 @@ 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
@ -328,14 +332,34 @@ function initializeWidgetOnList(list, parentId) {
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';
let showRejectionReason = sessionStorage.getItem("showRejectionReason") !== null
showOrHideObject(rejectionReasonFormGroup, show=showRejectionReason)
let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null
showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason)
}
});
});
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

View file

@ -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", "Doesnt meet naming requirements"),
("other", "Other (no auto-email sent)"),
],
null=True,
),
),
]

View file

@ -1,6 +1,5 @@
from __future__ import annotations
from typing import Union
import logging
from django.apps import apps
@ -250,6 +249,15 @@ class DomainRequest(TimeStampedModel):
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", "Doesnt 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
@ -263,6 +271,12 @@ class DomainRequest(TimeStampedModel):
blank=True,
)
action_needed_reason = models.TextField(
choices=ActionNeededReasons.choices,
null=True,
blank=True,
)
federal_agency = models.ForeignKey(
"registrar.FederalAgency",
on_delete=models.PROTECT,
@ -539,6 +553,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):
"""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()
@ -546,6 +570,23 @@ class DomainRequest(TimeStampedModel):
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().
@ -598,7 +639,7 @@ class DomainRequest(TimeStampedModel):
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.
@ -625,6 +666,7 @@ class DomainRequest(TimeStampedModel):
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:
@ -708,9 +750,10 @@ class DomainRequest(TimeStampedModel):
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
@ -728,7 +771,7 @@ class DomainRequest(TimeStampedModel):
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.
@ -740,8 +783,7 @@ class DomainRequest(TimeStampedModel):
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
@ -749,6 +791,46 @@ class DomainRequest(TimeStampedModel):
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=[
@ -797,6 +879,8 @@ class DomainRequest(TimeStampedModel):
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(

View file

@ -69,8 +69,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% 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">

View file

@ -0,0 +1,51 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
STATUS: Action needed
----------------------------------------------------------------
ORGANIZATION ALREADY HAS A .GOV DOMAIN
We've reviewed your domain request, but your organization already has at least one other .gov domain. We need more information about your rationale for registering another .gov domain.
In general, there are two reasons we will approve an additional domain:
- You determine a current .gov domain name will be replaced
- We determine an additional domain name is appropriate
WE LIMIT ADDITIONAL DOMAIN NAMES
Our practice is to only approve one domain per online service per government organization, evaluating additional requests on a case-by-case basis.
There are two core reasons we limit additional domains:
- We want to minimize your operational and security load, which increases with each additional domain.
- Fewer domains allow us to take protective, namespace-wide security actions faster and without undue dependencies.
If youre attempting to claim an additional domain to prevent others from obtaining it, thats not necessary. .Gov domains are only available to U.S.-based government organizations, and we dont operate on a first come, first served basis. We'll only assign a domain to the organization whose real name or services actually correspond to the domain name.
CONSIDER USING A SUBDOMAIN
Using a subdomain of an existing domain (e.g., service.domain.gov) is a common approach to logically divide your namespace while still maintaining an association with your existing domain name. Subdomains can also be delegated to allow an affiliated entity to manage their own DNS settings.
ACTION NEEDED
FOR A REPLACEMENT DOMAIN: If youre requesting a new domain that will replace your current domain name, we can allow for a transition period where both are registered to your organization. Afterwards, we will reclaim and retire the legacy name.
Reply to this email. Tell us how many months your organization needs to maintain your current .gov domain and conduct a transition to a new one. Detail why that period of time is needed.
FOR AN ADDITIONAL DOMAIN: If youre requesting an additional domain and not replacing your existing one, well need more information to support that request.
Reply to this email. Detail why you believe another domain is necessary for your organization, and why a subdomain wont meet your needs.
If you have questions or comments, include those in your reply.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
Update on your .gov request: {{ domain_request.requested_domain.name }}

View file

@ -0,0 +1,34 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
STATUS: Action needed
----------------------------------------------------------------
DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS
We've reviewed your domain request and, unfortunately, it does not meet our naming requirements.
Domains should uniquely identify a government organization and be clear to the general public. Read more about naming requirements for your type of organization <https://get.gov/domains/choosing/>.
ACTION NEEDED
First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. <https://manage.get.gov/> Once you submit your updated request, well resume the adjudication process.
If you have questions or want to discuss potential domain names, reply to this email.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
Update on your .gov request: {{ domain_request.requested_domain.name }}

View file

@ -0,0 +1,35 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
STATUS: Action needed
----------------------------------------------------------------
ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS
We've reviewed your domain request, but we need more information about the organization you represent:
- {{ domain_request.organization_name }}
.Gov domains are only available to official US-based government organizations, not simply those that provide a public benefit. We lack clear documentation that demonstrates your organization is eligible for a .gov domain.
ACTION NEEDED
Reply to this email with links to (or copies of) your authorizing legislation, your founding charter or bylaws, recent election results, or other similar documentation. Without this, we cant continue our review and your request will likely be rejected.
If you have questions or comments, include those in your reply.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
Update on your .gov request: {{ domain_request.requested_domain.name }}

View file

@ -0,0 +1,36 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.
DOMAIN REQUESTED: {{ domain_request.requested_domain.name }}
REQUEST RECEIVED ON: {{ domain_request.submission_date|date }}
STATUS: Action needed
----------------------------------------------------------------
AUTHORIZING OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS
We've reviewed your domain request, but we need more information about the authorizing official listed on the request:
- {{ domain_request.authorizing_official.get_formatted_name }}
- {{ domain_request.authorizing_official.title }}
We expect an authorizing official to be someone in a role of significant, executive responsibility within the organization. Our guidelines are open-ended to accommodate the wide variety of government organizations that are eligible for .gov domains, but the person you listed does not meet our expectations for your type of organization. Read more about our guidelines for authorizing officials. <https://get.gov/domains/eligibility/>
ACTION NEEDED
Reply to this email with a justification for naming {{ domain_request.authorizing_official.get_formatted_name }} as the authorizing official. If you have questions or comments, include those in your reply.
Alternatively, you can log in to the registrar and enter a different authorizing official for this domain request. <https://manage.get.gov/> Once you submit your updated request, well resume the adjudication process.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <http://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
Update on your .gov request: {{ domain_request.requested_domain.name }}

View file

@ -1445,18 +1445,23 @@ class TestDomainRequestAdmin(MockEppLib):
# The results are filtered by "status in [submitted,in review,action needed]"
self.assertContains(response, "status in [submitted,in review,action needed]", count=1)
def transition_state_and_send_email(self, domain_request, status, rejection_reason=None):
@less_console_noise_decorator
def transition_state_and_send_email(self, domain_request, status, rejection_reason=None, action_needed_reason=None):
"""Helper method for the email test cases."""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a mock request
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
# Modify the domain request's properties
domain_request.status = status
if rejection_reason:
domain_request.rejection_reason = rejection_reason
if action_needed_reason:
domain_request.action_needed_reason = action_needed_reason
# Use the model admin's save_model method
self.admin.save_model(request, domain_request, form=None, change=True)
@ -1493,6 +1498,57 @@ class TestDomainRequestAdmin(MockEppLib):
bcc_email = kwargs["Destination"]["BccAddresses"][0]
self.assertEqual(bcc_email, bcc_email_address)
@override_settings(IS_PRODUCTION=True)
def test_action_needed_sends_reason_email_prod_bcc(self):
"""When an action needed reason is set, an email is sent out and help@get.gov
is BCC'd in production"""
# Ensure there is no user with this email
EMAIL = "mayor@igorville.gov"
BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
User.objects.filter(email=EMAIL).delete()
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED
# Create a sample domain request
domain_request = completed_domain_request(status=in_review)
# Test the email sent out for already_has_domains
already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains)
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
# Test the email sent out for bad_name
bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
self.assert_email_is_accurate(
"DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
# Test the email sent out for eligibility_unclear
eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear)
self.assert_email_is_accurate(
"ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Test the email sent out for questionable_ao
questionable_ao = DomainRequest.ActionNeededReasons.QUESTIONABLE_AUTHORIZING_OFFICIAL
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_ao)
self.assert_email_is_accurate(
"AUTHORIZING OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
# Assert that no other emails are sent on OTHER
other = DomainRequest.ActionNeededReasons.OTHER
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other)
# Should be unchanged from before
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
def test_save_model_sends_submitted_email(self):
"""When transitioning to submitted from started or withdrawn on a domain request,
an email is sent out.
@ -1528,7 +1584,9 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Move it to IN_REVIEW
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.IN_REVIEW)
other = DomainRequest.ActionNeededReasons.OTHER
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Test Submitted Status Again from in IN_REVIEW, no new email should be sent
@ -1536,7 +1594,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Move it to IN_REVIEW
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.IN_REVIEW)
self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Move it to ACTION_NEEDED
@ -1586,7 +1644,9 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Move it to IN_REVIEW
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.IN_REVIEW)
other = domain_request.ActionNeededReasons.OTHER
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Test Submitted Status Again from in IN_REVIEW, no new email should be sent
@ -1594,7 +1654,7 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Move it to IN_REVIEW
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.IN_REVIEW)
self.transition_state_and_send_email(domain_request, in_review, action_needed_reason=other)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Move it to ACTION_NEEDED
@ -2238,6 +2298,7 @@ class TestDomainRequestAdmin(MockEppLib):
"updated_at",
"status",
"rejection_reason",
"action_needed_reason",
"federal_agency",
"creator",
"investigator",
@ -2399,6 +2460,10 @@ class TestDomainRequestAdmin(MockEppLib):
stack.enter_context(patch.object(messages, "error"))
domain_request.status = another_state
if another_state == DomainRequest.DomainRequestStatus.ACTION_NEEDED:
domain_request.action_needed_reason = domain_request.ActionNeededReasons.OTHER
domain_request.rejection_reason = rejection_reason
self.admin.save_model(request, domain_request, None, True)

View file

@ -2,6 +2,7 @@
import boto3
import logging
import textwrap
from datetime import datetime
from django.conf import settings
from django.template.loader import get_template
@ -27,6 +28,7 @@ def send_templated_email(
bcc_address="",
context={},
attachment_file: str = None,
wrap_email=False,
):
"""Send an email built from a template to one email address.
@ -66,6 +68,11 @@ def send_templated_email(
try:
if attachment_file is None:
# Wrap the email body to a maximum width of 80 characters per line.
# Not all email clients support CSS to do this, and our .txt files require parsing.
if wrap_email:
email_body = wrap_text_and_preserve_paragraphs(email_body, width=80)
ses_client.send_email(
FromEmailAddress=settings.DEFAULT_FROM_EMAIL,
Destination=destination,
@ -91,6 +98,26 @@ def send_templated_email(
raise EmailSendingError("Could not send SES email.") from exc
def wrap_text_and_preserve_paragraphs(text, width):
"""
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.
Args:
text (str): Text to wrap.
width (int): Max width per line, default 80.
Returns:
str: Wrapped text with preserved paragraph structure.
"""
# Split text into paragraphs by newlines
paragraphs = text.split("\n")
# Add \n to any line that exceeds our max length
wrapped_paragraphs = [textwrap.fill(paragraph, width=width) for paragraph in paragraphs]
# Join paragraphs with double newlines
return "\n".join(wrapped_paragraphs)
def send_email_with_attachment(sender, recipient, subject, body, attachment_file, ses_client):
# Create a multipart/mixed parent container
msg = MIMEMultipart("mixed")

View file

@ -79,6 +79,7 @@ class FSMErrorCodes(IntEnum):
- 3 INVESTIGATOR_NOT_STAFF Investigator is a non-staff user
- 4 INVESTIGATOR_NOT_SUBMITTER The form submitter is not the investigator
- 5 NO_REJECTION_REASON No rejection reason is specified
- 6 NO_ACTION_NEEDED_REASON No action needed reason is specified
"""
APPROVE_DOMAIN_IN_USE = 1
@ -86,6 +87,7 @@ class FSMErrorCodes(IntEnum):
INVESTIGATOR_NOT_STAFF = 3
INVESTIGATOR_NOT_SUBMITTER = 4
NO_REJECTION_REASON = 5
NO_ACTION_NEEDED_REASON = 6
class FSMDomainRequestError(Exception):
@ -100,6 +102,7 @@ class FSMDomainRequestError(Exception):
FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."),
FSMErrorCodes.INVESTIGATOR_NOT_SUBMITTER: ("Only the assigned investigator can make this change."),
FSMErrorCodes.NO_REJECTION_REASON: ("A rejection reason is required."),
FSMErrorCodes.NO_ACTION_NEEDED_REASON: ("A reason is required for this status."),
}
def __init__(self, *args, code=None, **kwargs):