mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-21 10:16:13 +02:00
Merge pull request #1821 from cisagov/rjm/1578-rejection-reason
Issue 1578: Rejection reason (RJM)
This commit is contained in:
commit
ae4d9547ca
7 changed files with 1401 additions and 881 deletions
|
@ -872,7 +872,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
]
|
]
|
||||||
|
|
||||||
# Filters
|
# Filters
|
||||||
list_filter = ("status", "organization_type", InvestigatorFilter)
|
list_filter = ("status", "organization_type", "rejection_reason", InvestigatorFilter)
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
search_fields = [
|
search_fields = [
|
||||||
|
@ -886,7 +886,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
# Detail view
|
# Detail view
|
||||||
form = DomainApplicationAdminForm
|
form = DomainApplicationAdminForm
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
(None, {"fields": ["status", "investigator", "creator", "approved_domain", "notes"]}),
|
(None, {"fields": ["status", "rejection_reason", "investigator", "creator", "approved_domain", "notes"]}),
|
||||||
(
|
(
|
||||||
"Type of organization",
|
"Type of organization",
|
||||||
{
|
{
|
||||||
|
@ -987,6 +987,23 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
||||||
"This action is not permitted. The domain is already active.",
|
"This action is not permitted. The domain is already active.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
obj
|
||||||
|
and obj.status == models.DomainApplication.ApplicationStatus.REJECTED
|
||||||
|
and not obj.rejection_reason
|
||||||
|
):
|
||||||
|
# This condition should never be triggered.
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
# Clear the success message
|
||||||
|
messages.set_level(request, messages.ERROR)
|
||||||
|
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
"A rejection reason is required.",
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if obj.status != original_obj.status:
|
if obj.status != original_obj.status:
|
||||||
status_method_mapping = {
|
status_method_mapping = {
|
||||||
|
|
|
@ -339,3 +339,46 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
|
||||||
|
* status select amd to show/hide the rejection reason
|
||||||
|
*/
|
||||||
|
(function (){
|
||||||
|
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
|
||||||
|
|
||||||
|
if (rejectionReasonFormGroup) {
|
||||||
|
let statusSelect = document.getElementById('id_status')
|
||||||
|
|
||||||
|
// Initial handling of rejectionReasonFormGroup display
|
||||||
|
if (statusSelect.value != 'rejected')
|
||||||
|
rejectionReasonFormGroup.style.display = 'none';
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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" });
|
||||||
|
})();
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 4.2.7 on 2024-02-26 22:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("registrar", "0069_alter_contact_email_alter_contact_first_name_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="domainapplication",
|
||||||
|
name="rejection_reason",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("purpose_not_met", "Purpose requirements not met"),
|
||||||
|
("requestor_not_eligible", "Requestor not eligible to make request"),
|
||||||
|
("org_has_domain", "Org already has a .gov domain"),
|
||||||
|
("contacts_not_verified", "Org contacts couldn't be verified"),
|
||||||
|
("org_not_eligible", "Org not eligible for a .gov domain"),
|
||||||
|
("naming_not_met", "Naming requirements not met"),
|
||||||
|
("other", "Other/Unspecified"),
|
||||||
|
],
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -351,12 +351,34 @@ class DomainApplication(TimeStampedModel):
|
||||||
]
|
]
|
||||||
AGENCY_CHOICES = [(v, v) for v in AGENCIES]
|
AGENCY_CHOICES = [(v, v) for v in AGENCIES]
|
||||||
|
|
||||||
|
class RejectionReasons(models.TextChoices):
|
||||||
|
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met"
|
||||||
|
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request"
|
||||||
|
SECOND_DOMAIN_REASONING = (
|
||||||
|
"org_has_domain",
|
||||||
|
"Org already has a .gov domain",
|
||||||
|
)
|
||||||
|
CONTACTS_OR_ORGANIZATION_LEGITIMACY = (
|
||||||
|
"contacts_not_verified",
|
||||||
|
"Org contacts couldn't be verified",
|
||||||
|
)
|
||||||
|
ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain"
|
||||||
|
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
|
||||||
|
OTHER = "other", "Other/Unspecified"
|
||||||
|
|
||||||
# #### Internal fields about the application #####
|
# #### Internal fields about the application #####
|
||||||
status = FSMField(
|
status = FSMField(
|
||||||
choices=ApplicationStatus.choices, # possible states as an array of constants
|
choices=ApplicationStatus.choices, # possible states as an array of constants
|
||||||
default=ApplicationStatus.STARTED, # sensible default
|
default=ApplicationStatus.STARTED, # sensible default
|
||||||
protected=False, # can change state directly, particularly in Django admin
|
protected=False, # can change state directly, particularly in Django admin
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rejection_reason = models.TextField(
|
||||||
|
choices=RejectionReasons.choices,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
# This is the application user who created this application. The contact
|
# This is the application user who created this application. The contact
|
||||||
# information that they gave is in the `submitter` field
|
# information that they gave is in the `submitter` field
|
||||||
creator = models.ForeignKey(
|
creator = models.ForeignKey(
|
||||||
|
@ -364,6 +386,7 @@ class DomainApplication(TimeStampedModel):
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="applications_created",
|
related_name="applications_created",
|
||||||
)
|
)
|
||||||
|
|
||||||
investigator = models.ForeignKey(
|
investigator = models.ForeignKey(
|
||||||
"registrar.User",
|
"registrar.User",
|
||||||
null=True,
|
null=True,
|
||||||
|
@ -688,12 +711,17 @@ class DomainApplication(TimeStampedModel):
|
||||||
|
|
||||||
This action is logged.
|
This action is logged.
|
||||||
|
|
||||||
|
This action cleans up the rejection status if moving away from rejected.
|
||||||
|
|
||||||
As side effects this will delete the domain and domain_information
|
As side effects this will delete the domain and domain_information
|
||||||
(will cascade) when they exist."""
|
(will cascade) when they exist."""
|
||||||
|
|
||||||
if self.status == self.ApplicationStatus.APPROVED:
|
if self.status == self.ApplicationStatus.APPROVED:
|
||||||
self.delete_and_clean_up_domain("in_review")
|
self.delete_and_clean_up_domain("in_review")
|
||||||
|
|
||||||
|
if self.status == self.ApplicationStatus.REJECTED:
|
||||||
|
self.rejection_reason = None
|
||||||
|
|
||||||
literal = DomainApplication.ApplicationStatus.IN_REVIEW
|
literal = DomainApplication.ApplicationStatus.IN_REVIEW
|
||||||
# Check if the tuple exists, then grab its value
|
# Check if the tuple exists, then grab its value
|
||||||
in_review = literal if literal is not None else "In Review"
|
in_review = literal if literal is not None else "In Review"
|
||||||
|
@ -715,12 +743,17 @@ class DomainApplication(TimeStampedModel):
|
||||||
|
|
||||||
This action is logged.
|
This action is logged.
|
||||||
|
|
||||||
|
This action cleans up the rejection status if moving away from rejected.
|
||||||
|
|
||||||
As side effects this will delete the domain and domain_information
|
As side effects this will delete the domain and domain_information
|
||||||
(will cascade) when they exist."""
|
(will cascade) when they exist."""
|
||||||
|
|
||||||
if self.status == self.ApplicationStatus.APPROVED:
|
if self.status == self.ApplicationStatus.APPROVED:
|
||||||
self.delete_and_clean_up_domain("reject_with_prejudice")
|
self.delete_and_clean_up_domain("reject_with_prejudice")
|
||||||
|
|
||||||
|
if self.status == self.ApplicationStatus.REJECTED:
|
||||||
|
self.rejection_reason = None
|
||||||
|
|
||||||
literal = DomainApplication.ApplicationStatus.ACTION_NEEDED
|
literal = DomainApplication.ApplicationStatus.ACTION_NEEDED
|
||||||
# Check if the tuple is setup correctly, then grab its value
|
# Check if the tuple is setup correctly, then grab its value
|
||||||
action_needed = literal if literal is not None else "Action Needed"
|
action_needed = literal if literal is not None else "Action Needed"
|
||||||
|
@ -739,6 +772,8 @@ class DomainApplication(TimeStampedModel):
|
||||||
def approve(self, send_email=True):
|
def approve(self, send_email=True):
|
||||||
"""Approve an application that has been submitted.
|
"""Approve an application that has been submitted.
|
||||||
|
|
||||||
|
This action cleans up the rejection status if moving away from rejected.
|
||||||
|
|
||||||
This has substantial side-effects because it creates another database
|
This has substantial side-effects because it creates another database
|
||||||
object for the approved Domain and makes the user who created the
|
object for the approved Domain and makes the user who created the
|
||||||
application into an admin on that domain. It also triggers an email
|
application into an admin on that domain. It also triggers an email
|
||||||
|
@ -761,6 +796,9 @@ class DomainApplication(TimeStampedModel):
|
||||||
user=self.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER
|
user=self.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.status == self.ApplicationStatus.REJECTED:
|
||||||
|
self.rejection_reason = None
|
||||||
|
|
||||||
self._send_status_update_email(
|
self._send_status_update_email(
|
||||||
"application approved",
|
"application approved",
|
||||||
"emails/status_change_approved.txt",
|
"emails/status_change_approved.txt",
|
||||||
|
|
|
@ -8,7 +8,56 @@ REQUEST RECEIVED ON: {{ application.submission_date|date }}
|
||||||
STATUS: Rejected
|
STATUS: Rejected
|
||||||
|
|
||||||
----------------------------------------------------------------
|
----------------------------------------------------------------
|
||||||
|
{% if application.rejection_reason != 'other' %}
|
||||||
|
REJECTION REASON{% endif %}{% if application.rejection_reason == 'purpose_not_met' %}
|
||||||
|
Your domain request was rejected because the purpose you provided did not meet our
|
||||||
|
requirements. You didn’t provide enough information about how you intend to use the
|
||||||
|
domain.
|
||||||
|
|
||||||
|
Learn more about:
|
||||||
|
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
|
||||||
|
- What you can and can’t do with .gov domains <https://get.gov/domains/requirements/>
|
||||||
|
|
||||||
|
If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'requestor_not_eligible' %}
|
||||||
|
Your domain request was rejected because we don’t believe you’re eligible to request a
|
||||||
|
.gov domain on behalf of {{ application.organization_name }}. You must be a government employee, or be
|
||||||
|
working on behalf of a government organization, to request a .gov domain.
|
||||||
|
|
||||||
|
|
||||||
|
DEMONSTRATE ELIGIBILITY
|
||||||
|
If you can provide more information that demonstrates your eligibility, or you want to
|
||||||
|
discuss further, reply to this email.{% elif application.rejection_reason == 'org_has_domain' %}
|
||||||
|
Your domain request was rejected because {{ application.organization_name }} has a .gov domain. Our
|
||||||
|
practice is to approve one domain per online service per government organization. We
|
||||||
|
evaluate additional requests on a case-by-case basis. You did not provide sufficient
|
||||||
|
justification for an additional domain.
|
||||||
|
|
||||||
|
Read more about our practice of approving one domain per online service
|
||||||
|
<https://get.gov/domains/before/#one-domain-per-service>.
|
||||||
|
|
||||||
|
If you have questions or comments, reply to this email.{% elif application.rejection_reason == 'contacts_not_verified' %}
|
||||||
|
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.{% elif application.rejection_reason == 'org_not_eligible' %}
|
||||||
|
Your domain request was rejected because we determined that {{ application.organization_name }} is not
|
||||||
|
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
|
||||||
|
government organizations.
|
||||||
|
|
||||||
|
|
||||||
|
DEMONSTRATE ELIGIBILITY
|
||||||
|
If you can provide documentation that demonstrates your eligibility, reply to this email.
|
||||||
|
This can include links to (or copies of) your authorizing legislation, your founding
|
||||||
|
charter or bylaws, or other similar documentation. Without this, we can’t approve a
|
||||||
|
.gov domain for your organization. Learn more about eligibility for .gov domains
|
||||||
|
<https://get.gov/domains/eligibility/>.{% elif application.rejection_reason == 'naming_not_met' %}
|
||||||
|
Your domain request was rejected because it does not meet our naming requirements.
|
||||||
|
Domains should uniquely identify a government organization and be clear to the
|
||||||
|
general public. Learn more about naming requirements for your type of organization
|
||||||
|
<https://get.gov/domains/choosing/>.
|
||||||
|
|
||||||
|
|
||||||
|
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.{% elif application.rejection_reason == 'other' %}
|
||||||
YOU CAN SUBMIT A NEW REQUEST
|
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.
|
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
|
||||||
|
|
||||||
|
@ -19,7 +68,7 @@ Learn more about:
|
||||||
|
|
||||||
NEED ASSISTANCE?
|
NEED ASSISTANCE?
|
||||||
If you have questions about this domain request or need help choosing a new domain name, reply to this email.
|
If you have questions about this domain request or need help choosing a new domain name, reply to this email.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
THANK YOU
|
THANK YOU
|
||||||
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
|
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -574,6 +574,56 @@ class TestDomainApplication(TestCase):
|
||||||
with self.assertRaises(TransitionNotAllowed):
|
with self.assertRaises(TransitionNotAllowed):
|
||||||
self.approved_application.reject_with_prejudice()
|
self.approved_application.reject_with_prejudice()
|
||||||
|
|
||||||
|
def test_approve_from_rejected_clears_rejection_reason(self):
|
||||||
|
"""When transitioning from rejected to approved on a domain request,
|
||||||
|
the rejection_reason is cleared."""
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
|
# Create a sample application
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
|
||||||
|
application.rejection_reason = DomainApplication.RejectionReasons.DOMAIN_PURPOSE
|
||||||
|
|
||||||
|
# Approve
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
|
application.approve()
|
||||||
|
|
||||||
|
self.assertEqual(application.status, DomainApplication.ApplicationStatus.APPROVED)
|
||||||
|
self.assertEqual(application.rejection_reason, None)
|
||||||
|
|
||||||
|
def test_in_review_from_rejected_clears_rejection_reason(self):
|
||||||
|
"""When transitioning from rejected to in_review on a domain request,
|
||||||
|
the rejection_reason is cleared."""
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
|
# Create a sample application
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
|
||||||
|
application.domain_is_not_active = True
|
||||||
|
application.rejection_reason = DomainApplication.RejectionReasons.DOMAIN_PURPOSE
|
||||||
|
|
||||||
|
# Approve
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
|
application.in_review()
|
||||||
|
|
||||||
|
self.assertEqual(application.status, DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||||
|
self.assertEqual(application.rejection_reason, None)
|
||||||
|
|
||||||
|
def test_action_needed_from_rejected_clears_rejection_reason(self):
|
||||||
|
"""When transitioning from rejected to action_needed on a domain request,
|
||||||
|
the rejection_reason is cleared."""
|
||||||
|
|
||||||
|
with less_console_noise():
|
||||||
|
# Create a sample application
|
||||||
|
application = completed_application(status=DomainApplication.ApplicationStatus.REJECTED)
|
||||||
|
application.domain_is_not_active = True
|
||||||
|
application.rejection_reason = DomainApplication.RejectionReasons.DOMAIN_PURPOSE
|
||||||
|
|
||||||
|
# Approve
|
||||||
|
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||||
|
application.action_needed()
|
||||||
|
|
||||||
|
self.assertEqual(application.status, DomainApplication.ApplicationStatus.ACTION_NEEDED)
|
||||||
|
self.assertEqual(application.rejection_reason, None)
|
||||||
|
|
||||||
def test_has_rationale_returns_true(self):
|
def test_has_rationale_returns_true(self):
|
||||||
"""has_rationale() returns true when an application has no_other_contacts_rationale"""
|
"""has_rationale() returns true when an application has no_other_contacts_rationale"""
|
||||||
with less_console_noise():
|
with less_console_noise():
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue