mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-24 03:30:50 +02:00
merged in main again
This commit is contained in:
commit
af849adbea
8 changed files with 655 additions and 100 deletions
|
@ -902,7 +902,7 @@ class DomainApplicationAdmin(ListHeaderAdmin):
|
|||
custom_election_board.short_description = "Election office" # type: ignore
|
||||
|
||||
# Filters
|
||||
list_filter = ("status", "organization_type", "federal_type", ElectionOfficeFilter, InvestigatorFilter)
|
||||
list_filter = ("status", "organization_type", "federal_type", ElectionOfficeFilter, "rejection_reason", InvestigatorFilter)
|
||||
|
||||
# Search
|
||||
search_fields = [
|
||||
|
@ -916,7 +916,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",
|
||||
{
|
||||
|
@ -1017,6 +1017,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" });
|
||||
})();
|
||||
|
|
|
@ -284,6 +284,7 @@ class OrganizationContactForm(RegistrarForm):
|
|||
message="Enter a zip code in the form of 12345 or 12345-6789.",
|
||||
)
|
||||
],
|
||||
error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")},
|
||||
)
|
||||
urbanization = forms.CharField(
|
||||
required=False,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -594,7 +594,7 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
# Now let's make sure the long description does not exist
|
||||
self.assertNotContains(response, "Federal: an agency of the U.S. government")
|
||||
|
||||
def transition_state_and_send_email(self, application, status):
|
||||
def transition_state_and_send_email(self, application, status, rejection_reason=None):
|
||||
"""Helper method for the email test cases."""
|
||||
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||
|
@ -602,8 +602,9 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
# Create a mock request
|
||||
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
|
||||
|
||||
# Modify the application's property
|
||||
# Modify the application's properties
|
||||
application.status = status
|
||||
application.rejection_reason = rejection_reason
|
||||
|
||||
# Use the model admin's save_model method
|
||||
self.admin.save_model(request, application, form=None, change=True)
|
||||
|
@ -771,7 +772,11 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Test Withdrawn Status
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.REJECTED)
|
||||
self.transition_state_and_send_email(
|
||||
application,
|
||||
DomainApplication.ApplicationStatus.REJECTED,
|
||||
DomainApplication.RejectionReasons.DOMAIN_PURPOSE,
|
||||
)
|
||||
self.assert_email_is_accurate("Your .gov domain request has been rejected.", 1, EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
|
@ -779,9 +784,9 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||
|
||||
def test_save_model_sends_rejected_email(self):
|
||||
"""When transitioning to rejected on a domain request,
|
||||
an email is sent out every time."""
|
||||
def test_save_model_sends_rejected_email_purpose_not_met(self):
|
||||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is domain purpose."""
|
||||
|
||||
with less_console_noise():
|
||||
# Ensure there is no user with this email
|
||||
|
@ -791,19 +796,250 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
# Create a sample application
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
|
||||
# Test Submitted Status
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.REJECTED)
|
||||
self.assert_email_is_accurate("Your .gov domain request has been rejected.", 0, EMAIL)
|
||||
# Reject for reason DOMAIN_PURPOSE and test email
|
||||
self.transition_state_and_send_email(
|
||||
application,
|
||||
DomainApplication.ApplicationStatus.REJECTED,
|
||||
DomainApplication.RejectionReasons.DOMAIN_PURPOSE,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because the purpose you provided did not meet our \nrequirements.",
|
||||
0,
|
||||
EMAIL,
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Test Withdrawn Status
|
||||
# Approve
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
# Test Submitted Status Again (No new email should be sent)
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.REJECTED)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||
def test_save_model_sends_rejected_email_requestor(self):
|
||||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is requestor."""
|
||||
|
||||
with less_console_noise():
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
|
||||
# Create a sample application
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
|
||||
# Reject for reason REQUESTOR and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
application, DomainApplication.ApplicationStatus.REJECTED, DomainApplication.RejectionReasons.REQUESTOR
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because we don’t believe you’re eligible to request a \n.gov "
|
||||
"domain on behalf of Testorg",
|
||||
0,
|
||||
EMAIL,
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
def test_save_model_sends_rejected_email_org_has_domain(self):
|
||||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is second domain."""
|
||||
|
||||
with less_console_noise():
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
|
||||
# Create a sample application
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
|
||||
# Reject for reason SECOND_DOMAIN_REASONING and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
application,
|
||||
DomainApplication.ApplicationStatus.REJECTED,
|
||||
DomainApplication.RejectionReasons.SECOND_DOMAIN_REASONING,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because Testorg has a .gov domain.", 0, EMAIL
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
def test_save_model_sends_rejected_email_contacts_or_org_legitimacy(self):
|
||||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is contacts or org legitimacy."""
|
||||
|
||||
with less_console_noise():
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
|
||||
# Create a sample application
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
|
||||
# Reject for reason CONTACTS_OR_ORGANIZATION_LEGITIMACY and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
application,
|
||||
DomainApplication.ApplicationStatus.REJECTED,
|
||||
DomainApplication.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because we could not verify the organizational \n"
|
||||
"contacts you provided. If you have questions or comments, reply to this email.",
|
||||
0,
|
||||
EMAIL,
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
def test_save_model_sends_rejected_email_org_eligibility(self):
|
||||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is org eligibility."""
|
||||
|
||||
with less_console_noise():
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
|
||||
# Create a sample application
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
|
||||
# Reject for reason ORGANIZATION_ELIGIBILITY and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
application,
|
||||
DomainApplication.ApplicationStatus.REJECTED,
|
||||
DomainApplication.RejectionReasons.ORGANIZATION_ELIGIBILITY,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because we determined that Testorg is not \neligible for "
|
||||
"a .gov domain.",
|
||||
0,
|
||||
EMAIL,
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
def test_save_model_sends_rejected_email_naming(self):
|
||||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is naming."""
|
||||
|
||||
with less_console_noise():
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
|
||||
# Create a sample application
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
|
||||
# Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
application,
|
||||
DomainApplication.ApplicationStatus.REJECTED,
|
||||
DomainApplication.RejectionReasons.NAMING_REQUIREMENTS,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because it does not meet our naming requirements.", 0, EMAIL
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
def test_save_model_sends_rejected_email_other(self):
|
||||
"""When transitioning to rejected on a domain request, an email is sent
|
||||
explaining why when the reason is other."""
|
||||
|
||||
with less_console_noise():
|
||||
# Ensure there is no user with this email
|
||||
EMAIL = "mayor@igorville.gov"
|
||||
User.objects.filter(email=EMAIL).delete()
|
||||
|
||||
# Create a sample application
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
|
||||
# Reject for reason NAMING_REQUIREMENTS and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
application,
|
||||
DomainApplication.ApplicationStatus.REJECTED,
|
||||
DomainApplication.RejectionReasons.OTHER,
|
||||
)
|
||||
self.assert_email_is_accurate("Choosing a .gov domain name", 0, EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# Approve
|
||||
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
|
||||
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 1, EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
|
||||
def test_transition_to_rejected_without_rejection_reason_does_trigger_error(self):
|
||||
"""
|
||||
When transitioning to rejected without a rejection reason, admin throws a user friendly message.
|
||||
|
||||
The transition fails.
|
||||
"""
|
||||
|
||||
with less_console_noise():
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
|
||||
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
|
||||
request.user = self.superuser
|
||||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(messages, "error"))
|
||||
application.status = DomainApplication.ApplicationStatus.REJECTED
|
||||
|
||||
self.admin.save_model(request, application, None, True)
|
||||
|
||||
messages.error.assert_called_once_with(
|
||||
request,
|
||||
"A rejection reason is required.",
|
||||
)
|
||||
|
||||
application.refresh_from_db()
|
||||
self.assertEqual(application.status, DomainApplication.ApplicationStatus.APPROVED)
|
||||
|
||||
def test_transition_to_rejected_with_rejection_reason_does_not_trigger_error(self):
|
||||
"""
|
||||
When transitioning to rejected with a rejection reason, admin does not throw an error alert.
|
||||
|
||||
The transition is successful.
|
||||
"""
|
||||
|
||||
with less_console_noise():
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
|
||||
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
|
||||
request.user = self.superuser
|
||||
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch.object(messages, "error"))
|
||||
application.status = DomainApplication.ApplicationStatus.REJECTED
|
||||
application.rejection_reason = DomainApplication.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY
|
||||
|
||||
self.admin.save_model(request, application, None, True)
|
||||
|
||||
messages.error.assert_not_called()
|
||||
|
||||
application.refresh_from_db()
|
||||
self.assertEqual(application.status, DomainApplication.ApplicationStatus.REJECTED)
|
||||
|
||||
def test_save_model_sends_withdrawn_email(self):
|
||||
"""When transitioning to withdrawn on a domain request,
|
||||
|
@ -894,6 +1130,7 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
"created_at",
|
||||
"updated_at",
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"creator",
|
||||
"investigator",
|
||||
"organization_type",
|
||||
|
@ -1007,7 +1244,88 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
"Cannot edit an application with a restricted creator.",
|
||||
)
|
||||
|
||||
def trigger_saving_approved_to_another_state(self, domain_is_active, another_state):
|
||||
self.assertEqual(readonly_fields, expected_fields)
|
||||
|
||||
def test_readonly_fields_for_analyst(self):
|
||||
with less_console_noise():
|
||||
request = self.factory.get("/") # Use the correct method and path
|
||||
request.user = self.staffuser
|
||||
|
||||
readonly_fields = self.admin.get_readonly_fields(request)
|
||||
|
||||
expected_fields = [
|
||||
"creator",
|
||||
"about_your_organization",
|
||||
"requested_domain",
|
||||
"approved_domain",
|
||||
"alternative_domains",
|
||||
"purpose",
|
||||
"submitter",
|
||||
"no_other_contacts_rationale",
|
||||
"anything_else",
|
||||
"is_policy_acknowledged",
|
||||
]
|
||||
|
||||
self.assertEqual(readonly_fields, expected_fields)
|
||||
|
||||
def test_readonly_fields_for_superuser(self):
|
||||
with less_console_noise():
|
||||
request = self.factory.get("/") # Use the correct method and path
|
||||
request.user = self.superuser
|
||||
|
||||
readonly_fields = self.admin.get_readonly_fields(request)
|
||||
|
||||
expected_fields = []
|
||||
|
||||
self.assertEqual(readonly_fields, expected_fields)
|
||||
|
||||
def test_saving_when_restricted_creator(self):
|
||||
with less_console_noise():
|
||||
# Create an instance of the model
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||
application.creator.status = User.RESTRICTED
|
||||
application.creator.save()
|
||||
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.get("/")
|
||||
request.user = self.superuser
|
||||
|
||||
with patch("django.contrib.messages.error") as mock_error:
|
||||
# Simulate saving the model
|
||||
self.admin.save_model(request, application, None, False)
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
mock_error.assert_called_once_with(
|
||||
request,
|
||||
"This action is not permitted for applications with a restricted creator.",
|
||||
)
|
||||
|
||||
# Assert that the status has not changed
|
||||
self.assertEqual(application.status, DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
|
||||
def test_change_view_with_restricted_creator(self):
|
||||
with less_console_noise():
|
||||
# Create an instance of the model
|
||||
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||
application.creator.status = User.RESTRICTED
|
||||
application.creator.save()
|
||||
|
||||
with patch("django.contrib.messages.warning") as mock_warning:
|
||||
# Create a request object with a superuser
|
||||
request = self.factory.get("/admin/your_app/domainapplication/{}/change/".format(application.pk))
|
||||
request.user = self.superuser
|
||||
|
||||
self.admin.display_restricted_warning(request, application)
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
mock_warning.assert_called_once_with(
|
||||
request,
|
||||
"Cannot edit an application with a restricted creator.",
|
||||
)
|
||||
|
||||
def trigger_saving_approved_to_another_state(self, domain_is_active, another_state, rejection_reason=None):
|
||||
"""Helper method that triggers domain request state changes from approved to another state,
|
||||
with an associated domain that can be either active (READY) or not.
|
||||
|
||||
|
@ -1037,6 +1355,8 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
stack.enter_context(patch.object(messages, "error"))
|
||||
|
||||
application.status = another_state
|
||||
application.rejection_reason = rejection_reason
|
||||
|
||||
self.admin.save_model(request, application, None, True)
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
|
@ -1078,7 +1398,11 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.ACTION_NEEDED)
|
||||
|
||||
def test_side_effects_when_saving_approved_to_rejected(self):
|
||||
self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.REJECTED)
|
||||
self.trigger_saving_approved_to_another_state(
|
||||
False,
|
||||
DomainApplication.ApplicationStatus.REJECTED,
|
||||
DomainApplication.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
|
||||
)
|
||||
|
||||
def test_side_effects_when_saving_approved_to_ineligible(self):
|
||||
self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.INELIGIBLE)
|
||||
|
@ -1101,6 +1425,7 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
"organization_type",
|
||||
"federal_type",
|
||||
DomainApplicationAdmin.ElectionOfficeFilter,
|
||||
"rejection_reason",
|
||||
DomainApplicationAdmin.InvestigatorFilter,
|
||||
)
|
||||
|
||||
|
@ -1193,6 +1518,7 @@ class TestDomainApplicationAdmin(MockEppLib):
|
|||
It then retrieves the queryset for the 'investigator' dropdown from DomainApplicationAdmin
|
||||
and checks that it matches the expected queryset, which only includes staff users.
|
||||
"""
|
||||
|
||||
with less_console_noise():
|
||||
# Create a mock DomainApplication object, with a fake investigator
|
||||
application: DomainApplication = generic_domain_object("application", "SomeGuy")
|
||||
|
@ -1792,105 +2118,107 @@ class AuditedAdminTest(TestCase):
|
|||
)
|
||||
|
||||
def test_alphabetically_sorted_fk_fields_domain_information(self):
|
||||
tested_fields = [
|
||||
DomainInformation.authorizing_official.field,
|
||||
DomainInformation.submitter.field,
|
||||
# DomainInformation.creator.field,
|
||||
(DomainInformation.domain.field, ["name"]),
|
||||
(DomainInformation.domain_application.field, ["requested_domain__name"]),
|
||||
]
|
||||
# Creates multiple domain applications - review status does not matter
|
||||
applications = multiple_unalphabetical_domain_objects("information")
|
||||
with less_console_noise():
|
||||
tested_fields = [
|
||||
DomainInformation.authorizing_official.field,
|
||||
DomainInformation.submitter.field,
|
||||
# DomainInformation.creator.field,
|
||||
(DomainInformation.domain.field, ["name"]),
|
||||
(DomainInformation.domain_application.field, ["requested_domain__name"]),
|
||||
]
|
||||
# Creates multiple domain applications - review status does not matter
|
||||
applications = multiple_unalphabetical_domain_objects("information")
|
||||
|
||||
# Create a mock request
|
||||
request = self.factory.post("/admin/registrar/domaininformation/{}/change/".format(applications[0].pk))
|
||||
# Create a mock request
|
||||
request = self.factory.post("/admin/registrar/domaininformation/{}/change/".format(applications[0].pk))
|
||||
|
||||
model_admin = AuditedAdmin(DomainInformation, self.site)
|
||||
model_admin = AuditedAdmin(DomainInformation, self.site)
|
||||
|
||||
sorted_fields = []
|
||||
# Typically we wouldn't want two nested for fields,
|
||||
# but both fields are of a fixed length.
|
||||
# For test case purposes, this should be performant.
|
||||
for field in tested_fields:
|
||||
isOtherOrderfield: bool = isinstance(field, tuple)
|
||||
field_obj = None
|
||||
if isOtherOrderfield:
|
||||
sorted_fields = field[1]
|
||||
field_obj = field[0]
|
||||
else:
|
||||
sorted_fields = ["first_name", "last_name"]
|
||||
field_obj = field
|
||||
# We want both of these to be lists, as it is richer test wise.
|
||||
desired_order = self.order_by_desired_field_helper(model_admin, request, field_obj.name, *sorted_fields)
|
||||
current_sort_order = list(model_admin.formfield_for_foreignkey(field_obj, request).queryset)
|
||||
sorted_fields = []
|
||||
# Typically we wouldn't want two nested for fields,
|
||||
# but both fields are of a fixed length.
|
||||
# For test case purposes, this should be performant.
|
||||
for field in tested_fields:
|
||||
isOtherOrderfield: bool = isinstance(field, tuple)
|
||||
field_obj = None
|
||||
if isOtherOrderfield:
|
||||
sorted_fields = field[1]
|
||||
field_obj = field[0]
|
||||
else:
|
||||
sorted_fields = ["first_name", "last_name"]
|
||||
field_obj = field
|
||||
# We want both of these to be lists, as it is richer test wise.
|
||||
desired_order = self.order_by_desired_field_helper(model_admin, request, field_obj.name, *sorted_fields)
|
||||
current_sort_order = list(model_admin.formfield_for_foreignkey(field_obj, request).queryset)
|
||||
|
||||
# Conforms to the same object structure as desired_order
|
||||
current_sort_order_coerced_type = []
|
||||
# Conforms to the same object structure as desired_order
|
||||
current_sort_order_coerced_type = []
|
||||
|
||||
# This is necessary as .queryset and get_queryset
|
||||
# return lists of different types/structures.
|
||||
# We need to parse this data and coerce them into the same type.
|
||||
for obj in current_sort_order:
|
||||
last = None
|
||||
if not isOtherOrderfield:
|
||||
first = obj.first_name
|
||||
last = obj.last_name
|
||||
elif field_obj == DomainInformation.domain.field:
|
||||
first = obj.name
|
||||
elif field_obj == DomainInformation.domain_application.field:
|
||||
first = obj.requested_domain.name
|
||||
# This is necessary as .queryset and get_queryset
|
||||
# return lists of different types/structures.
|
||||
# We need to parse this data and coerce them into the same type.
|
||||
for obj in current_sort_order:
|
||||
last = None
|
||||
if not isOtherOrderfield:
|
||||
first = obj.first_name
|
||||
last = obj.last_name
|
||||
elif field_obj == DomainInformation.domain.field:
|
||||
first = obj.name
|
||||
elif field_obj == DomainInformation.domain_application.field:
|
||||
first = obj.requested_domain.name
|
||||
|
||||
name_tuple = self.coerced_fk_field_helper(first, last, field_obj.name, ":")
|
||||
if name_tuple is not None:
|
||||
current_sort_order_coerced_type.append(name_tuple)
|
||||
name_tuple = self.coerced_fk_field_helper(first, last, field_obj.name, ":")
|
||||
if name_tuple is not None:
|
||||
current_sort_order_coerced_type.append(name_tuple)
|
||||
|
||||
self.assertEqual(
|
||||
desired_order,
|
||||
current_sort_order_coerced_type,
|
||||
"{} is not ordered alphabetically".format(field_obj.name),
|
||||
)
|
||||
self.assertEqual(
|
||||
desired_order,
|
||||
current_sort_order_coerced_type,
|
||||
"{} is not ordered alphabetically".format(field_obj.name),
|
||||
)
|
||||
|
||||
def test_alphabetically_sorted_fk_fields_domain_invitation(self):
|
||||
tested_fields = [DomainInvitation.domain.field]
|
||||
with less_console_noise():
|
||||
tested_fields = [DomainInvitation.domain.field]
|
||||
|
||||
# Creates multiple domain applications - review status does not matter
|
||||
applications = multiple_unalphabetical_domain_objects("invitation")
|
||||
# Creates multiple domain applications - review status does not matter
|
||||
applications = multiple_unalphabetical_domain_objects("invitation")
|
||||
|
||||
# Create a mock request
|
||||
request = self.factory.post("/admin/registrar/domaininvitation/{}/change/".format(applications[0].pk))
|
||||
# Create a mock request
|
||||
request = self.factory.post("/admin/registrar/domaininvitation/{}/change/".format(applications[0].pk))
|
||||
|
||||
model_admin = AuditedAdmin(DomainInvitation, self.site)
|
||||
model_admin = AuditedAdmin(DomainInvitation, self.site)
|
||||
|
||||
sorted_fields = []
|
||||
# Typically we wouldn't want two nested for fields,
|
||||
# but both fields are of a fixed length.
|
||||
# For test case purposes, this should be performant.
|
||||
for field in tested_fields:
|
||||
sorted_fields = ["name"]
|
||||
# We want both of these to be lists, as it is richer test wise.
|
||||
sorted_fields = []
|
||||
# Typically we wouldn't want two nested for fields,
|
||||
# but both fields are of a fixed length.
|
||||
# For test case purposes, this should be performant.
|
||||
for field in tested_fields:
|
||||
sorted_fields = ["name"]
|
||||
# We want both of these to be lists, as it is richer test wise.
|
||||
|
||||
desired_order = self.order_by_desired_field_helper(model_admin, request, field.name, *sorted_fields)
|
||||
current_sort_order = list(model_admin.formfield_for_foreignkey(field, request).queryset)
|
||||
desired_order = self.order_by_desired_field_helper(model_admin, request, field.name, *sorted_fields)
|
||||
current_sort_order = list(model_admin.formfield_for_foreignkey(field, request).queryset)
|
||||
|
||||
# Conforms to the same object structure as desired_order
|
||||
current_sort_order_coerced_type = []
|
||||
# Conforms to the same object structure as desired_order
|
||||
current_sort_order_coerced_type = []
|
||||
|
||||
# This is necessary as .queryset and get_queryset
|
||||
# return lists of different types/structures.
|
||||
# We need to parse this data and coerce them into the same type.
|
||||
for contact in current_sort_order:
|
||||
first = contact.name
|
||||
last = None
|
||||
# This is necessary as .queryset and get_queryset
|
||||
# return lists of different types/structures.
|
||||
# We need to parse this data and coerce them into the same type.
|
||||
for contact in current_sort_order:
|
||||
first = contact.name
|
||||
last = None
|
||||
|
||||
name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":")
|
||||
if name_tuple is not None:
|
||||
current_sort_order_coerced_type.append(name_tuple)
|
||||
name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":")
|
||||
if name_tuple is not None:
|
||||
current_sort_order_coerced_type.append(name_tuple)
|
||||
|
||||
self.assertEqual(
|
||||
desired_order,
|
||||
current_sort_order_coerced_type,
|
||||
"{} is not ordered alphabetically".format(field.name),
|
||||
)
|
||||
self.assertEqual(
|
||||
desired_order,
|
||||
current_sort_order_coerced_type,
|
||||
"{} is not ordered alphabetically".format(field.name),
|
||||
)
|
||||
|
||||
def coerced_fk_field_helper(self, first_name, last_name, field_name, queryset_shorthand):
|
||||
"""Handles edge cases for test cases"""
|
||||
|
|
|
@ -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