diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 196449bfa..e1c809058 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -1,4 +1,6 @@ from __future__ import annotations +from json import JSONDecodeError +import json from typing import Union import logging @@ -12,6 +14,7 @@ from registrar.models.domain import Domain from .utility.time_stamped_model import TimeStampedModel from ..utility.email import send_templated_email, EmailSendingError from itertools import chain +from auditlog.models import LogEntry # type: ignore logger = logging.getLogger(__name__) @@ -565,6 +568,25 @@ class DomainApplication(TimeStampedModel): except Exception: return "" + def has_previously_had_a_status_of(self, status): + """Return True if this request has previously had the status of {passed param}.""" + + log_entries = LogEntry.objects.get_for_object(self) + + for entry in log_entries: + try: + changes_dict = json.loads(entry.changes) + logger.info(changes_dict) + # changes_dict will look like {'status': ['withdrawn', 'submitted']}, + # henceforth the len(changes_dict.get('status', [])) == 2 + if len(changes_dict.get("status", [])) == 2 and changes_dict.get("status", [])[1] == status: + logger.info(f"found one instance where it had a status of {status}") + return True + except JSONDecodeError: + pass + + return False + def domain_is_not_active(self): if self.approved_domain: return not self.approved_domain.is_active() @@ -633,11 +655,13 @@ 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 for this transition to the first time the request transitions to this status + if not self.has_previously_had_a_status_of("submitted"): + self._send_status_update_email( + "submission confirmation", + "emails/submission_confirmation.txt", + "emails/submission_confirmation_subject.txt", + ) @transition( field="status", @@ -713,12 +737,14 @@ class DomainApplication(TimeStampedModel): user=self.creator, domain=created_domain, role=UserDomainRole.Roles.MANAGER ) - self._send_status_update_email( - "application approved", - "emails/status_change_approved.txt", - "emails/status_change_approved_subject.txt", - send_email, - ) + # Limit email notifications for this transition to the first time the request transitions to this status + if not self.has_previously_had_a_status_of("approved"): + self._send_status_update_email( + "application approved", + "emails/status_change_approved.txt", + "emails/status_change_approved_subject.txt", + send_email, + ) @transition( field="status", @@ -727,11 +753,14 @@ class DomainApplication(TimeStampedModel): ) def withdraw(self): """Withdraw an application that has been submitted.""" - self._send_status_update_email( - "withdraw", - "emails/domain_request_withdrawn.txt", - "emails/domain_request_withdrawn_subject.txt", - ) + + # Limit email notifications for this transition to the first time the request transitions to this status + if not self.has_previously_had_a_status_of("withdrawn"): + self._send_status_update_email( + "withdraw", + "emails/domain_request_withdrawn.txt", + "emails/domain_request_withdrawn_subject.txt", + ) @transition( field="status", @@ -757,11 +786,13 @@ class DomainApplication(TimeStampedModel): logger.error(err) logger.error("Can't query an approved domain while attempting a DA reject()") - self._send_status_update_email( - "action needed", - "emails/status_change_rejected.txt", - "emails/status_change_rejected_subject.txt", - ) + # Limit email notifications for this transition to the first time the request transitions to this status + if not self.has_previously_had_a_status_of("rejected"): + self._send_status_update_email( + "action needed", + "emails/status_change_rejected.txt", + "emails/status_change_rejected_subject.txt", + ) @transition( field="status", diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 7c9aa8fe4..7773cb60b 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -316,6 +316,7 @@ class TestDomainApplicationAdminForm(TestCase): ) +@boto3_mocking.patching class TestDomainApplicationAdmin(MockEppLib): def setUp(self): super().setUp() @@ -421,83 +422,143 @@ 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 the first time (and the first time only) on a domain request, + an 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 + 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 (No new email should be sent) + self.transition_state_and_send_email(application, DomainApplication.ApplicationStatus.SUBMITTED) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 2) + def test_save_model_sends_approved_email(self): - # make sure there is no user with this email + """When transitioning to approved the first time (and the first time only) on a domain request, + an 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(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), 2) + + def test_save_model_sends_rejected_email(self): + """When transitioning to rejected the first time (and the first time only) on a domain request, + an email is sent out.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample application + 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), 2) + + def test_save_model_sends_withdrawn_email(self): + """When transitioning to withdrawn the first time (and the first time only) on a domain request, + an email is sent out.""" + + # Ensure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + # Create a sample application + 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), 2) + def test_save_model_sets_approved_domain(self): # make sure there is no user with this email EMAIL = "mayor@igorville.gov" @@ -520,45 +581,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" @@ -707,7 +729,6 @@ 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) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index ef6522747..a1b22373d 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1,6 +1,6 @@ from django.test import TestCase from django.db.utils import IntegrityError -from unittest.mock import patch +from unittest.mock import MagicMock, patch from registrar.models import ( Contact, @@ -154,17 +154,34 @@ class TestDomainApplication(TestCase): application.submit() self.assertEqual(application.status, application.ApplicationStatus.SUBMITTED) + @patch("auditlog.models.LogEntry.objects.get_for_object") + def test_has_previously_had_a_status_of_returns_true(self, mock_get_for_object): + """Set up mock LogEntry.objects.get_for_object to return a log entry with the desired status""" + + log_entry_with_status = MagicMock(changes='{"status": ["previous_status", "desired_status"]}') + mock_get_for_object.return_value = [log_entry_with_status] + + result = self.started_application.has_previously_had_a_status_of("desired_status") + + self.assertTrue(result) + + @patch("auditlog.models.LogEntry.objects.get_for_object") + def test_has_previously_had_a_status_of_returns_false(self, mock_get_for_object): + """Set up mock LogEntry.objects.get_for_object to return a log entry + with a different status than the desired status""" + + log_entry_with_status = MagicMock(changes='{"status": ["previous_status", "different_status"]}') + mock_get_for_object.return_value = [log_entry_with_status] + + result = self.started_application.has_previously_had_a_status_of("desired_status") + + self.assertFalse(result) + def test_submit_sends_email(self): """Create an application and submit it and see if email was sent.""" - 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() + + # submitter's email is mayor@igorville.gov + application = completed_application() with boto3_mocking.clients.handler_for("sesv2", self.mock_client): with less_console_noise(): @@ -176,7 +193,185 @@ class TestDomainApplication(TestCase): [ email for email in MockSESClient.EMAILS_SENT - if "test@test.gov" in email["kwargs"]["Destination"]["ToAddresses"] + if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"] + ] + ), + 0, + ) + + @patch("auditlog.models.LogEntry.objects.get_for_object") + def test_submit_does_not_send_email_if_submitted_previously(self, mock_get_for_object): + """Create an application, make it so it was submitted previously, submit it, + and see that an email was not sent.""" + + # submitter's email is mayor@igorville.gov + application = completed_application() + + # Mock the logs + log_entry_with_status = MagicMock(changes='{"status": ["started", "submitted"]}') + mock_get_for_object.return_value = [log_entry_with_status] + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + application.submit() + + # check to see if an email was sent + self.assertEqual( + len( + [ + email + for email in MockSESClient.EMAILS_SENT + if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"] + ] + ), + 0, + ) + + def test_approve_sends_email(self): + """Create an application and approve it and see if email was sent.""" + + # submitter's email is mayor@igorville.gov + application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW) + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + application.approve() + + # check to see if an email was sent + self.assertGreater( + len( + [ + email + for email in MockSESClient.EMAILS_SENT + if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"] + ] + ), + 0, + ) + + @patch("auditlog.models.LogEntry.objects.get_for_object") + def test_approve_does_not_send_email_if_approved_previously(self, mock_get_for_object): + """Create an application, make it so it was approved previously, approve it, + and see that an email was not sent.""" + + # submitter's email is mayor@igorville.gov + application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW) + + # Mock the logs + log_entry_with_status = MagicMock(changes='{"status": ["submitted", "approved"]}') + mock_get_for_object.return_value = [log_entry_with_status] + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + application.approve() + + # check to see if an email was sent + self.assertEqual( + len( + [ + email + for email in MockSESClient.EMAILS_SENT + if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"] + ] + ), + 0, + ) + + def test_withdraw_sends_email(self): + """Create an application and withdraw it and see if email was sent.""" + + # submitter's email is mayor@igorville.gov + application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW) + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + application.withdraw() + + # check to see if an email was sent + self.assertGreater( + len( + [ + email + for email in MockSESClient.EMAILS_SENT + if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"] + ] + ), + 0, + ) + + @patch("auditlog.models.LogEntry.objects.get_for_object") + def test_withdraw_does_not_send_email_if_withdrawn_previously(self, mock_get_for_object): + """Create an application, make it so it was withdrawn previously, withdraw it, + and see that an email was not sent.""" + + # submitter's email is mayor@igorville.gov + application = completed_application(status=DomainApplication.ApplicationStatus.IN_REVIEW) + + # Mock the logs + log_entry_with_status = MagicMock(changes='{"status": ["submitted", "withdrawn"]}') + mock_get_for_object.return_value = [log_entry_with_status] + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + application.withdraw() + + # check to see if an email was sent + self.assertEqual( + len( + [ + email + for email in MockSESClient.EMAILS_SENT + if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"] + ] + ), + 0, + ) + + def test_reject_sends_email(self): + """Create an application and reject it and see if email was sent.""" + + # submitter's email is mayor@igorville.gov + application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED) + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + application.reject() + + # check to see if an email was sent + self.assertGreater( + len( + [ + email + for email in MockSESClient.EMAILS_SENT + if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"] + ] + ), + 0, + ) + + @patch("auditlog.models.LogEntry.objects.get_for_object") + def test_reject_does_not_send_email_if_rejected_previously(self, mock_get_for_object): + """Create an application, make it so it was rejected previously, reject it, + and see that an email was not sent.""" + + # submitter's email is mayor@igorville.gov + application = completed_application(status=DomainApplication.ApplicationStatus.APPROVED) + + # Mock the logs + log_entry_with_status = MagicMock(changes='{"status": ["submitted", "rejected"]}') + mock_get_for_object.return_value = [log_entry_with_status] + + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + with less_console_noise(): + application.reject() + + # check to see if an email was sent + self.assertEqual( + len( + [ + email + for email in MockSESClient.EMAILS_SENT + if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"] ] ), 0,