Merge remote-tracking branch 'origin/main' into nl/2359-portfolio-inline-domaingroups-and-suborgs

This commit is contained in:
CocoByte 2024-07-19 12:41:41 -06:00
commit 612eea1a1e
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
25 changed files with 651 additions and 263 deletions

View file

@ -0,0 +1,91 @@
# 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 npm@latest &&
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/)**.'
})

View file

@ -22,7 +22,8 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
docker compose run node npm install &&
docker compose run node npm install npm@latest &&
docker compose run node npm install &&
docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile
- name: Collect static assets

View file

@ -47,7 +47,8 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
docker compose run node npm install &&
docker compose run node npm install npm@latest &&
docker compose run node npm install &&
docker compose run node npx gulp copyAssets &&
docker compose run node npx gulp compile
- name: Collect static assets

View file

@ -0,0 +1,18 @@
name: Notify users based on issue labels
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- uses: jenschelkopf/issue-label-notification-action@1.3
with:
recipients: |
design-review=@Katherine-Osos
message: 'cc/ {recipients} — adding you to this **{label}** issue!'

View file

@ -1,4 +1,3 @@
version: "3.0"
services:
app:
build: .

View file

@ -705,6 +705,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"last_name",
"title",
"email",
"phone",
"Permissions",
"is_active",
"groups",
@ -1344,7 +1345,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
@ -1605,7 +1606,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
@ -1659,7 +1659,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
@ -1706,29 +1706,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):
"""
@ -1923,49 +1933,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."""

View file

@ -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);
}
})();

View file

@ -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();
})();

View file

@ -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};
}
}

View file

@ -1,18 +1,14 @@
@use "uswds-core" as *;
.dotgov-table {
a {
display: flex;
align-items: flex-start;
color: color('primary');
.dotgov-table a,
.usa-link--icon {
display: flex;
align-items: flex-start;
color: color('primary');
&:visited {
color: color('primary');
}
&:visited {
color: color('primary');
}
}
a {
.usa-icon {
// align icon with x height
margin-top: units(0.5);

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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" %}
<div id="action-needed-reason-email-readonly" class="readonly margin-top-0 padding-top-0 display-none">
<div class="margin-top-05 collapse--dgsimple collapsed">
{{ field.field.value|linebreaks }}
</div>
<button id="action_needed_reason_email__show_details" type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-0 margin-bottom-1 margin-left-1">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
<div>
{{ field.field }}
<input id="action-needed-email-sent" class="display-none" value="{{action_needed_email_sent}}">
</div>
{% else %}
{{ field.field }}
{% endif %}
{% endblock field_other %}
{% block after_help_text %}
{% if field.field.name == "action_needed_reason_email" %}
{% comment %}

View file

@ -42,10 +42,13 @@
</div>
<br>
<p><b class="review__step__name">Last updated:</b> {{DomainRequest.updated_at|date:"F j, Y"}}</p>
{% if DomainRequest.status != 'rejected' %}
<p>{% include "includes/domain_request.html" %}</p>
<p><a href="{% url 'domain-request-withdraw-confirmation' pk=DomainRequest.id %}" class="usa-button usa-button--outline withdraw_outline">
Withdraw request</a>
</p>
{% endif %}
</div>
<div class="grid-col desktop:grid-offset-2 maxw-tablet">

View file

@ -0,0 +1,3 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
{{ custom_email_content }}
{% endautoescape %}

View file

@ -42,13 +42,11 @@ Your domain request was rejected because we determined that {{ domain_request.or
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
government organizations.
Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>.
DEMONSTRATE ELIGIBILITY
If you can provide documentation that demonstrates your eligibility, reply to this email.
This can include links to (or copies of) your authorizing legislation, your founding
charter or bylaws, or other similar documentation. Without this, we cant approve a
.gov domain for your organization. Learn more about eligibility for .gov domains
<https://get.gov/domains/eligibility/>.{% elif domain_request.rejection_reason == 'naming_not_met' %}
If you have questions or comments, reply to this email.
{% elif domain_request.rejection_reason == 'naming_not_met' %}
Your domain request was rejected because it does not meet our naming requirements.
Domains should uniquely identify a government organization and be clear to the
general public. Learn more about naming requirements for your type of organization

View file

@ -156,7 +156,7 @@
<div class="domains__no-data display-none">
<p>You don't have any registered domains.</p>
<p class="maxw-none clearfix">
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet display-flex flex-align-start usa-link" target="_blank">
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet usa-link usa-link--icon" target="_blank">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
</svg>

View file

@ -8,7 +8,7 @@
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
{%endif %}
</svg>
<div class="display-inline padding-left-05 margin-left-3 readonly-field {% if not field.field.required %}text-base{% endif %}">
<div class="display-inline padding-left-05 margin-left-3 input-with-edit-button__readonly-field {% if not field.field.required %}text-base{% endif %}">
{% if field.name != "phone" %}
{{ field.value }}
{% else %}

View file

@ -112,8 +112,11 @@
<div class="text-right">
<a
href="{{ edit_link }}"
class="usa-link font-sans-sm"
class="usa-link usa-link--icon font-sans-sm line-height-sans-5"
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{% static 'img/sprite.svg' %}#edit"></use>
</svg>
Edit<span class="sr-only"> {{ title }}</span>
</a>
</div>

View file

@ -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

View file

@ -47,10 +47,10 @@ class CsvReportsTest(MockDb):
fake_open = mock_open()
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
]
# We don't actually want to write anything for a test case,
# we just want to verify what is being written.
@ -69,12 +69,12 @@ class CsvReportsTest(MockDb):
fake_open = mock_open()
expected_file_content = [
call("Domain name,Domain type,Agency,Organization name,City,State,Security contact email\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,\r\n"),
call("adomain2.gov,Interstate,,,,,\r\n"),
call("zdomain12.gov,Interstate,,,,,\r\n"),
call("cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("cdomain1.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\r\n"),
call("adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("ddomain3.gov,Federal,Armed Forces Retirement Home,,,,(blank)\r\n"),
call("adomain2.gov,Interstate,,,,,(blank)\r\n"),
call("zdomain12.gov,Interstate,,,,,(blank)\r\n"),
]
# We don't actually want to write anything for a test case,
# we just want to verify what is being written.
@ -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()
@ -233,29 +234,30 @@ class ExportDataTest(MockDb, MockEppLib):
expected_content = (
"Domain name,Status,First ready on,Expiration date,Domain type,Agency,Organization name,City,State,SO,"
"SO email,Security contact email,Domain managers,Invited domain managers\n"
"cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,, ,,,"
"cdomain11.gov,Ready,2024-04-02,(blank),Federal - Executive,World War I Centennial Commission,,,,(blank),,,"
"meoward@rocks.com,\n"
"defaultsecurity.gov,Ready,2023-11-01,(blank),Federal - Executive,World War I Centennial Commission,,,"
', ,,dotgov@cisa.dhs.gov,"meoward@rocks.com, info@example.com, big_lebowski@dude.co",'
',,,(blank),"meoward@rocks.com, info@example.com, big_lebowski@dude.co",'
"woofwardthethird@rocks.com\n"
"adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,, ,,,,"
"adomain10.gov,Ready,2024-04-03,(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,"
"squeaker@rocks.com\n"
"bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n"
"bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n"
"bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n"
"ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,, ,,"
"bdomain4.gov,Unknown,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"bdomain5.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"bdomain6.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"ddomain3.gov,On hold,(blank),2023-11-15,Federal,Armed Forces Retirement Home,,,,,,"
"security@mail.gov,,\n"
"sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n"
"xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n"
"zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,, ,,,,\n"
"adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,, ,,registrar@dotgov.gov,"
"sdomain8.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"xdomain7.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"zdomain9.gov,Deleted,(blank),(blank),Federal,Armed Forces Retirement Home,,,,(blank),,,,\n"
"adomain2.gov,Dns needed,(blank),(blank),Interstate,,,,,(blank),,,"
"meoward@rocks.com,squeaker@rocks.com\n"
"zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,, ,,,meoward@rocks.com,\n"
"zdomain12.gov,Ready,2024-04-02,(blank),Interstate,,,,,(blank),,,meoward@rocks.com,\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator
@ -285,17 +287,18 @@ class ExportDataTest(MockDb, MockEppLib):
# sorted alphabetially by domain name
expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,dotgov@cisa.dhs.gov\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
"adomain2.gov,Interstate,,,,,registrar@dotgov.gov\n"
"zdomain12.gov,Interstate,,,,,\n"
"adomain2.gov,Interstate,,,,,(blank)\n"
"zdomain12.gov,Interstate,,,,,(blank)\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator
@ -325,15 +328,16 @@ class ExportDataTest(MockDb, MockEppLib):
# sorted alphabetially by domain name
expected_content = (
"Domain name,Domain type,Agency,Organization name,City,State,Security contact email\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,dotgov@cisa.dhs.gov\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,\n"
"cdomain11.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"defaultsecurity.gov,Federal - Executive,World War I Centennial Commission,,,,(blank)\n"
"adomain10.gov,Federal,Armed Forces Retirement Home,,,,(blank)\n"
"ddomain3.gov,Federal,Armed Forces Retirement Home,,,,security@mail.gov\n"
)
# Normalize line endings and remove commas,
# spaces and leading/trailing whitespace
csv_content = csv_content.replace(",,", "").replace(",", "").replace(" ", "").replace("\r\n", "\n").strip()
expected_content = expected_content.replace(",,", "").replace(",", "").replace(" ", "").strip()
self.maxDiff = None
self.assertEqual(csv_content, expected_content)
@less_console_noise_decorator
@ -402,6 +406,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

View file

@ -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"""

View file

@ -18,6 +18,7 @@ from django.contrib.postgres.aggregates import StringAgg
from registrar.models.utility.generic_helper import convert_queryset_to_dict
from registrar.templatetags.custom_filters import get_region
from registrar.utility.constants import BranchChoices
from registrar.utility.enums import DefaultEmail
logger = logging.getLogger(__name__)
@ -371,6 +372,15 @@ class DomainExport(BaseExport):
if domain_federal_type and domain_org_type == DomainRequest.OrgChoicesElectionOffice.FEDERAL:
domain_type = f"{human_readable_domain_org_type} - {human_readable_domain_federal_type}"
security_contact_email = model.get("security_contact_email")
invalid_emails = {DefaultEmail.LEGACY_DEFAULT.value, DefaultEmail.PUBLIC_CONTACT_DEFAULT.value}
if (
not security_contact_email
or not isinstance(security_contact_email, str)
or security_contact_email.lower().strip() in invalid_emails
):
security_contact_email = "(blank)"
# create a dictionary of fields which can be included in output.
# "extra_fields" are precomputed fields (generated in the DB or parsed).
FIELDS = {
@ -385,7 +395,7 @@ class DomainExport(BaseExport):
"State": model.get("state_territory"),
"SO": model.get("so_name"),
"SO email": model.get("senior_official__email"),
"Security contact email": model.get("security_contact_email"),
"Security contact email": security_contact_email,
"Created at": model.get("domain__created_at"),
"Deleted": model.get("domain__deleted"),
"Domain managers": model.get("managers"),

View file

@ -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)