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 (
obj
and original_obj.status == models.DomainApplication.ApplicationStatus.APPROVED
and (
obj.status == models.DomainApplication.ApplicationStatus.REJECTED
or obj.status == models.DomainApplication.ApplicationStatus.INELIGIBLE
)
and obj.status != models.DomainApplication.ApplicationStatus.APPROVED
and not obj.domain_is_not_active()
):
# 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
# error message. This action would still not go through
# shortcut or not as the rules are duplicated on the model,

View file

@ -17,5 +17,8 @@
.usa-alert__body::before {
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-lighter: $dhs-green-15,
/*---------------------------
## Emergency state
----------------------------*/
$theme-color-emergency: #FFC3F9,
/*---------------------------
# Input settings

View file

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

View file

@ -578,6 +578,19 @@ class DomainApplication(TimeStampedModel):
return not self.approved_domain.is_active()
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):
"""Send a status update email to the submitter.
@ -641,11 +654,15 @@ class DomainApplication(TimeStampedModel):
self.submission_date = timezone.now().date()
self.save()
self._send_status_update_email(
"submission confirmation",
"emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt",
)
# 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(
"submission confirmation",
"emails/submission_confirmation.txt",
"emails/submission_confirmation_subject.txt",
)
@transition(
field="status",
@ -657,11 +674,19 @@ class DomainApplication(TimeStampedModel):
ApplicationStatus.INELIGIBLE,
],
target=ApplicationStatus.IN_REVIEW,
conditions=[domain_is_not_active],
)
def in_review(self):
"""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
# Check if the tuple exists, then grab its value
in_review = literal if literal is not None else "In Review"
@ -676,11 +701,19 @@ class DomainApplication(TimeStampedModel):
ApplicationStatus.INELIGIBLE,
],
target=ApplicationStatus.ACTION_NEEDED,
conditions=[domain_is_not_active],
)
def action_needed(self):
"""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
# Check if the tuple is setup correctly, then grab its value
action_needed = literal if literal is not None else "Action Needed"
@ -735,6 +768,7 @@ class DomainApplication(TimeStampedModel):
)
def withdraw(self):
"""Withdraw an application that has been submitted."""
self._send_status_update_email(
"withdraw",
"emails/domain_request_withdrawn.txt",
@ -752,18 +786,9 @@ class DomainApplication(TimeStampedModel):
As side effects this will delete the domain and domain_information
(will cascade), and send an email notification."""
if self.status == self.ApplicationStatus.APPROVED:
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("Can't query an approved domain while attempting a DA reject()")
self.delete_and_clean_up_domain("reject")
self._send_status_update_email(
"action needed",
@ -792,17 +817,7 @@ class DomainApplication(TimeStampedModel):
and domain_information (will cascade) when they exist."""
if self.status == self.ApplicationStatus.APPROVED:
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("Can't query an approved domain while attempting a DA reject_with_prejudice()")
self.delete_and_clean_up_domain("reject_with_prejudice")
self.creator.restrict_user()

View file

@ -24,34 +24,57 @@
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% 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 %}
{% block branding %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">.gov admin</a></h1>
{% if user.is_anonymous %}
{% include "admin/color_theme_toggle.html" %}
{% endif %}
{% 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 %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">.gov admin</a></h1>
{% if user.is_anonymous %}
{% include "admin/color_theme_toggle.html" %}
{% endif %}
{% 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 %}
This was copied from the 'userlinks' template, with a few minor changes.
You can find that here:
https://github.com/django/django/blob/d25f3892114466d689fd6936f79f3bd9a9acc30e/django/contrib/admin/templates/admin/base.html#L59
{% endcomment %}
{% block userlinks %}
{% if site_url %}
<a href="{{ site_url }}">{% translate 'View site' %}</a> /
{% endif %}
{% if user.is_active and user.is_staff %}
{% url 'django-admindocs-docroot' as docsroot %}
{% if docsroot %}
<a href="{{ docsroot }}">{% translate 'Documentation' %}</a> /
{% endif %}
{% endif %}
{% if user.has_usable_password %}
<a href="{% url 'admin:password_change' %}">{% translate 'Change password' %}</a> /
{% endif %}
<a href="{% url 'admin:logout' %}" id="admin-logout-button">{% translate 'Log out' %}</a>
{% include "admin/color_theme_toggle.html" %}
{% endblock %}
</div>
{% endif %}
{% endblock %}
{% block nav-global %}{% endblock %}
</div>
{% endblock %}
{% comment %}
This was copied from the 'userlinks' template, with a few minor changes.
You can find that here:
https://github.com/django/django/blob/d25f3892114466d689fd6936f79f3bd9a9acc30e/django/contrib/admin/templates/admin/base.html#L59
{% endcomment %}
{% block userlinks %}
{% if site_url %}
<a href="{{ site_url }}">{% translate 'View site' %}</a> /
{% endif %}
{% if user.is_active and user.is_staff %}
{% url 'django-admindocs-docroot' as docsroot %}
{% if docsroot %}
<a href="{{ docsroot }}">{% translate 'Documentation' %}</a> /
{% endif %}
{% endif %}
{% if user.has_usable_password %}
<a href="{% url 'admin:password_change' %}">{% translate 'Change password' %}</a> /
{% endif %}
<a href="{% url 'admin:logout' %}" id="admin-logout-button">{% translate 'Log out' %}</a>
{% include "admin/color_theme_toggle.html" %}
{% endblock %}
{% block nav-global %}{% endblock %}

View file

@ -8,10 +8,12 @@
<li class="usa-sidenav__item sidenav__step--locked">
<span>
{% if not this_step == steps.current %}
<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>
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
</svg>
{% if this_step != "review" %}
<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>
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
</svg>
{% endif %}
{% endif %}
<a href="{% namespaced_url 'application' this_step %}"
{% if this_step == steps.current %}

View file

@ -70,6 +70,10 @@
<script src="{% static 'js/uswds.min.js' %}" defer></script>
<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">
<div class="usa-accordion">
<header class="usa-banner__header">

View file

@ -18,7 +18,7 @@
<button
type="submit"
class="usa-button"
>Add user</button>
>Add a domain manager</button>
</form>
{% 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):
def setUp(self):
super().setUp()
@ -411,83 +412,166 @@ 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")
@boto3_mocking.patching
def transition_state_and_send_email(self, application, status):
"""Helper method for the email test cases."""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a mock request
request = self.factory.post("/admin/registrar/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):
# make sure there is no user with this email
"""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()
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with less_console_noise():
# Create a sample application
application = completed_application()
# Create a mock request
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
# 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)
# Create a sample application
application = completed_application()
# Test Submitted Status from started
self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED)
self.assert_email_is_accurate("We received your .gov domain request.", 0, EMAIL)
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):
# 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"
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.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)
# Create a sample application
application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW)
# Test Submitted Status
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)
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):
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
@ -510,45 +594,6 @@ class TestDomainApplicationAdmin(MockEppLib):
# Test that approved domain exists and equals requested domain
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):
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
@ -699,41 +744,13 @@ class TestDomainApplicationAdmin(MockEppLib):
"Cannot edit an application with a restricted creator.",
)
@boto3_mocking.patching
def test_error_when_saving_approved_to_rejected_and_domain_is_active(self):
# Create an instance of the model
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name)
application.approved_domain = domain
application.save()
def trigger_saving_approved_to_another_state(self, domain_is_active, another_state):
"""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.
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
request.user = self.superuser
Used to test errors when saving a change with an active domain, also used to test side effects
when saving a change goes through."""
# 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
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name)
@ -747,101 +764,60 @@ class TestDomainApplicationAdmin(MockEppLib):
# Define a custom implementation for is_active
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
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 never called
messages.error.assert_not_called()
application.status = another_state
self.admin.save_model(request, application, None, True)
self.assertEqual(application.approved_domain, None)
# 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
messages.error.assert_not_called()
# Assert that Domain got Deleted
with self.assertRaises(Domain.DoesNotExist):
domain.refresh_from_db()
self.assertEqual(application.approved_domain, None)
# Assert that DomainInformation got Deleted
with self.assertRaises(DomainInformation.DoesNotExist):
domain_information.refresh_from_db()
# 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_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):
# Create an instance of the model
application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED)
domain = Domain.objects.create(name=application.requested_domain.name)
application.approved_domain = domain
application.save()
self.trigger_saving_approved_to_another_state(True, DomainApplication.ApplicationStatus.INELIGIBLE)
# Create a request object with a superuser
request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
request.user = self.superuser
def test_side_effects_when_saving_approved_to_in_review(self):
self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.IN_REVIEW)
# Define a custom implementation for is_active
def custom_is_active(self):
return True # Override to return True
def test_side_effects_when_saving_approved_to_action_needed(self):
self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.ACTION_NEEDED)
# 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 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):
self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.REJECTED)
def test_side_effects_when_saving_approved_to_ineligible(self):
# Create an instance of the model
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()
self.trigger_saving_approved_to_another_state(False, DomainApplication.ApplicationStatus.INELIGIBLE)
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()
self.assertEqual(application.status, application.ApplicationStatus.SUBMITTED)
def test_submit_sends_email(self):
"""Create an application and submit it and see if email was sent."""
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()
def check_email_sent(self, application, msg, action, expected_count):
"""Check if an email was sent after performing an action."""
with self.subTest(msg=msg, action=action):
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
self.assertGreater(
len(
[
email
for email in MockSESClient.EMAILS_SENT
if "test@test.gov" in email["kwargs"]["Destination"]["ToAddresses"]
]
),
0,
)
# Check if an email was sent
sent_emails = [
email
for email in MockSESClient.EMAILS_SENT
if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"]
]
self.assertEqual(len(sent_emails), expected_count)
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):
"""
@ -464,6 +494,46 @@ class TestDomainApplication(TestCase):
with self.assertRaises(exception_type):
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):
"""Create an application with status approved, create a matching domain that
is active, and call reject against transition rules"""

View file

@ -1,4 +1,5 @@
from unittest import skip
from unittest.mock import Mock
from django.conf import settings
from django.urls import reverse
@ -10,6 +11,7 @@ import boto3_mocking # type: ignore
from registrar.models import (
DomainApplication,
DraftDomain,
Domain,
DomainInformation,
Contact,
@ -2202,6 +2204,134 @@ class DomainApplicationTestDifferentStatuses(TestWithUser, WebTest):
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):
"""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,
}
# 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]
return row
@ -127,6 +133,16 @@ def write_body(
else:
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
paginator = Paginator(all_domain_infos, 1000)
for page_num in paginator.page_range:

View file

@ -159,7 +159,11 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
def storage(self):
# marking session as modified on every access
# so that updates to nested keys are always saved
self.request.session.modified = True
# 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
return self.request.session.setdefault(self.prefix, {})
@storage.setter
@ -211,6 +215,7 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
if current_url == self.EDIT_URL_NAME and "id" in kwargs:
del self.storage
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
# 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).
An empty form will be provided if neither of those are true.
"""
kwargs = {
"files": files,
"prefix": self.steps.current,
@ -329,6 +335,43 @@ class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
]
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):
"""Define context for access on all wizard pages."""
# 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)
else:
modal_heading = "You are about to submit an incomplete request"
return {
"form_titles": self.TITLES,
"steps": self.steps,

View file

@ -555,7 +555,7 @@ class DomainYourContactInformationView(DomainFormBaseView):
# Post to DB using values from the form
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
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,
# 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