diff --git a/.github/workflows/deploy-branch-to-sandbox.yaml b/.github/workflows/deploy-branch-to-sandbox.yaml new file mode 100644 index 000000000..14a3d8ef8 --- /dev/null +++ b/.github/workflows/deploy-branch-to-sandbox.yaml @@ -0,0 +1,90 @@ +# Manually deploy a branch of choice to an environment of choice. + +name: Manual Build and Deploy +run-name: Manually build and deploy branch to sandbox of choice + +on: + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy' + required: true + default: 'backup' + type: 'choice' + options: + - ab + - backup + - cb + - dk + - es + - gd + - ko + - ky + - nl + - rb + - rh + - rjm + - meoward + - bob + - hotgov + - litterbox + # GitHub Actions has no "good" way yet to dynamically input branches + branch: + description: 'Branch to deploy' + required: true + default: 'main' + type: string + + +jobs: + variables: + runs-on: ubuntu-latest + steps: + - name: Setting global variables + uses: actions/github-script@v6 + id: var + with: + script: | + core.setOutput('environment', '${{ github.head_ref }}'.split("/")[0]); + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Compile USWDS assets + working-directory: ./src + run: | + docker compose run node npm install && + docker compose run node npx gulp copyAssets && + docker compose run node npx gulp compile + - name: Collect static assets + working-directory: ./src + run: docker compose run app python manage.py collectstatic --no-input + - name: Deploy to cloud.gov sandbox + uses: cloud-gov/cg-cli-tools@main + env: + ENVIRONMENT: ${{ github.event.inputs.environment }} + CF_USERNAME: CF_${{ github.event.inputs.environment }}_USERNAME + CF_PASSWORD: CF_${{ github.event.inputs.environment }}_PASSWORD + with: + cf_username: ${{ secrets[env.CF_USERNAME] }} + cf_password: ${{ secrets[env.CF_PASSWORD] }} + cf_org: cisa-dotgov + cf_space: ${{ env.ENVIRONMENT }} + cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml + comment: + runs-on: ubuntu-latest + needs: [deploy] + steps: + - uses: actions/github-script@v6 + env: + ENVIRONMENT: ${{ github.event.inputs.environment }} + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.' + }) + + diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 049eb38b4..9b842abf8 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1342,7 +1342,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): for name, data in fieldsets: fields = data.get("fields", []) fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields) - modified_fieldsets.append((name, {"fields": fields})) + modified_fieldsets.append((name, {**data, "fields": fields})) return modified_fieldsets return fieldsets @@ -1603,7 +1603,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "is_election_board", "federal_agency", "status_history", - "action_needed_reason_email", ) # Read only that we'll leverage for CISA Analysts @@ -1657,7 +1656,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): for name, data in fieldsets: fields = data.get("fields", []) fields = tuple(field for field in fields if field not in self.superuser_only_fields) - modified_fieldsets.append((name, {"fields": fields})) + modified_fieldsets.append((name, {**data, "fields": fields})) return modified_fieldsets return fieldsets @@ -1704,29 +1703,39 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if not change: return super().save_model(request, obj, form, change) - # == Handle non-status changes == # - # Change this in #1901. Add a check on "not self.action_needed_reason_email" - if obj.action_needed_reason: - self._handle_action_needed_reason_email(obj) - should_save = True - # Get the original domain request from the database. original_obj = models.DomainRequest.objects.get(pk=obj.pk) + + # == Handle action_needed_reason == # + + reason_changed = obj.action_needed_reason != original_obj.action_needed_reason + if reason_changed: + # Track the fact that we sent out an email + request.session["action_needed_email_sent"] = True + + # Set the action_needed_reason_email to the default if nothing exists. + # Since this check occurs after save, if the user enters a value then we won't update. + + default_email = self._get_action_needed_reason_default_email(obj, obj.action_needed_reason) + if obj.action_needed_reason_email: + emails = self.get_all_action_needed_reason_emails(obj) + is_custom_email = obj.action_needed_reason_email not in emails.values() + if not is_custom_email: + obj.action_needed_reason_email = default_email + else: + obj.action_needed_reason_email = default_email + + # == Handle status == # if obj.status == original_obj.status: # If the status hasn't changed, let the base function take care of it return super().save_model(request, obj, form, change) + else: + # Run some checks on the current object for invalid status changes + obj, should_save = self._handle_status_change(request, obj, original_obj) - # == Handle status changes == # - # Run some checks on the current object for invalid status changes - obj, should_save = self._handle_status_change(request, obj, original_obj) - - # We should only save if we don't display any errors in the steps above. - if should_save: - return super().save_model(request, obj, form, change) - - def _handle_action_needed_reason_email(self, obj): - text = self._get_action_needed_reason_default_email_text(obj, obj.action_needed_reason) - obj.action_needed_reason_email = text.get("email_body_text") + # We should only save if we don't display any errors in the steps above. + if should_save: + return super().save_model(request, obj, form, change) def _handle_status_change(self, request, obj, original_obj): """ @@ -1921,49 +1930,54 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Initialize extra_context and add filtered entries extra_context = extra_context or {} extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries - extra_context["action_needed_reason_emails"] = self.get_all_action_needed_reason_emails_as_json(obj) + emails = self.get_all_action_needed_reason_emails(obj) + extra_context["action_needed_reason_emails"] = json.dumps(emails) extra_context["has_profile_feature_flag"] = flag_is_active(request, "profile_feature") + # Denote if an action needed email was sent or not + email_sent = request.session.get("action_needed_email_sent", False) + extra_context["action_needed_email_sent"] = email_sent + if email_sent: + request.session["action_needed_email_sent"] = False + # Call the superclass method with updated extra_context return super().change_view(request, object_id, form_url, extra_context) - def get_all_action_needed_reason_emails_as_json(self, domain_request): + def get_all_action_needed_reason_emails(self, domain_request): """Returns a json dictionary of every action needed reason and its associated email for this particular domain request.""" + emails = {} for action_needed_reason in domain_request.ActionNeededReasons: - enum_value = action_needed_reason.value - # Change this in #1901. Just add a check for the current value. - emails[enum_value] = self._get_action_needed_reason_default_email_text(domain_request, enum_value) - return json.dumps(emails) + # Map the action_needed_reason to its default email + emails[action_needed_reason.value] = self._get_action_needed_reason_default_email( + domain_request, action_needed_reason.value + ) - def _get_action_needed_reason_default_email_text(self, domain_request, action_needed_reason: str): + return emails + + def _get_action_needed_reason_default_email(self, domain_request, action_needed_reason): """Returns the default email associated with the given action needed reason""" - if action_needed_reason is None or action_needed_reason == domain_request.ActionNeededReasons.OTHER: - return { - "subject_text": None, - "email_body_text": None, - } - - # Get the email body - template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt" - template = get_template(template_path) - - # Get the email subject - template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt" - subject_template = get_template(template_subject_path) + if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER: + return None if flag_is_active(None, "profile_feature"): # type: ignore recipient = domain_request.creator else: recipient = domain_request.submitter - # Return the content of the rendered views + # Return the context of the rendered views context = {"domain_request": domain_request, "recipient": recipient} - return { - "subject_text": subject_template.render(context=context), - "email_body_text": template.render(context=context), - } + + # Get the email body + template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt" + + email_body_text = get_template(template_path).render(context=context) + email_body_text_cleaned = None + if email_body_text: + email_body_text_cleaned = email_body_text.strip().lstrip("\n") + + return email_body_text_cleaned def process_log_entry(self, log_entry): """Process a log entry and return filtered entry dictionary if applicable.""" diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index ad759f445..0f7219913 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -36,6 +36,15 @@ function openInNewTab(el, removeAttribute = false){ } }; +// Adds or removes a boolean from our session +function addOrRemoveSessionBoolean(name, add){ + if (add) { + sessionStorage.setItem(name, "true"); + }else { + sessionStorage.removeItem(name); + } +} + // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Event handlers. @@ -418,15 +427,6 @@ function initializeWidgetOnList(list, parentId) { object.classList.add("display-none"); } } - - // Adds or removes a boolean from our session - function addOrRemoveSessionBoolean(name, add){ - if (add) { - sessionStorage.setItem(name, "true"); - }else { - sessionStorage.removeItem(name); - } - } })(); /** An IIFE for toggling the submit bar on domain request forms @@ -526,54 +526,80 @@ function initializeWidgetOnList(list, parentId) { * This shows the auto generated email on action needed reason. */ (function () { - let actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason"); - let actionNeededEmail = document.querySelector("#action_needed_reason_email_view_more"); - if(actionNeededReasonDropdown && actionNeededEmail) { - // Add a change listener to the action needed reason dropdown - handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail); - } + // Since this is an iife, these vars will be removed from memory afterwards + var actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason"); + var actionNeededEmail = document.querySelector("#id_action_needed_reason_email"); + var readonlyView = document.querySelector("#action-needed-reason-email-readonly"); - function handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail) { - actionNeededReasonDropdown.addEventListener("change", function() { + let emailWasSent = document.getElementById("action-needed-email-sent"); + let actionNeededEmailData = document.getElementById('action-needed-emails-data').textContent; + let actionNeededEmailsJson = JSON.parse(actionNeededEmailData); + + const domainRequestId = actionNeededReasonDropdown ? document.querySelector("#domain_request_id").value : null + const emailSentSessionVariableName = `actionNeededEmailSent-${domainRequestId}`; + const oldDropdownValue = actionNeededReasonDropdown ? actionNeededReasonDropdown.value : null; + const oldEmailValue = actionNeededEmailData ? actionNeededEmailData.value : null; + + if(actionNeededReasonDropdown && actionNeededEmail && domainRequestId) { + // Add a change listener to dom load + document.addEventListener('DOMContentLoaded', function() { let reason = actionNeededReasonDropdown.value; - // If a reason isn't specified, no email will be sent. - // You also cannot save the model in this state. - // This flow occurs if you switch back to the empty picker state. - if(!reason) { - showNoEmailMessage(actionNeededEmail); - return; - } - - let actionNeededEmails = JSON.parse(document.getElementById('action-needed-emails-data').textContent) - let emailData = actionNeededEmails[reason]; - if (emailData) { - let emailBody = emailData.email_body_text - if (emailBody) { - actionNeededEmail.value = emailBody - showActionNeededEmail(actionNeededEmail); - }else { - showNoEmailMessage(actionNeededEmail); - } - }else { - showNoEmailMessage(actionNeededEmail); + // Handle the session boolean (to enable/disable editing) + if (emailWasSent && emailWasSent.value === "True") { + // An email was sent out - store that information in a session variable + addOrRemoveSessionBoolean(emailSentSessionVariableName, add=true); } + // Show an editable email field or a readonly one + updateActionNeededEmailDisplay(reason) + }); + + // Add a change listener to the action needed reason dropdown + actionNeededReasonDropdown.addEventListener("change", function() { + let reason = actionNeededReasonDropdown.value; + let emailBody = reason in actionNeededEmailsJson ? actionNeededEmailsJson[reason] : null; + if (reason && emailBody) { + // Replace the email content + actionNeededEmail.value = emailBody; + + // Reset the session object on change since change refreshes the email content. + if (oldDropdownValue !== actionNeededReasonDropdown.value || oldEmailValue !== actionNeededEmail.value) { + let emailSent = sessionStorage.getItem(emailSentSessionVariableName) + if (emailSent !== null){ + addOrRemoveSessionBoolean(emailSentSessionVariableName, add=false) + } + } + } + + // Show an editable email field or a readonly one + updateActionNeededEmailDisplay(reason) }); } - // Show the text field. Hide the "no email" message. - function showActionNeededEmail(actionNeededEmail){ - let noEmailMessage = document.getElementById("no-email-message"); - showElement(actionNeededEmail); - hideElement(noEmailMessage); + // Shows an editable email field or a readonly one. + // If the email doesn't exist or if we're of reason "other", display that no email was sent. + // Likewise, if we've sent this email before, we should just display the content. + function updateActionNeededEmailDisplay(reason) { + let emailHasBeenSentBefore = sessionStorage.getItem(emailSentSessionVariableName) !== null; + let collapseableDiv = readonlyView.querySelector(".collapse--dgsimple"); + let showMoreButton = document.querySelector("#action_needed_reason_email__show_details"); + if ((reason && reason != "other") && !emailHasBeenSentBefore) { + showElement(actionNeededEmail.parentElement) + hideElement(readonlyView) + hideElement(showMoreButton) + } else { + if (!reason || reason === "other") { + collapseableDiv.innerHTML = reason ? "No email will be sent." : "-"; + hideElement(showMoreButton) + if (collapseableDiv.classList.contains("collapsed")) { + showMoreButton.click() + } + }else { + showElement(showMoreButton) + } + hideElement(actionNeededEmail.parentElement) + showElement(readonlyView) + } } - - // Hide the text field. Show the "no email" message. - function showNoEmailMessage(actionNeededEmail) { - let noEmailMessage = document.getElementById("no-email-message"); - hideElement(actionNeededEmail); - showElement(noEmailMessage); - } - })(); diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 92f5869f7..a7252e16b 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -1,7 +1,6 @@ from __future__ import annotations from typing import Union import logging - from django.apps import apps from django.conf import settings from django.db import models @@ -619,9 +618,10 @@ class DomainRequest(TimeStampedModel): super().save(*args, **kwargs) - # Handle the action needed email. We send one when moving to action_needed, - # but we don't send one when we are _already_ in the state and change the reason. - self.sync_action_needed_reason() + # Handle the action needed email. + # An email is sent out when action_needed_reason is changed or added. + if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED: + self.sync_action_needed_reason() # Update the cached values after saving self._cache_status_and_action_needed_reason() @@ -631,10 +631,10 @@ class DomainRequest(TimeStampedModel): was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None reason_changed = self._cached_action_needed_reason != self.action_needed_reason - if was_already_action_needed and (reason_exists and reason_changed): + if was_already_action_needed and reason_exists and reason_changed: # We don't send emails out in state "other" if self.action_needed_reason != self.ActionNeededReasons.OTHER: - self._send_action_needed_reason_email() + self._send_action_needed_reason_email(email_content=self.action_needed_reason_email) def sync_yes_no_form_fields(self): """Some yes/no forms use a db field to track whether it was checked or not. @@ -688,7 +688,15 @@ class DomainRequest(TimeStampedModel): 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, bcc_address="", wrap_email=False + self, + new_status, + email_template, + email_template_subject, + bcc_address="", + context=None, + send_email=True, + wrap_email=False, + custom_email_content=None, ): """Send a status update email to the creator. @@ -699,11 +707,18 @@ class DomainRequest(TimeStampedModel): If the waffle flag "profile_feature" is active, then this email will be sent to the domain request creator rather than the submitter + Optional args: + bcc_address: str -> the address to bcc to + + context: dict -> The context sent to the template + send_email: bool -> Used to bypass the send_templated_email function, in the event we just want to log that an email would have been sent, rather than actually sending one. wrap_email: bool -> Wraps emails using `wrap_text_and_preserve_paragraphs` if any given paragraph exceeds our desired max length (for prettier display). + + custom_email_content: str -> Renders an email with the content of this string as its body text. """ recipient = self.creator if flag_is_active(None, "profile_feature") else self.submitter @@ -721,15 +736,21 @@ class DomainRequest(TimeStampedModel): return None try: + if not context: + context = { + "domain_request": self, + # This is the user that we refer to in the email + "recipient": recipient, + } + + if custom_email_content: + context["custom_email_content"] = custom_email_content + send_templated_email( email_template, email_template_subject, recipient.email, - context={ - "domain_request": self, - # This is the user that we refer to in the email - "recipient": recipient, - }, + context=context, bcc_address=bcc_address, wrap_email=wrap_email, ) @@ -787,8 +808,8 @@ class DomainRequest(TimeStampedModel): "submission confirmation", "emails/submission_confirmation.txt", "emails/submission_confirmation_subject.txt", - True, - bcc_address, + send_email=True, + bcc_address=bcc_address, ) @transition( @@ -858,43 +879,28 @@ class DomainRequest(TimeStampedModel): # Send out an email if an action needed reason exists if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER: - self._send_action_needed_reason_email(send_email) + email_content = self.action_needed_reason_email + self._send_action_needed_reason_email(send_email, email_content) - def _send_action_needed_reason_email(self, send_email=True): + def _send_action_needed_reason_email(self, send_email=True, email_content=None): """Sends out an automatic email for each valid action needed reason provided""" - # Store the filenames of the template and template subject - email_template_name: str = "" - email_template_subject_name: str = "" - - # Check for the "type" of action needed reason. - can_send_email = True - match self.action_needed_reason: - # Add to this match if you need to pass in a custom filename for these templates. - case self.ActionNeededReasons.OTHER, _: - # Unknown and other are default cases - do nothing - can_send_email = False - - # Assumes that the template name matches the action needed reason if nothing is specified. - # This is so you can override if you need, or have this taken care of for you. - if not email_template_name and not email_template_subject_name: - email_template_name = f"{self.action_needed_reason}.txt" - email_template_subject_name = f"{self.action_needed_reason}_subject.txt" + email_template_name = "custom_email.txt" + email_template_subject_name = f"{self.action_needed_reason}_subject.txt" bcc_address = "" if settings.IS_PRODUCTION: bcc_address = settings.DEFAULT_FROM_EMAIL - # If we can, try to send out an email as long as send_email=True - if can_send_email: - self._send_status_update_email( - new_status="action needed", - email_template=f"emails/action_needed_reasons/{email_template_name}", - email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}", - send_email=send_email, - bcc_address=bcc_address, - wrap_email=True, - ) + self._send_status_update_email( + new_status="action needed", + email_template=f"emails/action_needed_reasons/{email_template_name}", + email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}", + send_email=send_email, + bcc_address=bcc_address, + custom_email_content=email_content, + wrap_email=True, + ) @transition( field="status", @@ -952,7 +958,7 @@ class DomainRequest(TimeStampedModel): "domain request approved", "emails/status_change_approved.txt", "emails/status_change_approved_subject.txt", - send_email, + send_email=send_email, ) @transition( diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 8b8748f80..51dd9b518 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -143,6 +143,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endwith %} {% endblock field_readonly %} +{% block field_other %} + {% if field.field.name == "action_needed_reason_email" %} + +
+ {{ field.field }} + +
+ {% else %} + {{ field.field }} + {% endif %} +{% endblock field_other %} + {% block after_help_text %} {% if field.field.name == "action_needed_reason_email" %} {% comment %} diff --git a/src/registrar/templates/emails/action_needed_reasons/custom_email.txt b/src/registrar/templates/emails/action_needed_reasons/custom_email.txt new file mode 100644 index 000000000..58e539816 --- /dev/null +++ b/src/registrar/templates/emails/action_needed_reasons/custom_email.txt @@ -0,0 +1,3 @@ +{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #} +{{ custom_email_content }} +{% endautoescape %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index b77a1faf4..ac49b1bee 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -1399,13 +1399,18 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, "status in [submitted,in review,action needed]", count=1) @less_console_noise_decorator - def transition_state_and_send_email(self, domain_request, status, rejection_reason=None, action_needed_reason=None): + def transition_state_and_send_email( + self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None + ): """Helper method for the email test cases.""" with boto3_mocking.clients.handler_for("sesv2", self.mock_client): # Create a mock request request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) + # Create a fake session to hook to + request.session = {} + # Modify the domain request's properties domain_request.status = status @@ -1415,6 +1420,9 @@ class TestDomainRequestAdmin(MockEppLib): if action_needed_reason: domain_request.action_needed_reason = action_needed_reason + if action_needed_reason_email: + domain_request.action_needed_reason_email = action_needed_reason_email + # Use the model admin's save_model method self.admin.save_model(request, domain_request, form=None, change=True) @@ -1468,6 +1476,7 @@ class TestDomainRequestAdmin(MockEppLib): # Test the email sent out for already_has_domains already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains) + self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL) self.assertEqual(len(self.mock_client.EMAILS_SENT), 1) @@ -1487,7 +1496,7 @@ class TestDomainRequestAdmin(MockEppLib): ) self.assertEqual(len(self.mock_client.EMAILS_SENT), 3) - # Test the email sent out for questionable_so + # Test that a custom email is sent out for questionable_so questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so) self.assert_email_is_accurate( @@ -1502,6 +1511,43 @@ class TestDomainRequestAdmin(MockEppLib): # Should be unchanged from before self.assertEqual(len(self.mock_client.EMAILS_SENT), 4) + # Tests if an analyst can override existing email content + questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL + self.transition_state_and_send_email( + domain_request, + action_needed, + action_needed_reason=questionable_so, + action_needed_reason_email="custom email content", + ) + + domain_request.refresh_from_db() + self.assert_email_is_accurate("custom email content", 4, EMAIL, bcc_email_address=BCC_EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) + + # Tests if a new email gets sent when just the email is changed. + # An email should NOT be sent out if we just modify the email content. + self.transition_state_and_send_email( + domain_request, + action_needed, + action_needed_reason=questionable_so, + action_needed_reason_email="dummy email content", + ) + + self.assertEqual(len(self.mock_client.EMAILS_SENT), 5) + + # Set the request back to in review + domain_request.in_review() + + # Try sending another email when changing states AND including content + self.transition_state_and_send_email( + domain_request, + action_needed, + action_needed_reason=eligibility_unclear, + action_needed_reason_email="custom content when starting anew", + ) + self.assert_email_is_accurate("custom content when starting anew", 5, EMAIL, bcc_email_address=BCC_EMAIL) + self.assertEqual(len(self.mock_client.EMAILS_SENT), 6) + def test_save_model_sends_submitted_email(self): """When transitioning to submitted from started or withdrawn on a domain request, an email is sent out. @@ -2242,72 +2288,71 @@ class TestDomainRequestAdmin(MockEppLib): self.assertContains(response, "When a domain request is in ineligible status") self.assertContains(response, "Yes, select ineligible status") + @less_console_noise_decorator def test_readonly_when_restricted_creator(self): - with less_console_noise(): - domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) - with boto3_mocking.clients.handler_for("sesv2", self.mock_client): - domain_request.creator.status = User.RESTRICTED - domain_request.creator.save() + domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW) + with boto3_mocking.clients.handler_for("sesv2", self.mock_client): + domain_request.creator.status = User.RESTRICTED + domain_request.creator.save() - request = self.factory.get("/") - request.user = self.superuser + request = self.factory.get("/") + request.user = self.superuser - readonly_fields = self.admin.get_readonly_fields(request, domain_request) + readonly_fields = self.admin.get_readonly_fields(request, domain_request) - expected_fields = [ - "other_contacts", - "current_websites", - "alternative_domains", - "is_election_board", - "federal_agency", - "status_history", - "action_needed_reason_email", - "id", - "created_at", - "updated_at", - "status", - "rejection_reason", - "action_needed_reason", - "action_needed_reason_email", - "federal_agency", - "portfolio", - "sub_organization", - "creator", - "investigator", - "generic_org_type", - "is_election_board", - "organization_type", - "federally_recognized_tribe", - "state_recognized_tribe", - "tribe_name", - "federal_type", - "organization_name", - "address_line1", - "address_line2", - "city", - "state_territory", - "zipcode", - "urbanization", - "about_your_organization", - "senior_official", - "approved_domain", - "requested_domain", - "submitter", - "purpose", - "no_other_contacts_rationale", - "anything_else", - "has_anything_else_text", - "cisa_representative_email", - "cisa_representative_first_name", - "cisa_representative_last_name", - "has_cisa_representative", - "is_policy_acknowledged", - "submission_date", - "notes", - "alternative_domains", - ] + expected_fields = [ + "other_contacts", + "current_websites", + "alternative_domains", + "is_election_board", + "federal_agency", + "status_history", + "id", + "created_at", + "updated_at", + "status", + "rejection_reason", + "action_needed_reason", + "action_needed_reason_email", + "federal_agency", + "portfolio", + "sub_organization", + "creator", + "investigator", + "generic_org_type", + "is_election_board", + "organization_type", + "federally_recognized_tribe", + "state_recognized_tribe", + "tribe_name", + "federal_type", + "organization_name", + "address_line1", + "address_line2", + "city", + "state_territory", + "zipcode", + "urbanization", + "about_your_organization", + "senior_official", + "approved_domain", + "requested_domain", + "submitter", + "purpose", + "no_other_contacts_rationale", + "anything_else", + "has_anything_else_text", + "cisa_representative_email", + "cisa_representative_first_name", + "cisa_representative_last_name", + "has_cisa_representative", + "is_policy_acknowledged", + "submission_date", + "notes", + "alternative_domains", + ] - self.assertEqual(readonly_fields, expected_fields) + self.assertEqual(readonly_fields, expected_fields) def test_readonly_fields_for_analyst(self): with less_console_noise(): @@ -2323,7 +2368,6 @@ class TestDomainRequestAdmin(MockEppLib): "is_election_board", "federal_agency", "status_history", - "action_needed_reason_email", "creator", "about_your_organization", "requested_domain", @@ -2355,7 +2399,6 @@ class TestDomainRequestAdmin(MockEppLib): "is_election_board", "federal_agency", "status_history", - "action_needed_reason_email", ] self.assertEqual(readonly_fields, expected_fields) @@ -2425,6 +2468,8 @@ class TestDomainRequestAdmin(MockEppLib): request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk)) request.user = self.superuser + request.session = {} + # Define a custom implementation for is_active def custom_is_active(self): return domain_is_active # Override to return True diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index 6fdaa3e3d..e274893a2 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -46,6 +46,10 @@ def send_templated_email( template = get_template(template_name) email_body = template.render(context=context) + # Do cleanup on the email body. For emails with custom content. + if email_body: + email_body.strip().lstrip("\n") + subject_template = get_template(subject_template_name) subject = subject_template.render(context=context)