Merge branch 'main' into za/1411-make-statuses-more-meaningful

This commit is contained in:
zandercymatics 2024-02-12 14:14:15 -07:00
commit 5f99703883
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
18 changed files with 629 additions and 309 deletions

View file

@ -884,14 +884,11 @@ class DomainApplicationAdmin(ListHeaderAdmin):
if ( if (
obj obj
and original_obj.status == models.DomainApplication.ApplicationStatus.APPROVED and original_obj.status == models.DomainApplication.ApplicationStatus.APPROVED
and ( and obj.status != models.DomainApplication.ApplicationStatus.APPROVED
obj.status == models.DomainApplication.ApplicationStatus.REJECTED
or obj.status == models.DomainApplication.ApplicationStatus.INELIGIBLE
)
and not obj.domain_is_not_active() and not obj.domain_is_not_active()
): ):
# If an admin tried to set an approved application to # If an admin tried to set an approved application to
# rejected or ineligible and the related domain is already # another status and the related domain is already
# active, shortcut the action and throw a friendly # active, shortcut the action and throw a friendly
# error message. This action would still not go through # error message. This action would still not go through
# shortcut or not as the rules are duplicated on the model, # shortcut or not as the rules are duplicated on the model,

View file

@ -18,4 +18,7 @@
left: 1rem !important; left: 1rem !important;
} }
} }
.usa-alert__body.margin-left-1 {
margin-left: 0.5rem!important;
}
} }

View file

@ -116,6 +116,10 @@ in the form $setting: value,
$theme-color-success-light: $dhs-green-30, $theme-color-success-light: $dhs-green-30,
$theme-color-success-lighter: $dhs-green-15, $theme-color-success-lighter: $dhs-green-15,
/*---------------------------
## Emergency state
----------------------------*/
$theme-color-emergency: #FFC3F9,
/*--------------------------- /*---------------------------
# Input settings # Input settings

View file

@ -104,7 +104,7 @@ class DomainApplicationFixture:
# Random choice of agency for selects, used as placeholders for testing. # Random choice of agency for selects, used as placeholders for testing.
else random.choice(DomainApplication.AGENCIES) # nosec else random.choice(DomainApplication.AGENCIES) # nosec
) )
da.submission_date = fake.date()
da.federal_type = ( da.federal_type = (
app["federal_type"] app["federal_type"]
if "federal_type" in app if "federal_type" in app

View file

@ -578,6 +578,19 @@ class DomainApplication(TimeStampedModel):
return not self.approved_domain.is_active() return not self.approved_domain.is_active()
return True return True
def delete_and_clean_up_domain(self, called_from):
try:
domain_state = self.approved_domain.state
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
self.approved_domain.deletedInEpp()
self.approved_domain.save()
self.approved_domain.delete()
self.approved_domain = None
except Exception as err:
logger.error(err)
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): def _send_status_update_email(self, new_status, email_template, email_template_subject, send_email=True):
"""Send a status update email to the submitter. """Send a status update email to the submitter.
@ -641,6 +654,10 @@ class DomainApplication(TimeStampedModel):
self.submission_date = timezone.now().date() self.submission_date = timezone.now().date()
self.save() self.save()
# Limit email notifications to transitions from Started and Withdrawn
limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN]
if self.status in limited_statuses:
self._send_status_update_email( self._send_status_update_email(
"submission confirmation", "submission confirmation",
"emails/submission_confirmation.txt", "emails/submission_confirmation.txt",
@ -657,11 +674,19 @@ class DomainApplication(TimeStampedModel):
ApplicationStatus.INELIGIBLE, ApplicationStatus.INELIGIBLE,
], ],
target=ApplicationStatus.IN_REVIEW, target=ApplicationStatus.IN_REVIEW,
conditions=[domain_is_not_active],
) )
def in_review(self): def in_review(self):
"""Investigate an application that has been submitted. """Investigate an application that has been submitted.
This action is logged.""" This action is logged.
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")
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"
@ -676,11 +701,19 @@ class DomainApplication(TimeStampedModel):
ApplicationStatus.INELIGIBLE, ApplicationStatus.INELIGIBLE,
], ],
target=ApplicationStatus.ACTION_NEEDED, target=ApplicationStatus.ACTION_NEEDED,
conditions=[domain_is_not_active],
) )
def action_needed(self): def action_needed(self):
"""Send back an application that is under investigation or rejected. """Send back an application that is under investigation or rejected.
This action is logged.""" This action is logged.
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")
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"
@ -735,6 +768,7 @@ class DomainApplication(TimeStampedModel):
) )
def withdraw(self): def withdraw(self):
"""Withdraw an application that has been submitted.""" """Withdraw an application that has been submitted."""
self._send_status_update_email( self._send_status_update_email(
"withdraw", "withdraw",
"emails/domain_request_withdrawn.txt", "emails/domain_request_withdrawn.txt",
@ -752,18 +786,9 @@ class DomainApplication(TimeStampedModel):
As side effects this will delete the domain and domain_information As side effects this will delete the domain and domain_information
(will cascade), and send an email notification.""" (will cascade), and send an email notification."""
if self.status == self.ApplicationStatus.APPROVED: if self.status == self.ApplicationStatus.APPROVED:
try: self.delete_and_clean_up_domain("reject")
domain_state = self.approved_domain.state
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
self.approved_domain.deletedInEpp()
self.approved_domain.save()
self.approved_domain.delete()
self.approved_domain = None
except Exception as err:
logger.error(err)
logger.error("Can't query an approved domain while attempting a DA reject()")
self._send_status_update_email( self._send_status_update_email(
"action needed", "action needed",
@ -792,17 +817,7 @@ class DomainApplication(TimeStampedModel):
and domain_information (will cascade) when they exist.""" and domain_information (will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED: if self.status == self.ApplicationStatus.APPROVED:
try: self.delete_and_clean_up_domain("reject_with_prejudice")
domain_state = self.approved_domain.state
# Only reject if it exists on EPP
if domain_state != Domain.State.UNKNOWN:
self.approved_domain.deletedInEpp()
self.approved_domain.save()
self.approved_domain.delete()
self.approved_domain = None
except Exception as err:
logger.error(err)
logger.error("Can't query an approved domain while attempting a DA reject_with_prejudice()")
self.creator.restrict_user() self.creator.restrict_user()

View file

@ -24,15 +24,33 @@
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block extrastyle %}{{ block.super }} {% block extrastyle %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "css/styles.css" %}" /> <link rel="stylesheet" type="text/css" href="{% static 'css/styles.css' %}" />
{% endblock %} {% endblock %}
{% block header %}
{% if not IS_PRODUCTION %}
{% with add_body_class="margin-left-1" %}
{% include "includes/non-production-alert.html" %}
{% endwith %}
{% endif %}
{# Djando update: this div will change to header #}
<div id="header">
<div id="branding">
{% block branding %} {% block branding %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">.gov admin</a></h1> <h1 id="site-name"><a href="{% url 'admin:index' %}">.gov admin</a></h1>
{% if user.is_anonymous %} {% if user.is_anonymous %}
{% include "admin/color_theme_toggle.html" %} {% include "admin/color_theme_toggle.html" %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
</div>
{% block usertools %}
{% if has_permission %}
<div id="user-tools">
{% block welcome-msg %}
{% translate 'Welcome,' %}
<strong>{% firstof user.get_short_name user.get_username %}</strong>.
{% endblock %}
{% comment %} {% comment %}
This was copied from the 'userlinks' template, with a few minor changes. This was copied from the 'userlinks' template, with a few minor changes.
You can find that here: You can find that here:
@ -54,4 +72,9 @@
<a href="{% url 'admin:logout' %}" id="admin-logout-button">{% translate 'Log out' %}</a> <a href="{% url 'admin:logout' %}" id="admin-logout-button">{% translate 'Log out' %}</a>
{% include "admin/color_theme_toggle.html" %} {% include "admin/color_theme_toggle.html" %}
{% endblock %} {% endblock %}
</div>
{% endif %}
{% endblock %}
{% block nav-global %}{% endblock %} {% block nav-global %}{% endblock %}
</div>
{% endblock %}

View file

@ -8,11 +8,13 @@
<li class="usa-sidenav__item sidenav__step--locked"> <li class="usa-sidenav__item sidenav__step--locked">
<span> <span>
{% if not this_step == steps.current %} {% if not this_step == steps.current %}
{% if this_step != "review" %}
<svg class="usa-icon text-green" aria-hidden="true" focsuable="false" role="img" width="24" height="24"> <svg class="usa-icon text-green" aria-hidden="true" focsuable="false" role="img" width="24" height="24">
<title id="checked-step__{{forloop.counter}}">Checked mark</title> <title id="checked-step__{{forloop.counter}}">Checked mark</title>
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use> <use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
</svg> </svg>
{% endif %} {% endif %}
{% endif %}
<a href="{% namespaced_url 'application' this_step %}" <a href="{% namespaced_url 'application' this_step %}"
{% if this_step == steps.current %} {% if this_step == steps.current %}
class="usa-current" class="usa-current"

View file

@ -70,6 +70,10 @@
<script src="{% static 'js/uswds.min.js' %}" defer></script> <script src="{% static 'js/uswds.min.js' %}" defer></script>
<a class="usa-skipnav" href="#main-content">Skip to main content</a> <a class="usa-skipnav" href="#main-content">Skip to main content</a>
{% if not IS_PRODUCTION %}
{% include "includes/non-production-alert.html" %}
{% endif %}
<section class="usa-banner" aria-label="Official website of the United States government"> <section class="usa-banner" aria-label="Official website of the United States government">
<div class="usa-accordion"> <div class="usa-accordion">
<header class="usa-banner__header"> <header class="usa-banner__header">

View file

@ -18,7 +18,7 @@
<button <button
type="submit" type="submit"
class="usa-button" class="usa-button"
>Add user</button> >Add a domain manager</button>
</form> </form>
{% endblock %} {# domain_content #} {% endblock %} {# domain_content #}

View file

@ -0,0 +1,5 @@
<div class="usa-alert usa-alert--emergency margin-y-0 {% if add_class %}{{ add_class }}{% endif %}">
<div class="usa-alert__body {% if add_body_class %}{{ add_body_class }}{% endif %}">
<b>Attention:</b> You are on a test site.
</div>
</div>

View file

@ -306,6 +306,7 @@ class TestDomainApplicationAdminForm(TestCase):
) )
@boto3_mocking.patching
class TestDomainApplicationAdmin(MockEppLib): class TestDomainApplicationAdmin(MockEppLib):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -411,83 +412,166 @@ class TestDomainApplicationAdmin(MockEppLib):
# Now let's make sure the long description does not exist # Now let's make sure the long description does not exist
self.assertNotContains(response, "Federal: an agency of the U.S. government") self.assertNotContains(response, "Federal: an agency of the U.S. government")
@boto3_mocking.patching def transition_state_and_send_email(self, application, status):
def test_save_model_sends_submitted_email(self): """Helper method for the email test cases."""
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise(): with less_console_noise():
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Modify the application's property
application.status = status
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
def assert_email_is_accurate(self, expected_string, email_index, email_address):
"""Helper method for the email test cases.
email_index is the index of the email in mock_client."""
# Access the arguments passed to send_email
call_args = self.mock_client.EMAILS_SENT
kwargs = call_args[email_index]["kwargs"]
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
to_email = kwargs["Destination"]["ToAddresses"][0]
email_content = kwargs["Content"]
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
# Assert or perform other checks on the email details
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
self.assertEqual(to_email, email_address)
self.assertIn(expected_string, email_body)
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.
When transitioning to submitted from dns needed or in review on a domain request,
no email is sent out."""
# Ensure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
# Create a sample application # Create a sample application
application = completed_application() application = completed_application()
# Create a mock request # Test Submitted Status from started
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk)) self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL)
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.SUBMITTED
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = self.mock_client.EMAILS_SENT
kwargs = call_args[0]["kwargs"]
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
to_email = kwargs["Destination"]["ToAddresses"][0]
email_content = kwargs["Content"]
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
# Assert or perform other checks on the email details
expected_string = "We received your .gov domain request."
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@boto3_mocking.patching # Test Withdrawn Status
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.WITHDRAWN)
self.assert_email_is_accurate(
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 1, EMAIL
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
# Test Submitted Status Again (from withdrawn)
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Move it to IN_REVIEW
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.IN_REVIEW)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Test Submitted Status Again from in IN_REVIEW, no new email should be sent
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Move it to IN_REVIEW
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.IN_REVIEW)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Move it to ACTION_NEEDED
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.ACTION_NEEDED)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
# Test Submitted Status Again from in ACTION_NEEDED, no new email should be sent
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
def test_save_model_sends_approved_email(self): def test_save_model_sends_approved_email(self):
# make sure there is no user with this email """When transitioning to approved on a domain request,
an email is sent out every time."""
# Ensure there is no user with this email
EMAIL = "mayor@igorville.gov" EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete() User.objects.filter(email=EMAIL).delete()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a sample application # Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW) application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
# Create a mock request # Test Submitted Status
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk)) self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.APPROVED)
self.assert_email_is_accurate("Congratulations! Your .gov domain request has been approved.", 0, EMAIL)
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.APPROVED
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = self.mock_client.EMAILS_SENT
kwargs = call_args[0]["kwargs"]
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
to_email = kwargs["Destination"]["ToAddresses"][0]
email_content = kwargs["Content"]
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
# Assert or perform other checks on the email details
expected_string = "Congratulations! Your .gov domain request has been approved."
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@boto3_mocking.patching # Test Withdrawn Status
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.REJECTED)
self.assert_email_is_accurate("Your .gov domain request has been rejected.", 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.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."""
# 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)
# 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)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
# Test Withdrawn Status
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_withdrawn_email(self):
"""When transitioning to withdrawn on a domain request,
an email is sent out every time."""
# 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)
# Test Submitted Status
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.WITHDRAWN)
self.assert_email_is_accurate(
"Your .gov domain request has been withdrawn and will not be reviewed by our team.", 0, EMAIL
)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
# Test Withdrawn Status
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
self.assert_email_is_accurate("We received your .gov domain request.", 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.WITHDRAWN)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
def test_save_model_sets_approved_domain(self): def test_save_model_sets_approved_domain(self):
# make sure there is no user with this email # make sure there is no user with this email
EMAIL = "mayor@igorville.gov" EMAIL = "mayor@igorville.gov"
@ -510,45 +594,6 @@ class TestDomainApplicationAdmin(MockEppLib):
# Test that approved domain exists and equals requested domain # Test that approved domain exists and equals requested domain
self.assertEqual(application.requested_domain.name, application.approved_domain.name) self.assertEqual(application.requested_domain.name, application.approved_domain.name)
@boto3_mocking.patching
def test_save_model_sends_rejected_email(self):
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# Modify the application's property
application.status = DomainApplication.ApplicationStatus.REJECTED
# Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True)
# Access the arguments passed to send_email
call_args = self.mock_client.EMAILS_SENT
kwargs = call_args[0]["kwargs"]
# Retrieve the email details from the arguments
from_email = kwargs.get("FromEmailAddress")
to_email = kwargs["Destination"]["ToAddresses"][0]
email_content = kwargs["Content"]
email_body = email_content["Simple"]["Body"]["Text"]["Data"]
# Assert or perform other checks on the email details
expected_string = "Your .gov domain request has been rejected."
self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL)
self.assertEqual(to_email, EMAIL)
self.assertIn(expected_string, email_body)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
@boto3_mocking.patching
def test_save_model_sets_restricted_status_on_user(self): def test_save_model_sets_restricted_status_on_user(self):
# make sure there is no user with this email # make sure there is no user with this email
EMAIL = "mayor@igorville.gov" EMAIL = "mayor@igorville.gov"
@ -699,41 +744,13 @@ class TestDomainApplicationAdmin(MockEppLib):
"Cannot edit an application with a restricted creator.", "Cannot edit an application with a restricted creator.",
) )
@boto3_mocking.patching def trigger_saving_approved_to_another_state(self, domain_is_active, another_state):
def test_error_when_saving_approved_to_rejected_and_domain_is_active(self): """Helper method that triggers domain request state changes from approved to another state,
# Create an instance of the model with an associated domain that can be either active (READY) or not.
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name)
application.approved_domain = domain
application.save()
# Create a request object with a superuser Used to test errors when saving a change with an active domain, also used to test side effects
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk)) when saving a change goes through."""
request.user = self.superuser
# Define a custom implementation for is_active
def custom_is_active(self):
return True # Override to return True
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error"))
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.REJECTED
self.admin.save_model(request, application, None, True)
# Assert that the error message was called with the correct argument
messages.error.assert_called_once_with(
request,
"This action is not permitted. The domain " + "is already active.",
)
def test_side_effects_when_saving_approved_to_rejected(self):
# Create an instance of the model # Create an instance of the model
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED) application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name) domain = Domain.objects.create(name=application.requested_domain.name)
@ -747,19 +764,24 @@ class TestDomainApplicationAdmin(MockEppLib):
# Define a custom implementation for is_active # Define a custom implementation for is_active
def custom_is_active(self): def custom_is_active(self):
return False # Override to return False return domain_is_active # Override to return True
# Use ExitStack to combine patch contexts # Use ExitStack to combine patch contexts
with ExitStack() as stack: with ExitStack() as stack:
# Patch Domain.is_active and django.contrib.messages.error simultaneously # Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active)) stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error")) stack.enter_context(patch.object(messages, "error"))
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise(): application.status = another_state
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.REJECTED
self.admin.save_model(request, application, None, True) self.admin.save_model(request, application, None, True)
# Assert that the error message was called with the correct argument
if domain_is_active:
messages.error.assert_called_once_with(
request,
"This action is not permitted. The domain " + "is already active.",
)
else:
# Assert that the error message was never called # Assert that the error message was never called
messages.error.assert_not_called() messages.error.assert_not_called()
@ -773,75 +795,29 @@ class TestDomainApplicationAdmin(MockEppLib):
with self.assertRaises(DomainInformation.DoesNotExist): with self.assertRaises(DomainInformation.DoesNotExist):
domain_information.refresh_from_db() domain_information.refresh_from_db()
def test_error_when_saving_approved_to_in_review_and_domain_is_active(self):
self.trigger_saving_approved_to_another_state(True, DomainApplication.ApplicationStatus.IN_REVIEW)
def test_error_when_saving_approved_to_action_needed_and_domain_is_active(self):
self.trigger_saving_approved_to_another_state(True, DomainApplication.ApplicationStatus.ACTION_NEEDED)
def test_error_when_saving_approved_to_rejected_and_domain_is_active(self):
self.trigger_saving_approved_to_another_state(True, DomainApplication.ApplicationStatus.REJECTED)
def test_error_when_saving_approved_to_ineligible_and_domain_is_active(self): def test_error_when_saving_approved_to_ineligible_and_domain_is_active(self):
# Create an instance of the model self.trigger_saving_approved_to_another_state(True, DomainApplication.ApplicationStatus.INELIGIBLE)
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name)
application.approved_domain = domain
application.save()
# Create a request object with a superuser def test_side_effects_when_saving_approved_to_in_review(self):
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk)) self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.IN_REVIEW)
request.user = self.superuser
# Define a custom implementation for is_active def test_side_effects_when_saving_approved_to_action_needed(self):
def custom_is_active(self): self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.ACTION_NEEDED)
return True # Override to return True
# Use ExitStack to combine patch contexts def test_side_effects_when_saving_approved_to_rejected(self):
with ExitStack() as stack: self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.REJECTED)
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error"))
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.INELIGIBLE
self.admin.save_model(request, application, None, True)
# Assert that the error message was called with the correct argument
messages.error.assert_called_once_with(
request,
"This action is not permitted. The domain " + "is already active.",
)
def test_side_effects_when_saving_approved_to_ineligible(self): def test_side_effects_when_saving_approved_to_ineligible(self):
# Create an instance of the model self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.INELIGIBLE)
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name)
domain_information = DomainInformation.objects.create(creator=self.superuser, domain=domain)
application.approved_domain = domain
application.save()
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
request.user = self.superuser
# Define a custom implementation for is_active
def custom_is_active(self):
return False # Override to return False
# Use ExitStack to combine patch contexts
with ExitStack() as stack:
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error"))
# Simulate saving the model
application.status = DomainApplication.ApplicationStatus.INELIGIBLE
self.admin.save_model(request, application, None, True)
# Assert that the error message was never called
messages.error.assert_not_called()
self.assertEqual(application.approved_domain, None)
# Assert that Domain got Deleted
with self.assertRaises(Domain.DoesNotExist):
domain.refresh_from_db()
# Assert that DomainInformation got Deleted
with self.assertRaises(DomainInformation.DoesNotExist):
domain_information.refresh_from_db()
def test_has_correct_filters(self): def test_has_correct_filters(self):
""" """

View file

@ -0,0 +1,31 @@
from django.test import Client, TestCase, override_settings
from django.contrib.auth import get_user_model
class MyTestCase(TestCase):
def setUp(self):
self.client = Client()
username = "test_user"
first_name = "First"
last_name = "Last"
email = "info@example.com"
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
)
self.client.force_login(self.user)
def tearDown(self):
super().tearDown()
self.user.delete()
@override_settings(IS_PRODUCTION=True)
def test_production_environment(self):
"""No banner on prod."""
home_page = self.client.get("/")
self.assertNotContains(home_page, "You are on a test site.")
@override_settings(IS_PRODUCTION=False)
def test_non_production_environment(self):
"""Banner on non-prod."""
home_page = self.client.get("/")
self.assertContains(home_page, "You are on a test site.")

View file

@ -161,33 +161,63 @@ class TestDomainApplication(TestCase):
application.submit() application.submit()
self.assertEqual(application.status, application.ApplicationStatus.SUBMITTED) self.assertEqual(application.status, application.ApplicationStatus.SUBMITTED)
def test_submit_sends_email(self): def check_email_sent(self, application, msg, action, expected_count):
"""Create an application and submit it and see if email was sent.""" """Check if an email was sent after performing an action."""
with less_console_noise():
user, _ = User.objects.get_or_create(username="testy")
contact = Contact.objects.create(email="test@test.gov")
domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=user,
requested_domain=domain,
submitter=contact,
)
application.save()
with self.subTest(msg=msg, action=action):
with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
application.submit() with less_console_noise():
# Perform the specified action
action_method = getattr(application, action)
action_method()
# check to see if an email was sent # Check if an email was sent
self.assertGreater( sent_emails = [
len(
[
email email
for email in MockSESClient.EMAILS_SENT for email in MockSESClient.EMAILS_SENT
if "test@test.gov" in email["kwargs"]["Destination"]["ToAddresses"] if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"]
] ]
), self.assertEqual(len(sent_emails), expected_count)
0,
) def test_submit_from_started_sends_email(self):
msg = "Create an application and submit it and see if email was sent."
application = completed_application()
self.check_email_sent(application, msg, "submit", 1)
def test_submit_from_withdrawn_sends_email(self):
msg = "Create a withdrawn application and submit it and see if email was sent."
application = completed_application(status=DomainApplication.ApplicationStatus.WITHDRAWN)
self.check_email_sent(application, msg, "submit", 1)
def test_submit_from_action_needed_does_not_send_email(self):
msg = "Create an application with ACTION_NEEDED status and submit it, check if email was not sent."
application = completed_application(status=DomainApplication.ApplicationStatus.ACTION_NEEDED)
self.check_email_sent(application, msg, "submit", 0)
def test_submit_from_in_review_does_not_send_email(self):
msg = "Create a withdrawn application and submit it and see if email was sent."
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
self.check_email_sent(application, msg, "submit", 0)
def test_approve_sends_email(self):
msg = "Create an application and approve it and see if email was sent."
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
self.check_email_sent(application, msg, "approve", 1)
def test_withdraw_sends_email(self):
msg = "Create an application and withdraw it and see if email was sent."
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
self.check_email_sent(application, msg, "withdraw", 1)
def test_reject_sends_email(self):
msg = "Create an application and reject it and see if email was sent."
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
self.check_email_sent(application, msg, "reject", 1)
def test_reject_with_prejudice_does_not_send_email(self):
msg = "Create an application and reject it with prejudice and see if email was sent."
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
self.check_email_sent(application, msg, "reject_with_prejudice", 0)
def test_submit_transition_allowed(self): def test_submit_transition_allowed(self):
""" """
@ -464,6 +494,46 @@ class TestDomainApplication(TestCase):
with self.assertRaises(exception_type): with self.assertRaises(exception_type):
application.reject_with_prejudice() application.reject_with_prejudice()
def test_transition_not_allowed_approved_in_review_when_domain_is_active(self):
"""Create an application with status approved, create a matching domain that
is active, and call in_review against transition rules"""
domain = Domain.objects.create(name=self.approved_application.requested_domain.name)
self.approved_application.approved_domain = domain
self.approved_application.save()
# Define a custom implementation for is_active
def custom_is_active(self):
return True # Override to return True
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Use patch to temporarily replace is_active with the custom implementation
with patch.object(Domain, "is_active", custom_is_active):
# Now, when you call is_active on Domain, it will return True
with self.assertRaises(TransitionNotAllowed):
self.approved_application.in_review()
def test_transition_not_allowed_approved_action_needed_when_domain_is_active(self):
"""Create an application with status approved, create a matching domain that
is active, and call action_needed against transition rules"""
domain = Domain.objects.create(name=self.approved_application.requested_domain.name)
self.approved_application.approved_domain = domain
self.approved_application.save()
# Define a custom implementation for is_active
def custom_is_active(self):
return True # Override to return True
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Use patch to temporarily replace is_active with the custom implementation
with patch.object(Domain, "is_active", custom_is_active):
# Now, when you call is_active on Domain, it will return True
with self.assertRaises(TransitionNotAllowed):
self.approved_application.action_needed()
def test_transition_not_allowed_approved_rejected_when_domain_is_active(self): def test_transition_not_allowed_approved_rejected_when_domain_is_active(self):
"""Create an application with status approved, create a matching domain that """Create an application with status approved, create a matching domain that
is active, and call reject against transition rules""" is active, and call reject against transition rules"""

View file

@ -1,4 +1,5 @@
from unittest import skip from unittest import skip
from unittest.mock import Mock
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
@ -10,6 +11,7 @@ import boto3_mocking # type: ignore
from registrar.models import ( from registrar.models import (
DomainApplication, DomainApplication,
DraftDomain,
Domain, Domain,
DomainInformation, DomainInformation,
Contact, Contact,
@ -2202,6 +2204,134 @@ class DomainApplicationTestDifferentStatuses(TestWithUser, WebTest):
self.assertNotContains(home_page, "city.gov") self.assertNotContains(home_page, "city.gov")
class TestWizardUnlockingSteps(TestWithUser, WebTest):
def setUp(self):
super().setUp()
self.app.set_user(self.user.username)
self.wizard = ApplicationWizard()
# Mock the request object, its user, and session attributes appropriately
self.wizard.request = Mock(user=self.user, session={})
def tearDown(self):
super().tearDown()
def test_unlocked_steps_empty_application(self):
"""Test when all fields in the application are empty."""
unlocked_steps = self.wizard.db_check_for_unlocking_steps()
expected_dict = []
self.assertEqual(unlocked_steps, expected_dict)
def test_unlocked_steps_full_application(self):
"""Test when all fields in the application are filled."""
completed_application(status=DomainApplication.ApplicationStatus.STARTED, user=self.user)
# Make a request to the home page
home_page = self.app.get("/")
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
# and then setting the cookie on each request.
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Assert that the response contains "city.gov"
self.assertContains(home_page, "city.gov")
# Click the "Edit" link
response = home_page.click("Edit", index=0)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Check if the response is a redirect
if response.status_code == 302:
# Follow the redirect manually
try:
detail_page = response.follow()
self.wizard.get_context_data()
except Exception as err:
# Handle any potential errors while following the redirect
self.fail(f"Error following the redirect {err}")
# Now 'detail_page' contains the response after following the redirect
self.assertEqual(detail_page.status_code, 200)
# 10 unlocked steps, one active step, the review step will have link_usa but not check_circle
self.assertContains(detail_page, "#check_circle", count=10)
# Type of organization
self.assertContains(detail_page, "usa-current", count=1)
self.assertContains(detail_page, "link_usa-checked", count=11)
else:
self.fail(f"Expected a redirect, but got a different response: {response}")
def test_unlocked_steps_partial_application(self):
"""Test when some fields in the application are filled."""
# Create the site and contacts to delete (orphaned)
contact = Contact.objects.create(
first_name="Henry",
last_name="Mcfakerson",
)
# Create two non-orphaned contacts
contact_2 = Contact.objects.create(
first_name="Saturn",
last_name="Mars",
)
# Attach a user object to a contact (should not be deleted)
contact_user, _ = Contact.objects.get_or_create(user=self.user)
site = DraftDomain.objects.create(name="igorville.gov")
application = DomainApplication.objects.create(
creator=self.user,
requested_domain=site,
status=DomainApplication.ApplicationStatus.WITHDRAWN,
authorizing_official=contact,
submitter=contact_user,
)
application.other_contacts.set([contact_2])
# Make a request to the home page
home_page = self.app.get("/")
# django-webtest does not handle cookie-based sessions well because it keeps
# resetting the session key on each new request, thus destroying the concept
# of a "session". We are going to do it manually, saving the session ID here
# and then setting the cookie on each request.
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Assert that the response contains "city.gov"
self.assertContains(home_page, "igorville.gov")
# Click the "Edit" link
response = home_page.click("Edit", index=0)
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Check if the response is a redirect
if response.status_code == 302:
# Follow the redirect manually
try:
detail_page = response.follow()
self.wizard.get_context_data()
except Exception as err:
# Handle any potential errors while following the redirect
self.fail(f"Error following the redirect {err}")
# Now 'detail_page' contains the response after following the redirect
self.assertEqual(detail_page.status_code, 200)
# 5 unlocked steps (ao, domain, submitter, other contacts, and current sites
# which unlocks if domain exists), one active step, the review step is locked
self.assertContains(detail_page, "#check_circle", count=5)
# Type of organization
self.assertContains(detail_page, "usa-current", count=1)
self.assertContains(detail_page, "link_usa-checked", count=5)
else:
self.fail(f"Expected a redirect, but got a different response: {response}")
class HomeTests(TestWithUser): class HomeTests(TestWithUser):
"""A series of tests that target the two tables on home.html""" """A series of tests that target the two tables on home.html"""

View file

@ -92,6 +92,12 @@ def parse_row(columns, domain_info: DomainInformation, security_emails_dict=None
"Deleted": domain.deleted, "Deleted": domain.deleted,
} }
# user_emails = [user.email for user in domain.permissions]
# Dynamically add user emails to the FIELDS dictionary
# for i, user_email in enumerate(user_emails, start=1):
# FIELDS[f"User{i} email"] = user_email
row = [FIELDS.get(column, "") for column in columns] row = [FIELDS.get(column, "") for column in columns]
return row return row
@ -127,6 +133,16 @@ def write_body(
else: else:
logger.warning("csv_export -> Domain was none for PublicContact") logger.warning("csv_export -> Domain was none for PublicContact")
# all_user_nums = 0
# for domain_info in all_domain_infos:
# user_num = len(domain_info.domain.permissions)
# all_user_nums.append(user_num)
# if user_num > highest_user_nums:
# highest_user_nums = user_num
# Build the header here passing to it highest_user_nums
# Reduce the memory overhead when performing the write operation # Reduce the memory overhead when performing the write operation
paginator = Paginator(all_domain_infos, 1000) paginator = Paginator(all_domain_infos, 1000)
for page_num in paginator.page_range: for page_num in paginator.page_range:

View file

@ -159,6 +159,10 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
def storage(self): def storage(self):
# marking session as modified on every access # marking session as modified on every access
# so that updates to nested keys are always saved # so that updates to nested keys are always saved
# Also - check that self.request.session has the attr
# modified to account for test environments calling
# view methods
if hasattr(self.request.session, "modified"):
self.request.session.modified = True self.request.session.modified = True
return self.request.session.setdefault(self.prefix, {}) return self.request.session.setdefault(self.prefix, {})
@ -211,6 +215,7 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
if current_url == self.EDIT_URL_NAME and "id" in kwargs: if current_url == self.EDIT_URL_NAME and "id" in kwargs:
del self.storage del self.storage
self.storage["application_id"] = kwargs["id"] self.storage["application_id"] = kwargs["id"]
self.storage["step_history"] = self.db_check_for_unlocking_steps()
# if accessing this class directly, redirect to the first step # if accessing this class directly, redirect to the first step
# in other words, if `ApplicationWizard` is called as view # in other words, if `ApplicationWizard` is called as view
@ -269,6 +274,7 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
and from the database if `use_db` is True (provided that record exists). and from the database if `use_db` is True (provided that record exists).
An empty form will be provided if neither of those are true. An empty form will be provided if neither of those are true.
""" """
kwargs = { kwargs = {
"files": files, "files": files,
"prefix": self.steps.current, "prefix": self.steps.current,
@ -329,6 +335,43 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
] ]
return DomainApplication.objects.filter(creator=self.request.user, status__in=check_statuses) return DomainApplication.objects.filter(creator=self.request.user, status__in=check_statuses)
def db_check_for_unlocking_steps(self):
"""Helper for get_context_data
Queries the DB for an application and returns a list of unlocked steps."""
history_dict = {
"organization_type": self.application.organization_type is not None,
"tribal_government": self.application.tribe_name is not None,
"organization_federal": self.application.federal_type is not None,
"organization_election": self.application.is_election_board is not None,
"organization_contact": (
self.application.federal_agency is not None
or self.application.organization_name is not None
or self.application.address_line1 is not None
or self.application.city is not None
or self.application.state_territory is not None
or self.application.zipcode is not None
or self.application.urbanization is not None
),
"about_your_organization": self.application.about_your_organization is not None,
"authorizing_official": self.application.authorizing_official is not None,
"current_sites": (
self.application.current_websites.exists() or self.application.requested_domain is not None
),
"dotgov_domain": self.application.requested_domain is not None,
"purpose": self.application.purpose is not None,
"your_contact": self.application.submitter is not None,
"other_contacts": (
self.application.other_contacts.exists() or self.application.no_other_contacts_rationale is not None
),
"anything_else": (
self.application.anything_else is not None or self.application.is_policy_acknowledged is not None
),
"requirements": self.application.is_policy_acknowledged is not None,
"review": self.application.is_policy_acknowledged is not None,
}
return [key for key, value in history_dict.items() if value]
def get_context_data(self): def get_context_data(self):
"""Define context for access on all wizard pages.""" """Define context for access on all wizard pages."""
# Build the submit button that we'll pass to the modal. # Build the submit button that we'll pass to the modal.
@ -338,6 +381,7 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
modal_heading = "You are about to submit a domain request for " + str(self.application.requested_domain) modal_heading = "You are about to submit a domain request for " + str(self.application.requested_domain)
else: else:
modal_heading = "You are about to submit an incomplete request" modal_heading = "You are about to submit an incomplete request"
return { return {
"form_titles": self.TITLES, "form_titles": self.TITLES,
"steps": self.steps, "steps": self.steps,

View file

@ -555,7 +555,7 @@ class DomainYourContactInformationView(DomainFormBaseView):
# Post to DB using values from the form # Post to DB using values from the form
form.save() form.save()
messages.success(self.request, "Your contact information has been updated.") messages.success(self.request, "Your contact information for all your domains has been updated.")
# superclass has the redirect # superclass has the redirect
return super().form_valid(form) return super().form_valid(form)

View file

@ -6,4 +6,4 @@ set -o pipefail
# Make sure that django's `collectstatic` has been run locally before pushing up to any environment, # Make sure that django's `collectstatic` has been run locally before pushing up to any environment,
# so that the styles and static assets to show up correctly on any environment. # so that the styles and static assets to show up correctly on any environment.
gunicorn registrar.config.wsgi -t 60 gunicorn --worker-class=gevent registrar.config.wsgi -t 60