mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-19 17:25:56 +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
|
||||
list_filter = ("status", "organization_type", InvestigatorFilter)
|
||||
list_filter = ("status", "organization_type", "rejection_reason", InvestigatorFilter)
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
|
@ -886,7 +886,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
|||
# Detail view
|
||||
form = DomainApplicationAdminForm
|
||||
fieldsets = [
|
||||
(None, {"fields": ["status", "investigator", "creator", "approved_domain", "notes"]}),
|
||||
(None, {"fields": ["status", "rejection_reason", "investigator", "creator", "approved_domain", "notes"]}),
|
||||
(
|
||||
"Type of organization",
|
||||
{
|
||||
|
@ -987,6 +987,23 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
|||
"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:
|
||||
if obj.status != original_obj.status:
|
||||
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]
|
||||
|
||||
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 #####
|
||||
status = FSMField(
|
||||
choices=ApplicationStatus.choices, # possible states as an array of constants
|
||||
default=ApplicationStatus.STARTED, # sensible default
|
||||
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
|
||||
# information that they gave is in the `submitter` field
|
||||
creator = models.ForeignKey(
|
||||
|
@ -364,6 +386,7 @@ class DomainApplication(TimeStampedModel):
|
|||
on_delete=models.PROTECT,
|
||||
related_name="applications_created",
|
||||
)
|
||||
|
||||
investigator = models.ForeignKey(
|
||||
"registrar.User",
|
||||
null=True,
|
||||
|
@ -688,12 +711,17 @@ class DomainApplication(TimeStampedModel):
|
|||
|
||||
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
|
||||
(will cascade) when they exist."""
|
||||
|
||||
if self.status == self.ApplicationStatus.APPROVED:
|
||||
self.delete_and_clean_up_domain("in_review")
|
||||
|
||||
if self.status == self.ApplicationStatus.REJECTED:
|
||||
self.rejection_reason = None
|
||||
|
||||
literal = DomainApplication.ApplicationStatus.IN_REVIEW
|
||||
# Check if the tuple exists, then grab its value
|
||||
in_review = literal if literal is not None else "In Review"
|
||||
|
@ -715,12 +743,17 @@ class DomainApplication(TimeStampedModel):
|
|||
|
||||
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
|
||||
(will cascade) when they exist."""
|
||||
|
||||
if self.status == self.ApplicationStatus.APPROVED:
|
||||
self.delete_and_clean_up_domain("reject_with_prejudice")
|
||||
|
||||
if self.status == self.ApplicationStatus.REJECTED:
|
||||
self.rejection_reason = None
|
||||
|
||||
literal = DomainApplication.ApplicationStatus.ACTION_NEEDED
|
||||
# Check if the tuple is setup correctly, then grab its value
|
||||
action_needed = literal if literal is not None else "Action Needed"
|
||||
|
@ -739,6 +772,8 @@ class DomainApplication(TimeStampedModel):
|
|||
def approve(self, send_email=True):
|
||||
"""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
|
||||
object for the approved Domain and makes the user who created the
|
||||
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
|
||||
)
|
||||
|
||||
if self.status == self.ApplicationStatus.REJECTED:
|
||||
self.rejection_reason = None
|
||||
|
||||
self._send_status_update_email(
|
||||
"application approved",
|
||||
"emails/status_change_approved.txt",
|
||||
|
|
|
@ -8,9 +8,58 @@ REQUEST RECEIVED ON: {{ application.submission_date|date }}
|
|||
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
|
||||
If your organization is eligible for a .gov domain and you meet our other requirements, 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
|
||||
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
|
||||
|
||||
Learn more about:
|
||||
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
|
||||
|
@ -19,7 +68,7 @@ Learn more about:
|
|||
|
||||
NEED ASSISTANCE?
|
||||
If you have questions about this domain request or need help choosing a new domain name, reply to this email.
|
||||
|
||||
{% endif %}
|
||||
|
||||
THANK YOU
|
||||
.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):
|
||||
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):
|
||||
"""has_rationale() returns true when an application has no_other_contacts_rationale"""
|
||||
with less_console_noise():
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue