diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 7eb1668d8..3e524ab80 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -90,16 +90,36 @@ class DomainApplicationAdmin(AuditedAdmin): # Get the original application from the database original_obj = models.DomainApplication.objects.get(pk=obj.pk) - if ( - obj.status != original_obj.status - and obj.status == models.DomainApplication.INVESTIGATING - ): - # This is a transition annotated method in model which will throw an - # error if the condition is violated. To make this work, we need to - # call it on the original object which has the right status value, - # but pass the current object which contains the up-to-date data - # for the email. - original_obj.in_review(obj) + if obj.status != original_obj.status: + if obj.status == models.DomainApplication.STARTED: + # No conditions + pass + if obj.status == models.DomainApplication.SUBMITTED: + # This is an fsm in model which will throw an error if the + # transition condition is violated, so we call it on the + # original object which has the right status value, and pass + # the updated object which contains the up-to-date data + # for the side effects (like an email send). + original_obj.submit(updated_domain_application=obj) + if obj.status == models.DomainApplication.INVESTIGATING: + # This is an fsm in model which will throw an error if the + # transition condition is violated, so we call it on the + # original object which has the right status value, and pass + # the updated object which contains the up-to-date data + # for the side effects (like an email send). + original_obj.in_review(updated_domain_application=obj) + if obj.status == models.DomainApplication.APPROVED: + # This is an fsm in model which will throw an error if the + # transition condition is violated, so we call it on the + # original object which has the right status value, and pass + # the updated object which contains the up-to-date data + # for the side effects (like an email send). + original_obj.approve(updated_domain_application=obj) + if obj.status == models.DomainApplication.WITHDRAWN: + # This is a transition annotated method in model which will throw an + # error if the condition is violated. To make this work, we need to + # call it on the original object which has the right status value. + original_obj.withdraw() super().save_model(request, obj, form, change) diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 57be80b36..6cb1482e7 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -471,59 +471,34 @@ class DomainApplication(TimeStampedModel): except Exception: return "" - def _send_confirmation_email(self): - """Send a confirmation email that this application was submitted. + def _send_status_update_email( + self, new_status, email_template, email_template_subject + ): + """Send a atatus update email to the submitter. The email goes to the email address that the submitter gave as their contact information. If there is not submitter information, then do nothing. """ + if self.submitter is None or self.submitter.email is None: logger.warning( - "Cannot send confirmation email, no submitter email address." + f"Cannot send {new_status} email, no submitter email address." ) return try: send_templated_email( - "emails/submission_confirmation.txt", - "emails/submission_confirmation_subject.txt", + email_template, + email_template_subject, self.submitter.email, context={"application": self}, ) - logger.info( - f"Submission confirmation email sent to: {self.submitter.email}" - ) + logger.info(f"The {new_status} email sent to: {self.submitter.email}") except EmailSendingError: logger.warning("Failed to send confirmation email", exc_info=True) - def _send_in_review_email(self): - """Send an email that this application is now in review. - - The email goes to the email address that the submitter gave as their - contact information. If there is not submitter information, then do - nothing. - """ - if self.submitter is None or self.submitter.email is None: - logger.warning( - "Cannot send status change (in review) email," - "no submitter email address." - ) - return - try: - send_templated_email( - "emails/status_change_in_review.txt", - "emails/status_change_in_review_subject.txt", - self.submitter.email, - context={"application": self}, - ) - logging.info(f"In review email sent to: {self.submitter.email}") - except EmailSendingError: - logger.warning( - "Failed to send status change (in review) email", exc_info=True - ) - @transition(field="status", source=[STARTED, WITHDRAWN], target=SUBMITTED) - def submit(self): + def submit(self, updated_domain_application=None): """Submit an application that is started.""" # check our conditions here inside the `submit` method so that we @@ -542,10 +517,40 @@ class DomainApplication(TimeStampedModel): # When an application is submitted, we need to send a confirmation email # This is a side-effect of the state transition - self._send_confirmation_email() + if updated_domain_application is not None: + # A DomainApplication is being passed to this method (ie from admin) + updated_domain_application._send_status_update_email( + "submission confirmation", + "emails/submission_confirmation.txt", + "emails/submission_confirmation_subject.txt", + ) + else: + # views/application.py + self._send_status_update_email( + "submission confirmation", + "emails/submission_confirmation.txt", + "emails/submission_confirmation_subject.txt", + ) + + @transition(field="status", source=SUBMITTED, target=INVESTIGATING) + def in_review(self, updated_domain_application): + """Investigate an application that has been submitted. + + This method is called in admin.py on the original application + which has the correct status value, but is passed the changed + application which has the up-to-date data that we'll use + in the email.""" + + # When an application is moved to in review, we need to send a + # confirmation email. This is a side-effect of the state transition + updated_domain_application._send_status_update_email( + "application in review", + "emails/status_change_in_review.txt", + "emails/status_change_in_review_subject.txt", + ) @transition(field="status", source=[SUBMITTED, INVESTIGATING], target=APPROVED) - def approve(self): + def approve(self, updated_domain_application=None): """Approve an application that has been submitted. This has substantial side-effects because it creates another database @@ -570,18 +575,21 @@ class DomainApplication(TimeStampedModel): user=self.creator, domain=created_domain, role=UserDomainRole.Roles.ADMIN ) - @transition(field="status", source=SUBMITTED, target=INVESTIGATING) - def in_review(self, updated_domain_application): - """Investigate an application that has been submitted. - - This method is called in admin.py on the original application - which has the correct status value, but is passed the changed - application which has the up-to-date data that we'll use - in the email.""" - # When an application is moved to in review, we need to send a # confirmation email. This is a side-effect of the state transition - updated_domain_application._send_in_review_email() + if updated_domain_application is not None: + # A DomainApplication is being passed to this method (ie from admin) + updated_domain_application._send_status_update_email( + "application approved", + "emails/status_change_approved.txt", + "emails/status_change_approved_subject.txt", + ) + else: + self._send_status_update_email( + "application approved", + "emails/status_change_approved.txt", + "emails/status_change_approved.txt", + ) @transition(field="status", source=[SUBMITTED, INVESTIGATING], target=WITHDRAWN) def withdraw(self): diff --git a/src/registrar/templates/emails/status_change_approved.txt b/src/registrar/templates/emails/status_change_approved.txt new file mode 100644 index 000000000..80bf78842 --- /dev/null +++ b/src/registrar/templates/emails/status_change_approved.txt @@ -0,0 +1,40 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +Hi {{ application.submitter.first_name }}. + +Congratulations! Your .gov domain request has been approved. + +DOMAIN REQUESTED: {{ application.requested_domain.name }} +REQUEST RECEIVED ON: {{ application.updated_at|date }} +REQUEST #: {{ application.id }} +STATUS: In review + +Now that your .gov domain has been approved, there are a few more things to do before your domain can be used. + + +YOU MUST ADD DOMAIN NAME SERVER INFORMATION + +Before your .gov domain can be used, you have to connect it to your Domain Name System (DNS) hosting service. At this time, we don’t provide DNS hosting services. +Go to the domain management page to add your domain name server information . + +Get help with adding your domain name server information . + + +ADD DOMAIN MANAGERS, SECURITY EMAIL + +We strongly recommend that you add other points of contact who will help manage your domain. We also recommend that you provide a security email. This email will allow the public to report security issues on your domain. Security emails are made public. + +Go to the domain management page to add domain contacts and a security email . + +Get help with managing your .gov domain . + + +THANK YOU + +.Gov helps the public identify official, trusted information. Thank you for using a .gov domain. + +---------------------------------------------------------------- + +The .gov team +Contact us: +Visit +{% endautoescape %} diff --git a/src/registrar/templates/emails/status_change_approved_subject.txt b/src/registrar/templates/emails/status_change_approved_subject.txt new file mode 100644 index 000000000..32756d463 --- /dev/null +++ b/src/registrar/templates/emails/status_change_approved_subject.txt @@ -0,0 +1 @@ +Your .gov domain request is approved \ No newline at end of file diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 2f2e1190b..b34c3a08a 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1,7 +1,7 @@ from django.test import TestCase, RequestFactory from django.contrib.admin.sites import AdminSite from registrar.admin import DomainApplicationAdmin -from registrar.models import DomainApplication, User +from registrar.models import DomainApplication, DomainInformation, User from .common import completed_application from django.conf import settings @@ -15,7 +15,56 @@ class TestDomainApplicationAdmin(TestCase): self.factory = RequestFactory() @boto3_mocking.patching - def test_save_model_sends_email_on_property_change(self): + def test_save_model_sends_submitted_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() + + # 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.SUBMITTED + + # 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 = "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) + + # 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 EMAIL = "mayor@igorville.gov" User.objects.filter(email=EMAIL).delete() @@ -52,7 +101,7 @@ class TestDomainApplicationAdmin(TestCase): email_body = email_content["Simple"]["Body"]["Text"]["Data"] # Assert or perform other checks on the email details - expected_string = "Your .gov domain request is being reviewed" + expected_string = "Your .gov domain request is being reviewed." self.assertEqual(from_email, settings.DEFAULT_FROM_EMAIL) self.assertEqual(to_email, EMAIL) self.assertIn(expected_string, email_body) @@ -62,3 +111,53 @@ class TestDomainApplicationAdmin(TestCase): # Cleanup application.delete() + + @boto3_mocking.patching + def test_save_model_sends_approved_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.APPROVED + + # 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 = "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) + + # Perform assertions on the mock call itself + mock_client_instance.send_email.assert_called_once() + + # Cleanup + DomainInformation.objects.get(id=application.pk).delete() + application.delete()