Merge branch 'main' of https://github.com/cisagov/manage.get.gov into es/2162-delete-submitter-v2

This commit is contained in:
Erin Song 2024-09-05 14:46:22 -07:00
commit 07d8017e99
No known key found for this signature in database
49 changed files with 989 additions and 158 deletions

View file

@ -97,6 +97,7 @@ While on production (the sandbox referred to as `stable`), an existing analyst o
"username": "<UUID here>",
"first_name": "",
"last_name": "",
"email": "",
},
...
]
@ -121,6 +122,7 @@ Analysts are a variant of the admin role with limited permissions. The process f
"username": "<UUID here>",
"first_name": "",
"last_name": "",
"email": "",
},
...
]
@ -131,6 +133,20 @@ Analysts are a variant of the admin role with limited permissions. The process f
Do note that if you wish to have both an analyst and admin account, append `-Analyst` to your first and last name, or use a completely different first/last name to avoid confusion. Example: `Bob-Analyst`
## Adding an email address to the email whitelist (sandboxes only)
On all non-production environments, we use an email whitelist table (called `Allowed emails`). This whitelist is not case sensitive, and it provides an inclusion for +1 emails (like example.person+1@igorville.gov). The content after the `+` can be any _digit_. The whitelist checks for the "base" email (example.person) so even if you only have the +1 email defined, an email will still be sent assuming that it follows those conventions.
To add yourself to this, you can go about it in three ways.
Permanent (all sandboxes):
1. In src/registrar/fixtures_users.py, add the "email" field to your user in either the ADMIN or STAFF table.
2. In src/registrar/fixtures_users.py, add the desired email address to the `ADDITIONAL_ALLOWED_EMAILS` list. This route is suggested for product.
Sandbox specific (wiped when the db is reset):
3. Create a new record on the `Allowed emails` table with your email address. This can be done through django admin.
More detailed instructions regarding #3 can be found [here](https://docs.google.com/document/d/1ebIz4PcUuoiT7LlVy83EAyHAk_nWPEc99neMp4QjzDs).
## Adding to CODEOWNERS (optional)
The CODEOWNERS file sets the tagged individuals as default reviewers on any Pull Request that changes files that they are marked as owners of.

View file

@ -7,9 +7,11 @@ from django import forms
from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from django.conf import settings
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models.domain_information import DomainInformation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
@ -1953,6 +1955,9 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
else:
obj.action_needed_reason_email = default_email
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
self._check_for_valid_email(request, obj)
# == Handle status == #
if obj.status == original_obj.status:
# If the status hasn't changed, let the base function take care of it
@ -1965,6 +1970,29 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if should_save:
return super().save_model(request, obj, form, change)
def _check_for_valid_email(self, request, obj):
"""Certain emails are whitelisted in non-production environments,
so we should display that information using this function.
"""
# TODO 2574: remove lines 1977-1978 (refactor as needed)
profile_flag = flag_is_active(request, "profile_feature")
if profile_flag and hasattr(obj, "creator"):
recipient = obj.creator
elif not profile_flag and hasattr(obj, "submitter"):
recipient = obj.submitter
else:
recipient = None
# Displays a warning in admin when an email cannot be sent
if recipient and recipient.email:
email = recipient.email
allowed = models.AllowedEmail.is_allowed_email(email)
error_message = f"Could not send email. The email '{email}' does not exist within the whitelist."
if not allowed:
messages.warning(request, error_message)
def _handle_status_change(self, request, obj, original_obj):
"""
Checks for various conditions when a status change is triggered.
@ -2926,11 +2954,7 @@ class PortfolioAdmin(ListHeaderAdmin):
fieldsets = [
# created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
(None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
# TODO - uncomment in #2521
# ("Portfolio members", {
# "classes": ("collapse", "closed"),
# "fields": ["administrators", "members"]}
# ),
("Portfolio members", {"fields": ["display_admins", "display_members"]}),
("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
(
@ -2978,15 +3002,118 @@ class PortfolioAdmin(ListHeaderAdmin):
readonly_fields = [
# This is the created_at field
"created_on",
# Custom fields such as these must be defined as readonly.
# Django admin doesn't allow methods to be directly listed in fieldsets. We can
# display the custom methods display_admins amd display_members in the admin form if
# they are readonly.
"federal_type",
"domains",
"domain_requests",
"suborganizations",
"portfolio_type",
"display_admins",
"display_members",
"creator",
]
def get_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio
admin_permissions = UserPortfolioPermission.objects.filter(
portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Get the user objects associated with these permissions
admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions)
return admin_users
def get_non_admin_users(self, obj):
# Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude(
roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
# Get the user objects associated with these permissions
non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions)
return non_admin_users
def display_admins(self, obj):
"""Get joined users who are Admin, unpack and return an HTML block.
'DJA readonly can't handle querysets, so we need to unpack and return html here.
Alternatively, we could return querysets in context but that would limit where this
data would display in a custom change form without extensive template customization.
Will be used in the field_readonly block"""
admins = self.get_admin_users(obj)
if not admins:
return format_html("<p>No admins found.</p>")
admin_details = ""
for portfolio_admin in admins:
change_url = reverse("admin:registrar_user_change", args=[portfolio_admin.pk])
admin_details += "<address class='margin-bottom-2 dja-address-contact-list'>"
admin_details += f'<a href="{change_url}">{escape(portfolio_admin)}</a><br>'
admin_details += f"{escape(portfolio_admin.title)}<br>"
admin_details += f"{escape(portfolio_admin.email)}"
admin_details += "<div class='admin-icon-group admin-icon-group__clipboard-link'>"
admin_details += f"<input aria-hidden='true' class='display-none' value='{escape(portfolio_admin.email)}'>"
admin_details += (
"<button class='usa-button usa-button--unstyled padding-right-1 usa-button--icon padding-left-05"
+ "button--clipboard copy-to-clipboard text-no-underline' type='button'>"
)
admin_details += "<svg class='usa-icon'>"
admin_details += "<use aria-hidden='true' xlink:href='/public/img/sprite.svg#content_copy'></use>"
admin_details += "</svg>"
admin_details += "Copy"
admin_details += "</button>"
admin_details += "</div><br>"
admin_details += f"{escape(portfolio_admin.phone)}"
admin_details += "</address>"
return format_html(admin_details)
display_admins.short_description = "Administrators" # type: ignore
def display_members(self, obj):
"""Get joined users who have roles/perms that are not Admin, unpack and return an HTML block.
DJA readonly can't handle querysets, so we need to unpack and return html here.
Alternatively, we could return querysets in context but that would limit where this
data would display in a custom change form without extensive template customization.
Will be used in the after_help_text block."""
members = self.get_non_admin_users(obj)
if not members:
return ""
member_details = (
"<table><thead><tr><th>Name</th><th>Title</th><th>Email</th>"
+ "<th>Phone</th><th>Roles</th></tr></thead><tbody>"
)
for member in members:
full_name = member.get_formatted_name()
member_details += "<tr>"
member_details += f"<td>{escape(full_name)}</td>"
member_details += f"<td>{escape(member.title)}</td>"
member_details += f"<td>{escape(member.email)}</td>"
member_details += f"<td>{escape(member.phone)}</td>"
member_details += "<td>"
for role in member.portfolio_role_summary(obj):
member_details += f"<span class='usa-tag'>{escape(role)}</span> "
member_details += "</td></tr>"
member_details += "</tbody></table>"
return format_html(member_details)
display_members.short_description = "Members" # type: ignore
def display_members_summary(self, obj):
"""Will be passed as context and used in the field_readonly block."""
members = self.get_non_admin_users(obj)
if not members:
return {}
return self.get_field_links_as_list(members, "user", separator=", ")
def federal_type(self, obj: models.Portfolio):
"""Returns the federal_type field"""
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
@ -3046,7 +3173,7 @@ class PortfolioAdmin(ListHeaderAdmin):
]
def get_field_links_as_list(
self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None
self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
):
"""
Generate HTML links for items in a queryset, using a specified attribute for link text.
@ -3078,14 +3205,14 @@ class PortfolioAdmin(ListHeaderAdmin):
if link_info_attribute:
link += f" ({self.value_of_attribute(item, link_info_attribute)})"
if seperator:
if separator:
links.append(link)
else:
links.append(f"<li>{link}</li>")
# If no seperator is specified, just return an unordered list.
if seperator:
return format_html(seperator.join(links)) if links else "-"
# If no separator is specified, just return an unordered list.
if separator:
return format_html(separator.join(links)) if links else "-"
else:
links = "".join(links)
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else "-"
@ -3128,8 +3255,12 @@ class PortfolioAdmin(ListHeaderAdmin):
return readonly_fields
def change_view(self, request, object_id, form_url="", extra_context=None):
"""Add related suborganizations and domain groups"""
extra_context = {"skip_additional_contact_info": True}
"""Add related suborganizations and domain groups.
Add the summary for the portfolio members field (list of members that link to change_forms)."""
obj = self.get_object(request, object_id)
extra_context = extra_context or {}
extra_context["skip_additional_contact_info"] = True
extra_context["display_members_summary"] = self.display_members_summary(obj)
return super().change_view(request, object_id, form_url, extra_context)
def save_model(self, request, obj, form, change):
@ -3238,6 +3369,16 @@ class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().change_view(request, object_id, form_url, extra_context)
class AllowedEmailAdmin(ListHeaderAdmin):
class Meta:
model = models.AllowedEmail
list_display = ["email"]
search_fields = ["email"]
search_help_text = "Search by email."
ordering = ["email"]
admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin)
@ -3266,6 +3407,7 @@ admin.site.register(models.DomainGroup, DomainGroupAdmin)
admin.site.register(models.Suborganization, SuborganizationAdmin)
admin.site.register(models.SeniorOfficial, SeniorOfficialAdmin)
admin.site.register(models.UserPortfolioPermission, UserPortfolioPermissionAdmin)
admin.site.register(models.AllowedEmail, AllowedEmailAdmin)
# Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)

View file

@ -519,7 +519,6 @@ function initializeWidgetOnList(list, parentId) {
var actionNeededEmailReadonlyTextarea = document.querySelector("#action-needed-reason-email-readonly-textarea")
// Edit e-mail modal (and its confirmation button)
var actionNeededEmailAlreadySentModal = document.querySelector("#email-already-sent-modal")
var confirmEditEmailButton = document.querySelector("#email-already-sent-modal_continue-editing-button")
// Headers and footers (which change depending on if the e-mail was sent or not)
@ -561,11 +560,11 @@ function initializeWidgetOnList(list, parentId) {
updateActionNeededEmailDisplay(reason)
});
editEmailButton.addEventListener("click", function() {
if (!checkEmailAlreadySent()) {
showEmail(canEdit=true)
}
});
// editEmailButton.addEventListener("click", function() {
// if (!checkEmailAlreadySent()) {
// showEmail(canEdit=true)
// }
// });
confirmEditEmailButton.addEventListener("click", function() {
// Show editable view

View file

@ -1220,7 +1220,7 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url;
const suborganization = domain.suborganization ? domain.suborganization : '';
const suborganization = domain.suborganization ? domain.suborganization : '';
const row = document.createElement('tr');
@ -1229,7 +1229,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!noPortfolioFlag) {
markupForSuborganizationRow = `
<td>
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span>
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
</td>
`
}
@ -1910,7 +1910,7 @@ document.addEventListener('DOMContentLoaded', function() {
let editableFormGroup = button.parentElement.parentElement.parentElement;
if (editableFormGroup){
let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field")
let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field")
let inputField = document.getElementById(`id_${fieldName}`);
if (!inputField || !readonlyField) {
return;
@ -1936,8 +1936,8 @@ document.addEventListener('DOMContentLoaded', function() {
// 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")
fullNameField.classList.add("toggleable_input__error")
label = fullNameField.querySelector(".toggleable_input__readonly-field")
label.innerHTML = "Unknown";
}
}
@ -2043,7 +2043,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Due to the nature of how uswds works, this is slightly hacky.
// Use a MutationObserver to watch for changes in the dropdown list
const dropdownList = document.querySelector(`#${input.id}--list`);
const dropdownList = comboBox.querySelector(`#${input.id}--list`);
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === "childList") {
@ -2111,7 +2111,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!initialValue){
blankOption.classList.add("usa-combo-box__list-option--selected")
}
blankOption.textContent = "---------";
blankOption.textContent = "";
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
blankOption.addEventListener("click", (e) => {

View file

@ -546,7 +546,7 @@ button .usa-icon,
#submitRowToggle {
color: var(--body-fg);
}
.requested-domain-sticky {
.submit-row-sticky {
max-width: 325px;
overflow: hidden;
white-space: nowrap;

View file

@ -33,16 +33,19 @@ body {
}
#wrapper.dashboard--portfolio {
background-color: color('gray-1');
padding-top: units(4)!important;
}
#wrapper.dashboard--grey-1 {
background-color: color('gray-1');
}
.section--outlined {
.section-outlined {
background-color: color('white');
border: 1px solid color('base-lighter');
border-radius: 4px;
padding: 0 units(2) units(3);
padding: 0 units(4) units(3) units(2);
margin-top: units(3);
&.margin-top-0 {
@ -72,9 +75,13 @@ body {
}
}
.section--outlined__header--no-portfolio {
.section--outlined__search,
.section--outlined__utility-button {
.section-outlined--border-base-light {
border: 1px solid color('base-light');
}
.section-outlined__header--no-portfolio {
.section-outlined__search,
.section-outlined__utility-button {
margin-top: units(2);
}
@ -82,11 +89,11 @@ body {
display: flex;
column-gap: units(3);
.section--outlined__search,
.section--outlined__utility-button {
.section-outlined__search,
.section-outlined__utility-button {
margin-top: 0;
}
.section--outlined__search {
.section-outlined__search {
flex-grow: 4;
// Align right
max-width: 383px;
@ -192,3 +199,7 @@ abbr[title] {
max-width: 50ch;
}
}
.margin-right-neg-4px {
margin-right: -4px;
}

View file

@ -124,10 +124,6 @@ a.withdraw:active {
background-color: color('error-darker');
}
.usa-button--unstyled .usa-icon {
vertical-align: bottom;
}
a.usa-button--unstyled:visited {
color: color('primary');
}
@ -162,14 +158,14 @@ a.usa-button--unstyled:visited {
}
}
.input-with-edit-button {
.toggleable_input {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
&.input-with-edit-button__error {
&.toggleable_input__error {
svg.usa-icon {
color: #{$dhs-red};
}
@ -205,12 +201,32 @@ a.usa-button--unstyled:visited {
}
}
.dotgov-table a,
.usa-link--icon,
.usa-button--with-icon {
display: flex;
align-items: flex-start;
color: color('primary');
column-gap: units(.5);
align-items: center;
}
.dotgov-table a,
.usa-link--icon {
&:visited {
color: color('primary');
}
}
a .usa-icon,
.usa-button--with-icon .usa-icon {
height: 1.3em;
width: 1.3em;
}
.usa-icon.usa-icon--big {
margin: 0;
height: 1.5em;
width: 1.5em;
}
.margin-right-neg-4px {
margin-right: -4px;
}

View file

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

View file

@ -1,5 +1,10 @@
@use "uswds-core" as *;
td,
th {
vertical-align: top;
}
.dotgov-table--stacked {
td, th {
padding: units(1) units(2) units(2px) 0;
@ -12,7 +17,7 @@
tr {
border-bottom: none;
border-top: 2px solid color('base-light');
border-top: 2px solid color('base-lighter');
margin-top: units(2);
&:first-child {
@ -39,10 +44,6 @@
.dotgov-table {
width: 100%;
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
tbody th {
word-break: break-word;
}
@ -56,7 +57,7 @@
}
td, th {
border-bottom: 1px solid color('base-light');
border-bottom: 1px solid color('base-lighter');
}
thead th {
@ -72,11 +73,17 @@
td, th,
.usa-tabel th{
padding: units(2) units(2) units(2) 0;
padding: units(2) units(4) units(2) 0;
}
thead tr:first-child th:first-child {
border-top: none;
}
}
@include at-media(tablet-lg) {
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
}
}

View file

@ -10,7 +10,6 @@
--- Custom Styles ---------------------------------*/
@forward "base";
@forward "typography";
@forward "links";
@forward "lists";
@forward "accordions";
@forward "buttons";

View file

@ -6,6 +6,7 @@ from registrar.models import (
User,
UserGroup,
)
from registrar.models.allowed_email import AllowedEmail
fake = Faker()
@ -32,6 +33,7 @@ class UserFixture:
"username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf",
"first_name": "Aditi",
"last_name": "Green",
"email": "aditidevelops+01@gmail.com",
},
{
"username": "be17c826-e200-4999-9389-2ded48c43691",
@ -42,11 +44,13 @@ class UserFixture:
"username": "5f283494-31bd-49b5-b024-a7e7cae00848",
"first_name": "Rachid",
"last_name": "Mrad",
"email": "rachid.mrad@associates.cisa.dhs.gov",
},
{
"username": "eb2214cd-fc0c-48c0-9dbd-bc4cd6820c74",
"first_name": "Alysia",
"last_name": "Broddrick",
"email": "abroddrick@truss.works",
},
{
"username": "8f8e7293-17f7-4716-889b-1990241cbd39",
@ -63,6 +67,7 @@ class UserFixture:
"username": "83c2b6dd-20a2-4cac-bb40-e22a72d2955c",
"first_name": "Cameron",
"last_name": "Dixon",
"email": "cameron.dixon@cisa.dhs.gov",
},
{
"username": "0353607a-cbba-47d2-98d7-e83dcd5b90ea",
@ -83,16 +88,19 @@ class UserFixture:
"username": "2a88a97b-be96-4aad-b99e-0b605b492c78",
"first_name": "Rebecca",
"last_name": "Hsieh",
"email": "rebecca.hsieh@truss.works",
},
{
"username": "fa69c8e8-da83-4798-a4f2-263c9ce93f52",
"first_name": "David",
"last_name": "Kennedy",
"email": "david.kennedy@ecstech.com",
},
{
"username": "f14433d8-f0e9-41bf-9c72-b99b110e665d",
"first_name": "Nicolle",
"last_name": "LeClair",
"email": "nicolle.leclair@ecstech.com",
},
{
"username": "24840450-bf47-4d89-8aa9-c612fe68f9da",
@ -141,6 +149,7 @@ class UserFixture:
"username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54",
"first_name": "Aditi-Analyst",
"last_name": "Green-Analyst",
"email": "aditidevelops+02@gmail.com",
},
{
"username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99",
@ -183,6 +192,7 @@ class UserFixture:
"username": "5dc6c9a6-61d9-42b4-ba54-4beff28bac3c",
"first_name": "David-Analyst",
"last_name": "Kennedy-Analyst",
"email": "david.kennedy@associates.cisa.dhs.gov",
},
{
"username": "0eb6f326-a3d4-410f-a521-aa4c1fad4e47",
@ -194,7 +204,7 @@ class UserFixture:
"username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3",
"first_name": "Nicolle-Analyst",
"last_name": "LeClair-Analyst",
"email": "nicolle.leclair@ecstech.com",
"email": "nicolle.leclair@gmail.com",
},
{
"username": "378d0bc4-d5a7-461b-bd84-3ae6f6864af9",
@ -240,6 +250,9 @@ class UserFixture:
},
]
# Additional emails to add to the AllowedEmail whitelist.
ADDITIONAL_ALLOWED_EMAILS: list[str] = ["davekenn4242@gmail.com", "rachid_mrad@hotmail.com"]
def load_users(cls, users, group_name, are_superusers=False):
logger.info(f"Going to load {len(users)} users in group {group_name}")
for user_data in users:
@ -264,6 +277,32 @@ class UserFixture:
logger.warning(e)
logger.info(f"All users in group {group_name} loaded.")
def load_allowed_emails(cls, users, additional_emails):
"""Populates a whitelist of allowed emails (as defined in this list)"""
logger.info(f"Going to load allowed emails for {len(users)} users")
if additional_emails:
logger.info(f"Going to load {len(additional_emails)} additional allowed emails")
# Load user emails
allowed_emails = []
for user_data in users:
user_email = user_data.get("email")
if user_email and user_email not in allowed_emails:
allowed_emails.append(AllowedEmail(email=user_email))
else:
first_name = user_data.get("first_name")
last_name = user_data.get("last_name")
logger.warning(f"Could not add email to whitelist for {first_name} {last_name}.")
# Load additional emails
allowed_emails.extend([AllowedEmail(email=email) for email in additional_emails])
if allowed_emails:
AllowedEmail.objects.bulk_create(allowed_emails)
logger.info(f"Loaded {len(allowed_emails)} allowed emails")
else:
logger.info("No allowed emails to load")
@classmethod
def load(cls):
# Lumped under .atomic to ensure we don't make redundant DB calls.
@ -275,3 +314,7 @@ class UserFixture:
with transaction.atomic():
cls.load_users(cls, cls.ADMINS, "full_access_group", are_superusers=True)
cls.load_users(cls, cls.STAFF, "cisa_analysts_group")
# Combine ADMINS and STAFF lists
all_users = cls.ADMINS + cls.STAFF
cls.load_allowed_emails(cls, all_users, additional_emails=cls.ADDITIONAL_ALLOWED_EMAILS)

View file

@ -417,7 +417,7 @@ class SeniorOfficialContactForm(ContactForm):
# This action should be blocked by the UI, as the text fields are readonly.
# If they get past this point, we forbid it this way.
# This could be malicious, so lets reserve information for the backend only.
raise ValueError("Senior Official cannot be modified for federal or tribal domains.")
raise ValueError("Senior official cannot be modified for federal or tribal domains.")
elif db_so.has_more_than_one_join("information_senior_official"):
# Handle the case where the domain information object is available and the SO Contact
# has more than one joined object.

View file

@ -0,0 +1,25 @@
# Generated by Django 4.2.10 on 2024-08-29 18:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0120_add_domainrequest_submission_dates"),
]
operations = [
migrations.CreateModel(
name="AllowedEmail",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("email", models.EmailField(max_length=320, unique=True)),
],
options={
"abstract": False,
},
),
]

View file

@ -0,0 +1,37 @@
# This migration creates the create_full_access_group and create_cisa_analyst_group groups
# It is dependent on 0079 (which populates federal agencies)
# If permissions on the groups need changing, edit CISA_ANALYST_GROUP_PERMISSIONS
# in the user_group model then:
# [NOT RECOMMENDED]
# step 1: docker-compose exec app ./manage.py migrate --fake registrar 0035_contenttypes_permissions
# step 2: docker-compose exec app ./manage.py migrate registrar 0036_create_groups
# step 3: fake run the latest migration in the migrations list
# [RECOMMENDED]
# Alternatively:
# step 1: duplicate the migration that loads data
# step 2: docker-compose exec app ./manage.py migrate
from django.db import migrations
from registrar.models import UserGroup
from typing import Any
# For linting: RunPython expects a function reference,
# so let's give it one
def create_groups(apps, schema_editor) -> Any:
UserGroup.create_cisa_analyst_group(apps, schema_editor)
UserGroup.create_full_access_group(apps, schema_editor)
class Migration(migrations.Migration):
dependencies = [
("registrar", "0121_allowedemail"),
]
operations = [
migrations.RunPython(
create_groups,
reverse_code=migrations.RunPython.noop,
atomic=True,
),
]

View file

@ -22,6 +22,7 @@ from .domain_group import DomainGroup
from .suborganization import Suborganization
from .senior_official import SeniorOfficial
from .user_portfolio_permission import UserPortfolioPermission
from .allowed_email import AllowedEmail
__all__ = [
@ -48,6 +49,7 @@ __all__ = [
"Suborganization",
"SeniorOfficial",
"UserPortfolioPermission",
"AllowedEmail",
]
auditlog.register(Contact)
@ -73,3 +75,4 @@ auditlog.register(DomainGroup)
auditlog.register(Suborganization)
auditlog.register(SeniorOfficial)
auditlog.register(UserPortfolioPermission)
auditlog.register(AllowedEmail)

View file

@ -0,0 +1,52 @@
from django.db import models
from django.db.models import Q
import re
from .utility.time_stamped_model import TimeStampedModel
class AllowedEmail(TimeStampedModel):
"""
AllowedEmail is a whitelist for email addresses that we can send to
in non-production environments.
"""
email = models.EmailField(
unique=True,
null=False,
blank=False,
max_length=320,
)
@classmethod
def is_allowed_email(cls, email):
"""Given an email, check if this email exists within our AllowEmail whitelist"""
if not email:
return False
# Split the email into a local part and a domain part
local, domain = email.split("@")
# If the email exists within the whitelist, then do nothing else.
email_exists = cls.objects.filter(email__iexact=email).exists()
if email_exists:
return True
# Check if there's a '+' in the local part
if "+" in local:
base_local = local.split("+")[0]
base_email_exists = cls.objects.filter(Q(email__iexact=f"{base_local}@{domain}")).exists()
# Given an example email, such as "joe.smoe+1@igorville.com"
# The full regex statement will be: "^joe.smoe\\+\\d+@igorville.com$"
pattern = f"^{re.escape(base_local)}\\+\\d+@{re.escape(domain)}$"
return base_email_exists and re.match(pattern, email)
else:
# Edge case, the +1 record exists but the base does not,
# and the record we are checking is the base record.
pattern = f"^{re.escape(local)}\\+\\d+@{re.escape(domain)}$"
plus_email_exists = cls.objects.filter(Q(email__iregex=pattern)).exists()
return plus_email_exists
def __str__(self):
return str(self.email)

View file

@ -581,6 +581,12 @@ class DomainRequest(TimeStampedModel):
blank=True,
)
@classmethod
def get_statuses_that_send_emails(cls):
"""Returns a list of statuses that send an email to the user"""
excluded_statuses = [cls.DomainRequestStatus.INELIGIBLE, cls.DomainRequestStatus.IN_REVIEW]
return [status for status in cls.DomainRequestStatus if status not in excluded_statuses]
def sync_organization_type(self):
"""
Updates the organization_type (without saving) to match

View file

@ -245,6 +245,49 @@ class User(AbstractUser):
return permission.portfolio
return None
def has_edit_requests(self, portfolio):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
def portfolio_role_summary(self, portfolio):
"""Returns a list of roles based on the user's permissions."""
roles = []
# Define the conditions and their corresponding roles
conditions_roles = [
(self.has_edit_suborganization(portfolio), ["Admin"]),
(
self.has_view_all_domains_permission(portfolio)
and self.has_domain_requests_portfolio_permission(portfolio)
and self.has_edit_requests(portfolio),
["View-only admin", "Domain requestor"],
),
(
self.has_view_all_domains_permission(portfolio)
and self.has_domain_requests_portfolio_permission(portfolio),
["View-only admin"],
),
(
self.has_base_portfolio_permission(portfolio)
and self.has_edit_requests(portfolio)
and self.has_domains_portfolio_permission(portfolio),
["Domain requestor", "Domain manager"],
),
(self.has_base_portfolio_permission(portfolio) and self.has_edit_requests(portfolio), ["Domain requestor"]),
(
self.has_base_portfolio_permission(portfolio) and self.has_domains_portfolio_permission(portfolio),
["Domain manager"],
),
(self.has_base_portfolio_permission(portfolio), ["Member"]),
]
# Evaluate conditions and add roles
for condition, role_list in conditions_roles:
if condition:
roles.extend(role_list)
break
return roles
@classmethod
def needs_identity_verification(cls, email, uuid):
"""A method used by our oidc classes to test whether a user needs email/uuid verification

View file

@ -17,7 +17,7 @@ Template for an input field with a clipboard
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span>Copy</span>
Copy
</div>
</button>
</div>
@ -25,7 +25,7 @@ Template for an input field with a clipboard
<div class="admin-icon-group admin-icon-group__clipboard-link">
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline"
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05"
type="button"
>
<svg
@ -33,7 +33,7 @@ Template for an input field with a clipboard
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span class="padding-left-05">Copy</span>
Copy
</button>
</div>
{% endif %}

View file

@ -32,6 +32,8 @@
{% include "django/admin/includes/descriptions/website_description.html" %}
{% elif opts.model_name == 'portfolioinvitation' %}
{% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %}
{% elif opts.model_name == 'allowedemail' %}
{% include "django/admin/includes/descriptions/allowed_email_description.html" %}
{% else %}
<p>This table does not have a description yet.</p>
{% endif %}

View file

@ -120,7 +120,7 @@
</button>
</span>
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 requested-domain-sticky float-right visible-768">
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
Requested domain: <strong>{{ original.requested_domain.name }}</strong>
</p>
{{ block.super }}

View file

@ -0,0 +1,6 @@
<p>This table is an email allow list for <strong>non-production</strong> environments.</p>
<p>
If an email is sent out and the email does not exist within this table (or is not a subset of it),
then no email will be sent.
</p>
<p>If this table is populated in a production environment, no change will occur as it will simply be ignored.</p>

View file

@ -137,6 +137,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %}
{% endwith %}
</div>
{% elif field.field.name == "display_admins" %}
<div class="readonly">{{ field.contents|safe }}</div>
{% elif field.field.name == "display_members" %}
<div class="readonly">
{% if display_members_summary %}
{{ display_members_summary }}
{% else %}
<p>No additional members found.</p>
{% endif %}
</div>
{% else %}
<div class="readonly">{{ field.contents }}</div>
{% endif %}
@ -330,7 +340,14 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
</details>
{% endif %}
{% endwith %}
{% elif field.field.name == "state_territory" %}
{% elif field.field.name == "display_members" and field.contents %}
<details class="margin-top-1 dja-detail-table" aria-role="button" open>
<summary class="padding-1 padding-left-0 dja-details-summary">Details</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{{ field.contents|safe }}
</div>
</details>
{% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
<div class="flex-container margin-top-2">
<span>
CISA region:

View file

@ -1,4 +1,5 @@
{% extends 'django/admin/email_clipboard_change_form.html' %}
{% load custom_filters %}
{% load i18n static %}
{% block content %}
@ -16,10 +17,28 @@
This is a placeholder for now.
Disclaimer:
When extending the fieldset view - *make a new one* that extends from detail_table_fieldset.
For instance, "portfolio_fieldset.html".
When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
{% endcomment %}
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
{% endfor %}
{% endblock %}
{% block submit_buttons_bottom %}
<div class="submit-row-wrapper">
<span class="submit-row-toggle padding-1 padding-right-2 visible-desktop">
<button type="button" class="usa-button usa-button--unstyled" id="submitRowToggle">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#expand_more"></use>
</svg>
<span>Hide</span>
</button>
</span>
<p class="padding-top-05 text-right margin-top-2 padding-right-2 margin-bottom-0 submit-row-sticky float-right visible-768">
Organization Name: <strong>{{ original.organization_name }}</strong>
</p>
{{ block.super }}
</div>
<span class="scroll-indicator"></span>
{% endblock %}

View file

@ -7,7 +7,9 @@ for now we just carry the attribute to both the parent element and the select.
<div class="usa-combo-box"
{% for name, value in widget.attrs.items %}
{% if name != 'id' %}
{{ name }}="{{ value }}"
{% endif %}
{% endfor %}
>
{% include "django/forms/widgets/select.html" %}

View file

@ -63,10 +63,10 @@
<div class="grid-row margin-top-1">
<div class="grid-col">
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span>
</svg>Delete
</button>
</div>
</div>
@ -74,10 +74,10 @@
</fieldset>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-form">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon margin-bottom-2" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add new record</span>
</svg>Add new record
</button>
<button

View file

@ -52,20 +52,20 @@
{% endwith %}
</div>
<div class="tablet:grid-col-2">
<button type="button" class="usa-button usa-button--unstyled display-block delete-record margin-bottom-075">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon delete-record margin-bottom-075">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
</svg><span class="margin-left-05">Delete</span>
</svg>Delete
</button>
</div>
</div>
</div>
{% endfor %}
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-form">
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another name server</span>
</svg>Add another name server
</button>
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'

View file

@ -42,7 +42,7 @@
{% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" %}
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors form.zipcode %}
{% endwith %}

View file

@ -33,7 +33,7 @@
{% input_with_errors forms.0.state_territory %}
{% with add_class="usa-input--small" %}
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors forms.0.zipcode %}
{% endwith %}

View file

@ -1,7 +1,7 @@
{% extends "domain_base.html" %}
{% load static field_helpers%}
{% block title %}Suborganization{% endblock %}
{% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %}
{% block domain_content %}
{# this is right after the messages block in the parent template #}

View file

@ -21,7 +21,7 @@
</ul>
{% if domain.permissions %}
<section class="section--outlined">
<section class="section-outlined">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<h2 class> Domain managers </h2>
<caption class="sr-only">Domain managers</caption>
@ -112,7 +112,7 @@
</section>
{% if domain.invitations.exists %}
<section class="section--outlined">
<section class="section-outlined">
<h2>Invitations</h2>
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<caption class="sr-only">Domain invitations</caption>

View file

@ -3,7 +3,7 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domain_requests_json' as url %}
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
<section class="section--outlined domain-requests" id="domain-requests">
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
<div class="grid-row">
{% if not has_domain_requests_portfolio_permission %}
<div class="mobile:grid-col-12 desktop:grid-col-6">

View file

@ -5,8 +5,8 @@
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_domains_json' as url %}
<span id="get_domains_json_url" class="display-none">{{url}}</span>
<section class="section--outlined domains{% if not portfolio %} margin-top-0{% endif %}" id="domains">
<div class="section--outlined__header margin-bottom-3 {% if not portfolio %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
<section class="section-outlined domains margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domains">
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not portfolio %}
<h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span>
@ -14,7 +14,7 @@
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %}
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Domains search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
@ -43,10 +43,10 @@
</section>
</div>
{% if user_domain_count and user_domain_count > 0 %}
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="mobile-lg:margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Domains report component" class="margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon" role="button">
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV
</a>

View file

@ -12,6 +12,29 @@
<button type="button" class="usa-nav__close">
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
</button>
<div class="usa-nav__secondary">
<ul class="usa-nav__secondary-links">
<li class="usa-nav__secondary-item">
{% if user.is_authenticated %}
<span class="ellipsis usa-nav__username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__secondary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if path == user_profile_url or path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
Your profile
</a>
</li>
{% endif %}
<li class="usa-nav__secondary-item">
<a class="usa-nav-link" href="{% url 'logout' %}">Sign out</a>
{% else %}
<a class="usa-nav-link" href="{% url 'login' %}">Sign in</a>
{% endif %}
</li>
</ul>
</div>
<ul class="usa-nav__primary usa-accordion">
<li class="usa-nav__primary-item">
{% if has_domains_portfolio_permission %}
@ -45,36 +68,13 @@
<li class="usa-nav__primary-item">
{% url 'organization' as url %}
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
<a href="{{ url }}" class="usa-nav-link padding-y-0">
<a href="{{ url }}" class="usa-nav-link padding-y-0 {% if request.path == '/organization/' %} usa-current{% endif %}">
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
{{ portfolio.organization_name }}
</span>
</a>
</li>
</ul>
<div class="usa-nav__secondary">
<ul class="usa-nav__secondary-links">
<li class="usa-nav__secondary-item">
{% if user.is_authenticated %}
<span class="ellipsis usa-nav__username">{{ user.email }}</span>
</li>
{% if has_profile_feature_flag %}
<li class="usa-nav__secondary-item">
{% url 'user-profile' as user_profile_url %}
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if path == user_profile_url or path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
Your profile
</a>
</li>
{% endif %}
<li class="usa-nav__secondary-item">
<a class="usa-nav-link" href="{% url 'logout' %}">Sign out</a>
{% else %}
<a class="usa-nav-link" href="{% url 'login' %}">Sign in</a>
{% endif %}
</li>
</ul>
</div>
</div>
</nav>
{% endblock %}

View file

@ -4,7 +4,7 @@
{% include "includes/form_errors.html" with form=form %}
{% endif %}
<h1>Senior Official</h1>
<h1>Senior official</h1>
<p>
Your senior official is a person within your organization who can authorize domain requests.

View file

@ -1,6 +1,6 @@
{% load static field_helpers url_helpers custom_filters %}
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 input-with-edit-button {% if not field.value and field.field.required %}input-with-edit-button__error{% endif %}">
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 toggleable_input {% if not field.value and field.field.required %}toggleable_input__error{% endif %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
{% if field.value or not field.field.required %}
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
@ -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 input-with-edit-button__readonly-field {% if not field.field.required %}text-base{% endif %}">
<div class="display-inline padding-left-05 margin-left-3 toggleable_input__readonly-field {% if not field.field.required %}text-base{% endif %}">
{% if field.name != "phone" %}
{{ field.value }}
{% else %}

View file

@ -5,9 +5,10 @@
{% block title %} Domains | {% endblock %}
{% block portfolio_content %}
<div id="main-content">
<h1 id="domains-header">Domains</h1>
<section class="section--outlined">
<div class="section--outlined__header margin-bottom-3">
<section class="section-outlined">
<div class="section-outlined__header margin-bottom-3">
<h2 id="domains-header" class="display-inline-block">You arent managing any domains.</h2>
{% if portfolio_administrators %}
<p>If you believe you should have access to a domain, reach out to your organizations administrators.</p>
@ -27,4 +28,5 @@
{% endif %}
</div>
</section>
</div>
{% endblock %}

View file

@ -1,10 +1,10 @@
{% extends "base.html" %}
{% block wrapper %}
<div id="wrapper" class="dashboard--portfolio">
<div id="wrapper" class="{% block wrapper_class %}dashboard--portfolio{% endblock %}">
{% block content %}
<main id="main-content" class="grid-container">
<main class="grid-container">
{% if user.is_authenticated %}
{# the entire logged in page goes here #}
@ -26,10 +26,8 @@
{% endif %}
</main>
{% endblock %}
{% endblock content%}
<div role="complementary">{% block complementary %}{% endblock %}</div>
{% block content_bottom %}{% endblock %}
</div>
{% endblock wrapper %}

View file

@ -4,7 +4,13 @@
{% block title %} Domains | {% endblock %}
{% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %}
<div id="main-content">
<h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %}
</div>
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Organization mailing address | {{ portfolio.name }} | {% endblock %}
{% block title %}Organization mailing address | {{ portfolio.name }}{% endblock %}
{% load static %}
@ -17,7 +17,7 @@
{% include 'portfolio_organization_sidebar.html' %}
</div>
<div class="tablet:grid-col-9">
<div class="tablet:grid-col-9" id="main-content">
<h1>Organization</h1>
@ -41,7 +41,7 @@
{% input_with_errors form.address_line2 %}
{% input_with_errors form.city %}
{% input_with_errors form.state_territory %}
{% with add_class="usa-input--small" %}
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
{% input_with_errors form.zipcode %}
{% endwith %}
<button type="submit" class="usa-button">

View file

@ -4,7 +4,12 @@
{% block title %} Domain requests | {% endblock %}
{% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %}
<div id="main-content">
<h1 id="domain-requests-header">Domain requests</h1>
{% comment %}
@ -20,4 +25,5 @@
</p>
{% include "includes/domain_requests_table.html" with portfolio=portfolio %}
</div>
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends 'portfolio_base.html' %}
{% load static field_helpers%}
{% block title %}Senior Official | {{ portfolio.name }} | {% endblock %}
{% block title %}Senior official | {{ portfolio.name }}{% endblock %}
{% load static %}
@ -17,7 +17,7 @@
{% include 'portfolio_organization_sidebar.html' %}
</div>
<div class="tablet:grid-col-9">
<div class="tablet:grid-col-9" id="main-content">
{% include "includes/senior_official.html" with can_edit=False %}
</div>
</div>

View file

@ -169,3 +169,8 @@ def has_contact_info(user):
return False
else:
return bool(user.title or user.email or user.phone)
@register.filter
def model_name_lowercase(instance):
return instance.__class__.__name__.lower()

View file

@ -45,6 +45,8 @@ from registrar.models import (
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.senior_official import SeniorOfficial
from registrar.models.user_domain_role import UserDomainRole
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.verified_by_staff import VerifiedByStaff
from .common import (
MockDbForSharedTests,
@ -1967,6 +1969,7 @@ class TestPortfolioAdmin(TestCase):
DomainRequest.objects.all().delete()
Domain.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
@less_console_noise_decorator
def test_created_on_display(self):
@ -2018,3 +2021,91 @@ class TestPortfolioAdmin(TestCase):
domain_requests = self.admin.domain_requests(self.portfolio)
self.assertIn("2 domain requests", domain_requests)
@less_console_noise_decorator
def test_portfolio_members_display(self):
"""Tests the custom portfolio members field, admin and member sections"""
admin_user_1 = User.objects.create(
username="testuser1",
first_name="Gerald",
last_name="Meoward",
title="Captain",
email="meaoward@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_1, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
admin_user_2 = User.objects.create(
username="testuser2",
first_name="Arnold",
last_name="Poopy",
title="Major",
email="poopy@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_2, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
)
admin_user_3 = User.objects.create(
username="testuser3",
first_name="Mad",
last_name="Max",
title="Road warrior",
email="madmax@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_3, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
)
admin_user_4 = User.objects.create(
username="testuser4",
first_name="Agent",
last_name="Smith",
title="Program",
email="thematrix@gov.gov",
)
UserPortfolioPermission.objects.all().create(
user=admin_user_4,
portfolio=self.portfolio,
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
],
)
display_admins = self.admin.display_admins(self.portfolio)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_1.pk}/change/">Gerald Meoward meaoward@gov.gov</a>',
display_admins,
)
self.assertIn("Captain", display_admins)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_2.pk}/change/">Arnold Poopy poopy@gov.gov</a>', display_admins
)
self.assertIn("Major", display_admins)
display_members_summary = self.admin.display_members_summary(self.portfolio)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_3.pk}/change/">Mad Max madmax@gov.gov</a>',
display_members_summary,
)
self.assertIn(
f'<a href="/admin/registrar/user/{admin_user_4.pk}/change/">Agent Smith thematrix@gov.gov</a>',
display_members_summary,
)
display_members = self.admin.display_members(self.portfolio)
self.assertIn("Mad Max", display_members)
self.assertIn("<span class='usa-tag'>Member</span>", display_members)
self.assertIn("Road warrior", display_members)
self.assertIn("Agent Smith", display_members)
self.assertIn("<span class='usa-tag'>Domain requestor</span>", display_members)
self.assertIn("Program", display_members)

View file

@ -23,6 +23,7 @@ from registrar.models import (
Website,
SeniorOfficial,
Portfolio,
AllowedEmail,
)
from .common import (
MockSESClient,
@ -71,6 +72,8 @@ class TestDomainRequestAdmin(MockEppLib):
model=DomainRequest,
)
self.mock_client = MockSESClient()
allowed_emails = [AllowedEmail(email="mayor@igorville.gov"), AllowedEmail(email="help@get.gov")]
AllowedEmail.objects.bulk_create(allowed_emails)
def tearDown(self):
super().tearDown()
@ -87,6 +90,7 @@ class TestDomainRequestAdmin(MockEppLib):
def tearDownClass(self):
super().tearDownClass()
User.objects.all().delete()
AllowedEmail.objects.all().delete()
@less_console_noise_decorator
def test_domain_request_senior_official_is_alphabetically_sorted(self):
@ -596,7 +600,8 @@ class TestDomainRequestAdmin(MockEppLib):
):
"""Helper method for the email test cases."""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with boto3_mocking.clients.handler_for("sesv2", self.mock_client), ExitStack() as stack:
stack.enter_context(patch.object(messages, "warning"))
# Create a mock request
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
@ -623,7 +628,8 @@ class TestDomainRequestAdmin(MockEppLib):
):
"""Helper method for the email test cases.
email_index is the index of the email in mock_client."""
AllowedEmail.objects.get_or_create(email=email_address)
AllowedEmail.objects.get_or_create(email=bcc_email_address)
with less_console_noise():
# Access the arguments passed to send_email
call_args = self.mock_client.EMAILS_SENT
@ -1243,6 +1249,7 @@ class TestDomainRequestAdmin(MockEppLib):
with ExitStack() as stack:
stack.enter_context(patch.object(messages, "error"))
stack.enter_context(patch.object(messages, "warning"))
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
self.admin.save_model(request, domain_request, None, True)
@ -1271,6 +1278,7 @@ class TestDomainRequestAdmin(MockEppLib):
with ExitStack() as stack:
stack.enter_context(patch.object(messages, "error"))
stack.enter_context(patch.object(messages, "warning"))
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY
@ -1328,6 +1336,8 @@ class TestDomainRequestAdmin(MockEppLib):
request = self.factory.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
with ExitStack() as stack:
stack.enter_context(patch.object(messages, "warning"))
# Modify the domain request's property
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED
@ -1780,6 +1790,8 @@ class TestDomainRequestAdmin(MockEppLib):
# Patch Domain.is_active and django.contrib.messages.error simultaneously
stack.enter_context(patch.object(Domain, "is_active", custom_is_active))
stack.enter_context(patch.object(messages, "error"))
stack.enter_context(patch.object(messages, "warning"))
stack.enter_context(patch.object(messages, "success"))
domain_request.status = another_state

View file

@ -2,11 +2,12 @@
from unittest.mock import MagicMock
from django.test import TestCase
from django.test import TestCase, override_settings
from waffle.testutils import override_flag
from registrar.utility import email
from registrar.utility.email import send_templated_email
from .common import completed_domain_request
from registrar.models import AllowedEmail
from api.tests.common import less_console_noise_decorator
from datetime import datetime
@ -14,10 +15,33 @@ import boto3_mocking # type: ignore
class TestEmails(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
allowed_emails = [
AllowedEmail(email="doesnotexist@igorville.com"),
AllowedEmail(email="testy@town.com"),
AllowedEmail(email="mayor@igorville.gov"),
AllowedEmail(email="testy2@town.com"),
AllowedEmail(email="cisaRep@igorville.gov"),
AllowedEmail(email="sender@example.com"),
AllowedEmail(email="recipient@example.com"),
]
AllowedEmail.objects.bulk_create(allowed_emails)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
AllowedEmail.objects.all().delete()
def setUp(self):
self.mock_client_class = MagicMock()
self.mock_client = self.mock_client_class.return_value
def tearDown(self):
super().tearDown()
@boto3_mocking.patching
@override_flag("disable_email_sending", active=True)
@less_console_noise_decorator
@ -231,3 +255,59 @@ class TestEmails(TestCase):
self.assertIn("Content-Transfer-Encoding: base64", call_args["RawMessage"]["Data"])
self.assertIn("Content-Disposition: attachment;", call_args["RawMessage"]["Data"])
self.assertNotIn("Attachment file content", call_args["RawMessage"]["Data"])
class TestAllowedEmail(TestCase):
"""Tests our allowed email whitelist"""
def setUp(self):
self.mock_client_class = MagicMock()
self.mock_client = self.mock_client_class.return_value
self.email = "mayor@igorville.gov"
self.email_2 = "cake@igorville.gov"
self.plus_email = "mayor+1@igorville.gov"
self.invalid_plus_email = "1+mayor@igorville.gov"
def tearDown(self):
super().tearDown()
AllowedEmail.objects.all().delete()
@boto3_mocking.patching
@override_settings(IS_PRODUCTION=True)
@less_console_noise_decorator
def test_email_whitelist_disabled_in_production(self):
"""Tests if the whitelist is disabled in production"""
# Ensure that the given email isn't in the whitelist
is_in_whitelist = AllowedEmail.objects.filter(email=self.email).exists()
self.assertFalse(is_in_whitelist)
# The submit should work as normal
domain_request = completed_domain_request(has_anything_else=False)
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
domain_request.submit()
_, kwargs = self.mock_client.send_email.call_args
body = kwargs["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertNotIn("Anything else", body)
# spacing should be right between adjacent elements
self.assertRegex(body, r"5557\n\n----")
@boto3_mocking.patching
@override_settings(IS_PRODUCTION=False)
@less_console_noise_decorator
def test_email_whitelist(self):
"""Tests the email whitelist is enabled elsewhere"""
with boto3_mocking.clients.handler_for("sesv2", self.mock_client_class):
expected_message = "Could not send email. "
"The email 'doesnotexist@igorville.com' does not exist within the whitelist."
with self.assertRaisesRegex(email.EmailSendingError, expected_message):
send_templated_email(
"test content",
"test subject",
"doesnotexist@igorville.com",
context={"domain_request": self},
bcc_address=None,
)
# Assert that an email wasn't sent
self.assertFalse(self.mock_client.send_email.called)

View file

@ -18,6 +18,7 @@ from registrar.models import (
UserDomainRole,
FederalAgency,
UserPortfolioPermission,
AllowedEmail,
)
import boto3_mocking
@ -234,7 +235,7 @@ class TestDomainRequest(TestCase):
self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
):
"""Check if an email was sent after performing an action."""
email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)
with self.subTest(msg=msg, action=action):
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
# Perform the specified action
@ -253,6 +254,8 @@ class TestDomainRequest(TestCase):
email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn(expected_content, email_content)
email_allowed.delete()
@override_flag("profile_feature", active=False)
@less_console_noise_decorator
def test_submit_from_started_sends_email(self):
@ -1306,6 +1309,7 @@ class TestUser(TestCase):
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
self.user, _ = User.objects.get_or_create(email=self.email)
self.factory = RequestFactory()
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.user)
def tearDown(self):
super().tearDown()
@ -1320,6 +1324,65 @@ class TestUser(TestCase):
User.objects.all().delete()
UserDomainRole.objects.all().delete()
@patch.object(User, "has_edit_suborganization", return_value=True)
def test_portfolio_role_summary_admin(self, mock_edit_suborganization):
# Test if the user is recognized as an Admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
@patch.multiple(
User,
has_view_all_domains_permission=lambda self, portfolio: True,
has_domain_requests_portfolio_permission=lambda self, portfolio: True,
has_edit_requests=lambda self, portfolio: True,
)
def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self):
# Test if the user has both 'View-only admin' and 'Domain requestor' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin", "Domain requestor"])
@patch.multiple(
User,
has_view_all_domains_permission=lambda self, portfolio: True,
has_domain_requests_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_view_only_admin(self):
# Test if the user is recognized as a View-only admin
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin"])
@patch.multiple(
User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_edit_requests=lambda self, portfolio: True,
has_domains_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_requestor_domain_manager(self):
# Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"])
@patch.multiple(
User, has_base_portfolio_permission=lambda self, portfolio: True, has_edit_requests=lambda self, portfolio: True
)
def test_portfolio_role_summary_member_domain_requestor(self):
# Test if the user has 'Member' and 'Domain requestor' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor"])
@patch.multiple(
User,
has_base_portfolio_permission=lambda self, portfolio: True,
has_domains_portfolio_permission=lambda self, portfolio: True,
)
def test_portfolio_role_summary_member_domain_manager(self):
# Test if the user has 'Member' and 'Domain manager' roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
@patch.multiple(User, has_base_portfolio_permission=lambda self, portfolio: True)
def test_portfolio_role_summary_member(self):
# Test if the user is recognized as a Member
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
def test_portfolio_role_summary_empty(self):
# Test if the user has no roles
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
@less_console_noise_decorator
def test_check_transition_domains_without_domains_on_login(self):
"""A user's on_each_login callback does not check transition domains.
@ -2435,3 +2498,103 @@ class TestPortfolio(TestCase):
self.assertEqual(portfolio.urbanization, "test123")
self.assertEqual(portfolio.state_territory, DomainRequest.StateTerritoryChoices.PUERTO_RICO)
class TestAllowedEmail(TestCase):
"""Tests our allowed email whitelist"""
@less_console_noise_decorator
def setUp(self):
self.email = "mayor@igorville.gov"
self.email_2 = "cake@igorville.gov"
self.plus_email = "mayor+1@igorville.gov"
self.invalid_plus_email = "1+mayor@igorville.gov"
def tearDown(self):
super().tearDown()
AllowedEmail.objects.all().delete()
def test_email_in_whitelist(self):
"""Test for a normal email defined in the whitelist"""
AllowedEmail.objects.create(email=self.email)
is_allowed = AllowedEmail.is_allowed_email(self.email)
self.assertTrue(is_allowed)
def test_email_not_in_whitelist(self):
"""Test for a normal email NOT defined in the whitelist"""
# Check a email not in the list
is_allowed = AllowedEmail.is_allowed_email(self.email_2)
self.assertFalse(AllowedEmail.objects.filter(email=self.email_2).exists())
self.assertFalse(is_allowed)
def test_plus_email_in_whitelist(self):
"""Test for a +1 email defined in the whitelist"""
AllowedEmail.objects.create(email=self.plus_email)
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
self.assertTrue(plus_email_allowed)
def test_plus_email_not_in_whitelist(self):
"""Test for a +1 email not defined in the whitelist"""
# This email should not be allowed.
# Checks that we do more than just a regex check on the record.
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
self.assertFalse(plus_email_allowed)
def test_plus_email_not_in_whitelist_but_base_email_is(self):
"""
Test for a +1 email NOT defined in the whitelist, but the normal one is defined.
Example:
normal (in whitelist) - joe@igorville.com
+1 email (not in whitelist) - joe+1@igorville.com
"""
AllowedEmail.objects.create(email=self.email)
base_email_allowed = AllowedEmail.is_allowed_email(self.email)
self.assertTrue(base_email_allowed)
# The plus email should also be allowed
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
self.assertTrue(plus_email_allowed)
# This email shouldn't exist in the DB
self.assertFalse(AllowedEmail.objects.filter(email=self.plus_email).exists())
def test_plus_email_in_whitelist_but_base_email_is_not(self):
"""
Test for a +1 email defined in the whitelist, but the normal is NOT defined.
Example:
normal (not in whitelist) - joe@igorville.com
+1 email (in whitelist) - joe+1@igorville.com
"""
AllowedEmail.objects.create(email=self.plus_email)
plus_email_allowed = AllowedEmail.is_allowed_email(self.plus_email)
self.assertTrue(plus_email_allowed)
# The base email should also be allowed
base_email_allowed = AllowedEmail.is_allowed_email(self.email)
self.assertTrue(base_email_allowed)
# This email shouldn't exist in the DB
self.assertFalse(AllowedEmail.objects.filter(email=self.email).exists())
def test_invalid_regex_for_plus_email(self):
"""
Test for an invalid email that contains a '+'.
This base email should still pass, but the regex rule should not.
Our regex should only pass for emails that end with a '+'
Example:
Invalid email - 1+joe@igorville.com
Valid email: - joe+1@igorville.com
"""
AllowedEmail.objects.create(email=self.invalid_plus_email)
invalid_plus_email = AllowedEmail.is_allowed_email(self.invalid_plus_email)
# We still expect that this will pass, it exists in the db
self.assertTrue(invalid_plus_email)
# The base email SHOULD NOT pass, as it doesn't match our regex
base_email = AllowedEmail.is_allowed_email(self.email)
self.assertFalse(base_email)
# For good measure, also check the other plus email
regular_plus_email = AllowedEmail.is_allowed_email(self.plus_email)
self.assertFalse(regular_plus_email)

View file

@ -27,6 +27,7 @@ from registrar.models import (
Domain,
DomainInformation,
DomainInvitation,
AllowedEmail,
Contact,
PublicContact,
Host,
@ -346,6 +347,22 @@ class TestDomainDetail(TestDomainOverview):
class TestDomainManagers(TestDomainOverview):
@classmethod
def setUpClass(cls):
super().setUpClass()
allowed_emails = [
AllowedEmail(email=""),
AllowedEmail(email="testy@town.com"),
AllowedEmail(email="mayor@igorville.gov"),
AllowedEmail(email="testy2@town.com"),
]
AllowedEmail.objects.bulk_create(allowed_emails)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
AllowedEmail.objects.all().delete()
def tearDown(self):
"""Ensure that the user has its original permissions"""
super().tearDown()
@ -462,6 +479,7 @@ class TestDomainManagers(TestDomainOverview):
"""Inviting a non-existent user sends them an email."""
# make sure there is no user with this email
email_address = "mayor@igorville.gov"
allowed_email, _ = AllowedEmail.objects.get_or_create(email=email_address)
User.objects.filter(email=email_address).delete()
self.domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user, domain=self.domain)
@ -481,6 +499,7 @@ class TestDomainManagers(TestDomainOverview):
Destination={"ToAddresses": [email_address]},
Content=ANY,
)
allowed_email.delete()
@boto3_mocking.patching
@less_console_noise_decorator
@ -566,6 +585,7 @@ class TestDomainManagers(TestDomainOverview):
"""Inviting a user sends them an email, with email as the name."""
# Create a fake user object
email_address = "mayor@igorville.gov"
AllowedEmail.objects.get_or_create(email=email_address)
User.objects.get_or_create(email=email_address, username="fakeuser@fakeymail.com")
# Make sure the user is staff
@ -1130,7 +1150,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
def test_domain_senior_official(self):
"""Can load domain's senior official page."""
page = self.client.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
self.assertContains(page, "Senior official", count=3)
self.assertContains(page, "Senior official", count=4)
@less_console_noise_decorator
def test_domain_senior_official_content(self):

View file

@ -4,6 +4,7 @@ import boto3
import logging
import textwrap
from datetime import datetime
from django.apps import apps
from django.conf import settings
from django.template.loader import get_template
from email.mime.application import MIMEApplication
@ -27,7 +28,7 @@ def send_templated_email(
to_address: str,
bcc_address="",
context={},
attachment_file: str = None,
attachment_file=None,
wrap_email=False,
):
"""Send an email built from a template to one email address.
@ -39,9 +40,11 @@ def send_templated_email(
Raises EmailSendingError if SES client could not be accessed
"""
if flag_is_active(None, "disable_email_sending") and not settings.IS_PRODUCTION: # type: ignore
message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'."
raise EmailSendingError(message)
if not settings.IS_PRODUCTION: # type: ignore
# Split into a function: C901 'send_templated_email' is too complex.
# Raises an error if we cannot send an email (due to restrictions).
# Does nothing otherwise.
_can_send_email(to_address, bcc_address)
template = get_template(template_name)
email_body = template.render(context=context)
@ -71,7 +74,7 @@ def send_templated_email(
destination["BccAddresses"] = [bcc_address]
try:
if attachment_file is None:
if not attachment_file:
# Wrap the email body to a maximum width of 80 characters per line.
# Not all email clients support CSS to do this, and our .txt files require parsing.
if wrap_email:
@ -102,6 +105,24 @@ def send_templated_email(
raise EmailSendingError("Could not send SES email.") from exc
def _can_send_email(to_address, bcc_address):
"""Raises an EmailSendingError if we cannot send an email. Does nothing otherwise."""
if flag_is_active(None, "disable_email_sending"): # type: ignore
message = "Could not send email. Email sending is disabled due to flag 'disable_email_sending'."
raise EmailSendingError(message)
else:
# Raise an email sending error if these doesn't exist within our whitelist.
# If these emails don't exist, this function can handle that elsewhere.
AllowedEmail = apps.get_model("registrar", "AllowedEmail")
message = "Could not send email. The email '{}' does not exist within the whitelist."
if to_address and not AllowedEmail.is_allowed_email(to_address):
raise EmailSendingError(message.format(to_address))
if bcc_address and not AllowedEmail.is_allowed_email(bcc_address):
raise EmailSendingError(message.format(bcc_address))
def wrap_text_and_preserve_paragraphs(text, width):
"""
Wraps text to `width` preserving newlines; splits on '\n', wraps segments, rejoins with '\n'.