mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-04 10:13:30 +02:00
Merge remote-tracking branch 'origin/main' into nl/2136-CISA-rep-additional-details
This commit is contained in:
commit
f2f29d1492
16 changed files with 455 additions and 46 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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", "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
|
||||
|
@ -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(
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 you’ll 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 you’re attempting to claim an additional domain to prevent others from obtaining it, that’s not necessary. .Gov domains are only available to U.S.-based government organizations, and we don’t 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 you’re 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 you’re requesting an additional domain and not replacing your existing one, we’ll 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 won’t 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 %}
|
|
@ -0,0 +1 @@
|
|||
Update on your .gov request: {{ domain_request.requested_domain.name }}
|
|
@ -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 you’ll 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, we’ll 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 %}
|
|
@ -0,0 +1 @@
|
|||
Update on your .gov request: {{ domain_request.requested_domain.name }}
|
|
@ -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 you’ll 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 can’t 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 %}
|
|
@ -0,0 +1 @@
|
|||
Update on your .gov request: {{ domain_request.requested_domain.name }}
|
|
@ -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 you’ll 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, we’ll 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 %}
|
|
@ -0,0 +1 @@
|
|||
Update on your .gov request: {{ domain_request.requested_domain.name }}
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue