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/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 7052d786f..83e575dc5 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1826,6 +1826,9 @@ document.addEventListener('DOMContentLoaded', function() { } function setupListener(){ + + + document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { // Get the "{field_name}" and "edit-button" let fieldIdParts = button.id.split("__") @@ -1834,12 +1837,61 @@ document.addEventListener('DOMContentLoaded', function() { // When the edit button is clicked, show the input field under it handleEditButtonClick(fieldName, button); + + let editableFormGroup = button.parentElement.parentElement.parentElement; + if (editableFormGroup){ + let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field") + let inputField = document.getElementById(`id_${fieldName}`); + if (!inputField || !readonlyField) { + return; + } + + let inputFieldValue = inputField.value + if (inputFieldValue || fieldName == "full_name"){ + if (fieldName == "full_name"){ + let firstName = document.querySelector("#id_first_name"); + let middleName = document.querySelector("#id_middle_name"); + let lastName = document.querySelector("#id_last_name"); + if (firstName && lastName && firstName.value && lastName.value) { + let values = [firstName.value, middleName.value, lastName.value] + readonlyField.innerHTML = values.join(" "); + }else { + let fullNameField = document.querySelector('#full_name__edit-button-readonly'); + let svg = fullNameField.querySelector("svg use") + if (svg) { + const currentHref = svg.getAttribute('xlink:href'); + if (currentHref) { + const parts = currentHref.split('#'); + if (parts.length === 2) { + // Keep the path before '#' and replace the part after '#' with 'invalid' + const newHref = parts[0] + '#error'; + svg.setAttribute('xlink:href', newHref); + fullNameField.classList.add("input-with-edit-button__error") + label = fullNameField.querySelector(".input-with-edit-button__readonly-field") + label.innerHTML = "Unknown"; + } + } + } + } + + // Technically, the full_name field is optional, but we want to display it as required. + // This style is applied to readonly fields (gray text). This just removes it, as + // this is difficult to achieve otherwise by modifying the .readonly property. + if (readonlyField.classList.contains("text-base")) { + readonlyField.classList.remove("text-base") + } + }else { + readonlyField.innerHTML = inputFieldValue + } + } + } } }); } function showInputOnErrorFields(){ document.addEventListener('DOMContentLoaded', function() { + // Get all input elements within the form let form = document.querySelector("#finish-profile-setup-form"); let inputs = form ? form.querySelectorAll("input") : null; @@ -1878,9 +1930,9 @@ document.addEventListener('DOMContentLoaded', function() { }); }; - // Hookup all edit buttons to the `handleEditButtonClick` function setupListener(); // Show the input fields if an error exists showInputOnErrorFields(); + })(); diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index cc060ee01..b56af71af 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -190,7 +190,7 @@ abbr[title] { svg.usa-icon { color: #{$dhs-red}; } - div.readonly-field { + div.input-with-edit-button__readonly-field { color: #{$dhs-red}; } } diff --git a/src/registrar/forms/user_profile.py b/src/registrar/forms/user_profile.py index bfdcd0da8..2a6ed4a47 100644 --- a/src/registrar/forms/user_profile.py +++ b/src/registrar/forms/user_profile.py @@ -71,7 +71,7 @@ class UserProfileForm(forms.ModelForm): class FinishSetupProfileForm(UserProfileForm): """Form for updating user profile.""" - full_name = forms.CharField(required=True, label="Full name") + full_name = forms.CharField(required=False, label="Full name") def clean(self): cleaned_data = super().clean() @@ -93,4 +93,7 @@ class FinishSetupProfileForm(UserProfileForm): self.fields["title"].label = "Title or role in your organization" # Define the "full_name" value - self.fields["full_name"].initial = self.instance.get_formatted_name() + full_name = None + if self.instance.first_name and self.instance.last_name: + full_name = self.instance.get_formatted_name() + self.fields["full_name"].initial = full_name 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/models/user.py b/src/registrar/models/user.py index 87b7799d3..707714939 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -115,7 +115,8 @@ class User(AbstractUser): self.title, self.phone, ] - return None not in user_values + + return None not in user_values and "" not in user_values def __str__(self): # this info is pulled from Login.gov 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/templates/includes/readonly_input.html b/src/registrar/templates/includes/readonly_input.html index ebd5d788e..47db97f00 100644 --- a/src/registrar/templates/includes/readonly_input.html +++ b/src/registrar/templates/includes/readonly_input.html @@ -8,7 +8,7 @@ {%endif %} -
+
{% if field.name != "phone" %} {{ field.value }} {% else %} 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/tests/test_reports.py b/src/registrar/tests/test_reports.py index ded04e31b..37eeb7e3c 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -208,6 +208,7 @@ class ExportDataTest(MockDb, MockEppLib): @less_console_noise_decorator def test_domain_data_type(self): """Shows security contacts, domain managers, so""" + self.maxDiff = None # Add security email information self.domain_1.name = "defaultsecurity.gov" self.domain_1.save() @@ -402,6 +403,7 @@ class ExportDataTest(MockDb, MockEppLib): squeaker@rocks.com is invited to domain2 (DNS_NEEDED) and domain10 (No managers). She should show twice in this report but not in test_DomainManaged.""" + self.maxDiff = None # Create a CSV file in memory csv_file = StringIO() # Call the export functions diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 7ff054ef6..e5de9d49d 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -538,6 +538,49 @@ class FinishUserProfileTests(TestWithUser, WebTest): self._set_session_cookie() return page.follow() if follow else page + @less_console_noise_decorator + @override_flag("profile_feature", active=True) + def test_full_name_initial_value(self): + """Test that full_name initial value is empty when first_name or last_name is empty. + This will later be displayed as "unknown" using javascript.""" + self.app.set_user(self.incomplete_regular_user.username) + + # Test when first_name is empty + self.incomplete_regular_user.first_name = "" + self.incomplete_regular_user.last_name = "Doe" + self.incomplete_regular_user.save() + + finish_setup_page = self.app.get(reverse("home")).follow() + form = finish_setup_page.form + self.assertEqual(form["full_name"].value, "") + + # Test when last_name is empty + self.incomplete_regular_user.first_name = "John" + self.incomplete_regular_user.last_name = "" + self.incomplete_regular_user.save() + + finish_setup_page = self.app.get(reverse("home")).follow() + form = finish_setup_page.form + self.assertEqual(form["full_name"].value, "") + + # Test when both first_name and last_name are empty + self.incomplete_regular_user.first_name = "" + self.incomplete_regular_user.last_name = "" + self.incomplete_regular_user.save() + + finish_setup_page = self.app.get(reverse("home")).follow() + form = finish_setup_page.form + self.assertEqual(form["full_name"].value, "") + + # Test when both first_name and last_name are present + self.incomplete_regular_user.first_name = "John" + self.incomplete_regular_user.last_name = "Doe" + self.incomplete_regular_user.save() + + finish_setup_page = self.app.get(reverse("home")).follow() + form = finish_setup_page.form + self.assertEqual(form["full_name"].value, "John Doe") + @less_console_noise_decorator def test_new_user_with_profile_feature_on(self): """Tests that a new user is redirected to the profile setup page when profile_feature is on""" @@ -576,6 +619,49 @@ class FinishUserProfileTests(TestWithUser, WebTest): completed_setup_page = self.app.get(reverse("home")) self.assertContains(completed_setup_page, "Manage your domain") + @less_console_noise_decorator + def test_new_user_with_empty_name_can_add_name(self): + """Tests that a new user without a name can still enter this information accordingly""" + self.incomplete_regular_user.first_name = "" + self.incomplete_regular_user.last_name = "" + self.incomplete_regular_user.save() + self.app.set_user(self.incomplete_regular_user.username) + with override_flag("profile_feature", active=True): + # This will redirect the user to the setup page. + # Follow implicity checks if our redirect is working. + finish_setup_page = self.app.get(reverse("home")).follow() + self._set_session_cookie() + + # Assert that we're on the right page + self.assertContains(finish_setup_page, "Finish setting up your profile") + + finish_setup_page = self._submit_form_webtest(finish_setup_page.form) + + self.assertEqual(finish_setup_page.status_code, 200) + + # We're missing a phone number, so the page should tell us that + self.assertContains(finish_setup_page, "Enter your phone number.") + + # Check for the name of the save button + self.assertContains(finish_setup_page, "user_setup_save_button") + + # Add a phone number + finish_setup_form = finish_setup_page.form + finish_setup_form["first_name"] = "test" + finish_setup_form["last_name"] = "test2" + finish_setup_form["phone"] = "(201) 555-0123" + finish_setup_form["title"] = "CEO" + finish_setup_form["last_name"] = "example" + save_page = self._submit_form_webtest(finish_setup_form, follow=True) + + self.assertEqual(save_page.status_code, 200) + self.assertContains(save_page, "Your profile has been updated.") + + # Try to navigate back to the home page. + # This is the same as clicking the back button. + completed_setup_page = self.app.get(reverse("home")) + self.assertContains(completed_setup_page, "Manage your domain") + @less_console_noise_decorator def test_new_user_goes_to_domain_request_with_profile_feature_on(self): """Tests that a new user is redirected to the domain request page when profile_feature is on""" 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)