mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-17 18:09:25 +02:00
Merge branch 'main' of https://github.com/cisagov/manage.get.gov into es/2162-delete-submitter-v2
This commit is contained in:
commit
07d8017e99
49 changed files with 989 additions and 158 deletions
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
--- Custom Styles ---------------------------------*/
|
||||
@forward "base";
|
||||
@forward "typography";
|
||||
@forward "links";
|
||||
@forward "lists";
|
||||
@forward "accordions";
|
||||
@forward "buttons";
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
|
25
src/registrar/migrations/0121_allowedemail.py
Normal file
25
src/registrar/migrations/0121_allowedemail.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
37
src/registrar/migrations/0122_create_groups_v16.py
Normal file
37
src/registrar/migrations/0122_create_groups_v16.py
Normal 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,
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
52
src/registrar/models/allowed_email.py
Normal file
52
src/registrar/models/allowed_email.py
Normal 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)
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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>
|
|
@ -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:
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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" %}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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 #}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 aren’t managing any domains.</h2>
|
||||
{% if portfolio_administrators %}
|
||||
<p>If you believe you should have access to a domain, reach out to your organization’s administrators.</p>
|
||||
|
@ -27,4 +28,5 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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'.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue