diff --git a/src/registrar/admin.py b/src/registrar/admin.py index c4b6b7ad8..17e947b3c 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -272,7 +272,7 @@ class DomainApplicationAdmin(ListHeaderAdmin): elif obj.status == models.DomainApplication.WITHDRAWN: original_obj.withdraw() elif obj.status == models.DomainApplication.REJECTED: - original_obj.reject() + original_obj.reject(updated_domain_application=obj) else: logger.warning("Unknown status selected in django admin") diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index b869173c9..b7b371619 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -501,7 +501,9 @@ class DomainApplication(TimeStampedModel): except EmailSendingError: logger.warning("Failed to send confirmation email", exc_info=True) - @transition(field="status", source=[STARTED, ACTION_NEEDED, WITHDRAWN], target=SUBMITTED) + @transition( + field="status", source=[STARTED, ACTION_NEEDED, WITHDRAWN], target=SUBMITTED + ) def submit(self, updated_domain_application=None): """Submit an application that is started. @@ -558,7 +560,7 @@ class DomainApplication(TimeStampedModel): "emails/status_change_in_review.txt", "emails/status_change_in_review_subject.txt", ) - + @transition(field="status", source=[INVESTIGATING, REJECTED], target=ACTION_NEEDED) def action_needed(self, updated_domain_application): """Send back an application that is under investigation or rejected. @@ -571,7 +573,9 @@ class DomainApplication(TimeStampedModel): "emails/status_change_action_needed_subject.txt", ) - @transition(field="status", source=[SUBMITTED, INVESTIGATING, REJECTED], target=APPROVED) + @transition( + field="status", source=[SUBMITTED, INVESTIGATING, REJECTED], target=APPROVED + ) def approve(self, updated_domain_application=None): """Approve an application that has been submitted. @@ -620,10 +624,18 @@ class DomainApplication(TimeStampedModel): @transition(field="status", source=[SUBMITTED, INVESTIGATING], target=WITHDRAWN) def withdraw(self): """Withdraw an application that has been submitted.""" - + @transition(field="status", source=[INVESTIGATING, APPROVED], target=REJECTED) - def reject(self): - """Reject an application that has been submitted.""" + def reject(self, updated_domain_application): + """Reject an application that has been submitted. + + As a side effect, an email notification is sent, similar to in_review""" + + updated_domain_application._send_status_update_email( + "action needed", + "emails/status_change_rejected.txt", + "emails/status_change_rejected_subject.txt", + ) # ## Form policies ### # diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index d5396a829..036d34ad8 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -76,9 +76,6 @@ class TestDomainApplicationAdmin(TestCase): # Perform assertions on the mock call itself mock_client_instance.send_email.assert_called_once() - # Cleanup - application.delete() - @boto3_mocking.patching def test_save_model_sends_in_review_email(self): # make sure there is no user with this email @@ -125,9 +122,6 @@ class TestDomainApplicationAdmin(TestCase): # Perform assertions on the mock call itself mock_client_instance.send_email.assert_called_once() - # Cleanup - application.delete() - @boto3_mocking.patching def test_save_model_sends_approved_email(self): # make sure there is no user with this email @@ -174,10 +168,97 @@ class TestDomainApplicationAdmin(TestCase): # Perform assertions on the mock call itself mock_client_instance.send_email.assert_called_once() - # Cleanup - if DomainInformation.objects.get(id=application.pk) is not None: - DomainInformation.objects.get(id=application.pk).delete() - application.delete() + @boto3_mocking.patching + def test_save_model_sends_action_needed_email(self): + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + mock_client = MagicMock() + mock_client_instance = mock_client.return_value + + with boto3_mocking.clients.handler_for("sesv2", mock_client): + # Create a sample application + application = completed_application(status=DomainApplication.INVESTIGATING) + + # Create a mock request + request = self.factory.post( + "/admin/registrar/domainapplication/{}/change/".format(application.pk) + ) + + # Create an instance of the model admin + model_admin = DomainApplicationAdmin(DomainApplication, self.site) + + # Modify the application's property + application.status = DomainApplication.ACTION_NEEDED + + # Use the model admin's save_model method + model_admin.save_model(request, application, form=None, change=True) + + # Access the arguments passed to send_email + call_args = mock_client_instance.send_email.call_args + args, kwargs = call_args + + # 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 requires your attention." + self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL) + self.assertEqual(to_email, EMAIL) + self.assertIn(expected_string, email_body) + + # Perform assertions on the mock call itself + mock_client_instance.send_email.assert_called_once() + + @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() + + mock_client = MagicMock() + mock_client_instance = mock_client.return_value + + with boto3_mocking.clients.handler_for("sesv2", mock_client): + # Create a sample application + application = completed_application(status=DomainApplication.INVESTIGATING) + + # Create a mock request + request = self.factory.post( + "/admin/registrar/domainapplication/{}/change/".format(application.pk) + ) + + # Create an instance of the model admin + model_admin = DomainApplicationAdmin(DomainApplication, self.site) + + # Modify the application's property + application.status = DomainApplication.REJECTED + + # Use the model admin's save_model method + model_admin.save_model(request, application, form=None, change=True) + + # Access the arguments passed to send_email + call_args = mock_client_instance.send_email.call_args + args, kwargs = call_args + + # 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) + + # Perform assertions on the mock call itself + mock_client_instance.send_email.assert_called_once() def test_changelist_view(self): # Have to get creative to get past linter @@ -241,6 +322,7 @@ class TestDomainApplicationAdmin(TestCase): def tearDown(self): # delete any applications too + DomainInformation.objects.all().delete() DomainApplication.objects.all().delete() User.objects.all().delete() self.superuser.delete() diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 0819bb72b..4b5af5c4a 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -161,7 +161,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.submit() - + def test_transition_not_allowed_rejected_submitted(self): """Create an application with status rejected and call submit against transition rules""" @@ -197,7 +197,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.in_review() - + def test_transition_not_allowed_action_needed_investigating(self): """Create an application with status action needed and call in_review against transition rules""" @@ -206,7 +206,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.in_review() - + def test_transition_not_allowed_rejected_investigating(self): """Create an application with status rejected and call in_review against transition rules""" @@ -224,7 +224,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.in_review() - + def test_transition_not_allowed_started_action_needed(self): """Create an application with status started and call action_needed against transition rules""" @@ -233,7 +233,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.action_needed() - + def test_transition_not_allowed_submitted_action_needed(self): """Create an application with status submitted and call action_needed against transition rules""" @@ -242,7 +242,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.action_needed() - + def test_transition_not_allowed_action_needed_action_needed(self): """Create an application with status action needed and call action_needed against transition rules""" @@ -251,7 +251,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.action_needed() - + def test_transition_not_allowed_approved_action_needed(self): """Create an application with status approved and call action_needed against transition rules""" @@ -260,7 +260,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.action_needed() - + def test_transition_not_allowed_withdrawn_action_needed(self): """Create an application with status withdrawn and call action_needed against transition rules""" @@ -287,7 +287,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.approve() - + def test_transition_not_allowed_action_needed_approved(self): """Create an application with status action needed and call approve against transition rules""" @@ -323,7 +323,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.withdraw() - + def test_transition_not_allowed_action_needed_withdrawn(self): """Create an application with status action needed and call withdraw against transition rules""" @@ -332,7 +332,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.withdraw() - + def test_transition_not_allowed_rejected_withdrawn(self): """Create an application with status rejected and call withdraw against transition rules""" @@ -341,7 +341,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.withdraw() - + def test_transition_not_allowed_withdrawn_withdrawn(self): """Create an application with status withdrawn and call withdraw against transition rules""" @@ -350,7 +350,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.withdraw() - + def test_transition_not_allowed_started_rejected(self): """Create an application with status started and call reject against transition rules""" @@ -359,7 +359,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.reject() - + def test_transition_not_allowed_submitted_rejected(self): """Create an application with status submitted and call reject against transition rules""" @@ -368,7 +368,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.reject() - + def test_transition_not_allowed_action_needed_rejected(self): """Create an application with status action needed and call reject against transition rules""" @@ -377,7 +377,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.reject() - + def test_transition_not_allowed_withdrawn_rejected(self): """Create an application with status withdrawn and call reject against transition rules""" @@ -386,7 +386,7 @@ class TestDomainApplication(TestCase): with self.assertRaises(TransitionNotAllowed): application.reject() - + def test_transition_not_allowed_rejected_rejected(self): """Create an application with status rejected and call reject against transition rules"""