diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 1bfda0b84..4034bf35b 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -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, diff --git a/src/registrar/assets/sass/_theme/_alerts.scss b/src/registrar/assets/sass/_theme/_alerts.scss index 9ee28a357..163f243d3 100644 --- a/src/registrar/assets/sass/_theme/_alerts.scss +++ b/src/registrar/assets/sass/_theme/_alerts.scss @@ -17,5 +17,8 @@ .usa-alert__body::before { left: 1rem !important; } - } + } + .usa-alert__body.margin-left-1 { + margin-left: 0.5rem!important; + } } diff --git a/src/registrar/assets/sass/_theme/_uswds-theme.scss b/src/registrar/assets/sass/_theme/_uswds-theme.scss index 0cdf6675e..a26f23508 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme.scss @@ -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 diff --git a/src/registrar/fixtures_applications.py b/src/registrar/fixtures_applications.py index 92094b876..659a3040e 100644 --- a/src/registrar/fixtures_applications.py +++ b/src/registrar/fixtures_applications.py @@ -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 diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 4c1a8e22c..f048bdb89 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -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() diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index c0884c912..f9ff23455 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -24,34 +24,57 @@ {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} {% block extrastyle %}{{ block.super }} - + {% endblock %} -{% block branding %} -

.gov admin

-{% 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 #} + {% 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 %} - {% translate 'View site' %} / - {% endif %} - {% if user.is_active and user.is_staff %} - {% url 'django-admindocs-docroot' as docsroot %} - {% if docsroot %} - {% translate 'Documentation' %} / - {% endif %} - {% endif %} - {% if user.has_usable_password %} - {% translate 'Change password' %} / - {% endif %} - {% translate 'Log out' %} - {% include "admin/color_theme_toggle.html" %} - {% endblock %} -{% block nav-global %}{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/application_sidebar.html b/src/registrar/templates/application_sidebar.html index 318bea366..e0e51dd45 100644 --- a/src/registrar/templates/application_sidebar.html +++ b/src/registrar/templates/application_sidebar.html @@ -8,10 +8,12 @@
  • {% if not this_step == steps.current %} - + {% if this_step != "review" %} + + {% endif %} {% endif %} Skip to main content + {% if not IS_PRODUCTION %} + {% include "includes/non-production-alert.html" %} + {% endif %} +
    diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index d67c343a6..65290832d 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -18,7 +18,7 @@ + >Add a domain manager {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/includes/non-production-alert.html b/src/registrar/templates/includes/non-production-alert.html new file mode 100644 index 000000000..8e40892bc --- /dev/null +++ b/src/registrar/templates/includes/non-production-alert.html @@ -0,0 +1,5 @@ +
    +
    + Attention: You are on a test site. +
    +
    diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 86cc287e8..f90b18584 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -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): """ diff --git a/src/registrar/tests/test_environment_variables_effects.py b/src/registrar/tests/test_environment_variables_effects.py new file mode 100644 index 000000000..3a838c2a2 --- /dev/null +++ b/src/registrar/tests/test_environment_variables_effects.py @@ -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.") diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 294ec70af..0cb050f41 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -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""" diff --git a/src/registrar/tests/test_views_application.py b/src/registrar/tests/test_views_application.py index 733eaf578..0a596a148 100644 --- a/src/registrar/tests/test_views_application.py +++ b/src/registrar/tests/test_views_application.py @@ -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""" diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index e2e5f9c54..a188fb91c 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -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: diff --git a/src/registrar/views/application.py b/src/registrar/views/application.py index a15f36ccc..b71018d81 100644 --- a/src/registrar/views/application.py +++ b/src/registrar/views/application.py @@ -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, diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 04fe1ce3a..e6af77b6c 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -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) diff --git a/src/run.sh b/src/run.sh index 487c54591..1d35cd617 100755 --- a/src/run.sh +++ b/src/run.sh @@ -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