merge and lint

This commit is contained in:
Rachid Mrad 2024-02-22 20:58:22 -05:00
commit ec311b89cf
No known key found for this signature in database
12 changed files with 749 additions and 132 deletions

View file

@ -1,18 +1,20 @@
from datetime import date
import logging import logging
from django import forms from django import forms
from django.db.models.functions import Concat from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponse from django.db.models import Value, CharField
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions from django_fsm import get_available_FIELD_transitions
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http.response import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from dateutil.relativedelta import relativedelta # type: ignore
from epplibwrapper.errors import ErrorCode, RegistryError from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.domain import Domain from registrar.models import Contact, Domain, DomainApplication, DraftDomain, User, Website
from registrar.models.user import User
from registrar.utility import csv_export from registrar.utility import csv_export
from registrar.views.utility.mixins import OrderableFieldsMixin from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR from django.contrib.admin.views.main import ORDER_VAR
@ -23,6 +25,7 @@ from django_fsm import TransitionNotAllowed # type: ignore
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.html import escape from django.utils.html import escape
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -118,41 +121,52 @@ class CustomLogEntryAdmin(LogEntryAdmin):
class AdminSortFields: class AdminSortFields:
def get_queryset(db_field): _name_sort = ["first_name", "last_name", "email"]
# Define a mapping of field names to model querysets and sort expressions.
# A dictionary is used for specificity, but the downside is some degree of repetition.
# To eliminate this, this list can be generated dynamically but the readability of that
# is impacted.
sort_mapping = {
# == Contact == #
"other_contacts": (Contact, _name_sort),
"authorizing_official": (Contact, _name_sort),
"submitter": (Contact, _name_sort),
# == User == #
"creator": (User, _name_sort),
"user": (User, _name_sort),
"investigator": (User, _name_sort),
# == Website == #
"current_websites": (Website, "website"),
"alternative_domains": (Website, "website"),
# == DraftDomain == #
"requested_domain": (DraftDomain, "name"),
# == DomainApplication == #
"domain_application": (DomainApplication, "requested_domain__name"),
# == Domain == #
"domain": (Domain, "name"),
"approved_domain": (Domain, "name"),
}
@classmethod
def get_queryset(cls, db_field):
"""This is a helper function for formfield_for_manytomany and formfield_for_foreignkey""" """This is a helper function for formfield_for_manytomany and formfield_for_foreignkey"""
# customize sorting queryset_info = cls.sort_mapping.get(db_field.name, None)
if db_field.name in ( if queryset_info is None:
"other_contacts",
"authorizing_official",
"submitter",
):
# Sort contacts by first_name, then last_name, then email
return models.Contact.objects.all().order_by(Concat("first_name", "last_name", "email"))
elif db_field.name in ("current_websites", "alternative_domains"):
# sort web sites
return models.Website.objects.all().order_by("website")
elif db_field.name in (
"creator",
"user",
"investigator",
):
# Sort users by first_name, then last_name, then email
return models.User.objects.all().order_by(Concat("first_name", "last_name", "email"))
elif db_field.name in (
"domain",
"approved_domain",
):
# Sort domains by name
return models.Domain.objects.all().order_by("name")
elif db_field.name in ("requested_domain",):
# Sort draft domains by name
return models.DraftDomain.objects.all().order_by("name")
elif db_field.name in ("domain_application",):
# Sort domain applications by name
return models.DomainApplication.objects.all().order_by("requested_domain__name")
else:
return None return None
# Grab the model we want to order, and grab how we want to order it
model, order_by = queryset_info
match db_field.name:
case "investigator":
# We should only return users who are staff.
return model.objects.filter(is_staff=True).order_by(*order_by)
case _:
if isinstance(order_by, list) or isinstance(order_by, tuple):
return model.objects.order_by(*order_by)
else:
return model.objects.order_by(order_by)
class AuditedAdmin(admin.ModelAdmin): class AuditedAdmin(admin.ModelAdmin):
"""Custom admin to make auditing easier.""" """Custom admin to make auditing easier."""
@ -170,9 +184,14 @@ class AuditedAdmin(admin.ModelAdmin):
def formfield_for_manytomany(self, db_field, request, **kwargs): def formfield_for_manytomany(self, db_field, request, **kwargs):
"""customize the behavior of formfields with manytomany relationships. the customized """customize the behavior of formfields with manytomany relationships. the customized
behavior includes sorting of objects in lists as well as customizing helper text""" behavior includes sorting of objects in lists as well as customizing helper text"""
# Define a queryset. Note that in the super of this,
# a new queryset will only be generated if one does not exist.
# Thus, the order in which we define queryset matters.
queryset = AdminSortFields.get_queryset(db_field) queryset = AdminSortFields.get_queryset(db_field)
if queryset: if queryset:
kwargs["queryset"] = queryset kwargs["queryset"] = queryset
formfield = super().formfield_for_manytomany(db_field, request, **kwargs) formfield = super().formfield_for_manytomany(db_field, request, **kwargs)
# customize the help text for all formfields for manytomany # customize the help text for all formfields for manytomany
formfield.help_text = ( formfield.help_text = (
@ -182,11 +201,16 @@ class AuditedAdmin(admin.ModelAdmin):
return formfield return formfield
def formfield_for_foreignkey(self, db_field, request, **kwargs): def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""customize the behavior of formfields with foreign key relationships. this will customize """Customize the behavior of formfields with foreign key relationships. This will customize
the behavior of selects. customized behavior includes sorting of objects in list""" the behavior of selects. Customized behavior includes sorting of objects in list."""
# Define a queryset. Note that in the super of this,
# a new queryset will only be generated if one does not exist.
# Thus, the order in which we define queryset matters.
queryset = AdminSortFields.get_queryset(db_field) queryset = AdminSortFields.get_queryset(db_field)
if queryset: if queryset:
kwargs["queryset"] = queryset kwargs["queryset"] = queryset
return super().formfield_for_foreignkey(db_field, request, **kwargs) return super().formfield_for_foreignkey(db_field, request, **kwargs)
@ -221,7 +245,6 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
parameter_value: string} parameter_value: string}
TODO: convert investigator id to investigator username TODO: convert investigator id to investigator username
""" """
filters = [] filters = []
# Retrieve the filter parameters # Retrieve the filter parameters
for param in request.GET.keys(): for param in request.GET.keys():
@ -265,6 +288,14 @@ class UserContactInline(admin.StackedInline):
class MyUserAdmin(BaseUserAdmin): class MyUserAdmin(BaseUserAdmin):
"""Custom user admin class to use our inlines.""" """Custom user admin class to use our inlines."""
class Meta:
"""Contains meta information about this class"""
model = models.User
fields = "__all__"
_meta = Meta()
inlines = [UserContactInline] inlines = [UserContactInline]
list_display = ( list_display = (
@ -353,6 +384,42 @@ class MyUserAdmin(BaseUserAdmin):
# in autocomplete_fields for user # in autocomplete_fields for user
ordering = ["first_name", "last_name", "email"] ordering = ["first_name", "last_name", "email"]
def get_search_results(self, request, queryset, search_term):
"""
Override for get_search_results. This affects any upstream model using autocomplete_fields,
such as DomainApplication. This is because autocomplete_fields uses an API call to fetch data,
and this fetch comes from this method.
"""
# Custom filtering logic
queryset, use_distinct = super().get_search_results(request, queryset, search_term)
# If we aren't given a request to modify, we shouldn't try to
if request is None or not hasattr(request, "GET"):
return queryset, use_distinct
# Otherwise, lets modify it!
request_get = request.GET
# The request defines model name and field name.
# For instance, model_name could be "DomainApplication"
# and field_name could be "investigator".
model_name = request_get.get("model_name", None)
field_name = request_get.get("field_name", None)
# Make sure we're only modifying requests from these models.
models_to_target = {"domainapplication"}
if model_name in models_to_target:
# Define rules per field
match field_name:
case "investigator":
# We should not display investigators who don't have a staff role
queryset = queryset.filter(is_staff=True)
case _:
# In the default case, do nothing
pass
return queryset, use_distinct
# Let's define First group # Let's define First group
# (which should in theory be the ONLY group) # (which should in theory be the ONLY group)
def group(self, obj): def group(self, obj):
@ -417,6 +484,9 @@ class ContactAdmin(ListHeaderAdmin):
"contact", "contact",
"email", "email",
] ]
# this ordering effects the ordering of results
# in autocomplete_fields for user
ordering = ["first_name", "last_name", "email"]
# We name the custom prop 'contact' because linter # We name the custom prop 'contact' because linter
# is not allowing a short_description attr on it # is not allowing a short_description attr on it
@ -752,8 +822,23 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"""Lookup reimplementation, gets users of is_staff. """Lookup reimplementation, gets users of is_staff.
Returns a list of tuples consisting of (user.id, user) Returns a list of tuples consisting of (user.id, user)
""" """
privileged_users = User.objects.filter(is_staff=True).order_by("first_name", "last_name", "email") # Select all investigators that are staff, then order by name and email
return [(user.id, user) for user in privileged_users] privileged_users = (
DomainApplication.objects.select_related("investigator")
.filter(investigator__is_staff=True)
.order_by("investigator__first_name", "investigator__last_name", "investigator__email")
)
# Annotate the full name and return a values list that lookups can use
privileged_users_annotated = privileged_users.annotate(
full_name=Coalesce(
Concat("investigator__first_name", Value(" "), "investigator__last_name", output_field=CharField()),
"investigator__email",
output_field=CharField(),
)
).values_list("investigator__id", "full_name")
return privileged_users_annotated
def queryset(self, request, queryset): def queryset(self, request, queryset):
"""Custom queryset implementation, filters by investigator""" """Custom queryset implementation, filters by investigator"""
@ -853,26 +938,19 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"anything_else", "anything_else",
"is_policy_acknowledged", "is_policy_acknowledged",
] ]
autocomplete_fields = [
"approved_domain",
"requested_domain",
"submitter",
"creator",
"authorizing_official",
"investigator",
]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
# Table ordering # Table ordering
ordering = ["requested_domain__name"] ordering = ["requested_domain__name"]
# lists in filter_horizontal are not sorted properly, sort them
# by website
def formfield_for_manytomany(self, db_field, request, **kwargs):
if db_field.name in ("current_websites", "alternative_domains"):
kwargs["queryset"] = models.Website.objects.all().order_by("website") # Sort websites
return super().formfield_for_manytomany(db_field, request, **kwargs)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
# Removes invalid investigator options from the investigator dropdown
if db_field.name == "investigator":
kwargs["queryset"] = User.objects.filter(is_staff=True)
return db_field.formfield(**kwargs)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
# Trigger action when a fieldset is changed # Trigger action when a fieldset is changed
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
if obj and obj.creator.status != models.User.RESTRICTED: if obj and obj.creator.status != models.User.RESTRICTED:
@ -940,7 +1018,6 @@ class DomainApplicationAdmin(ListHeaderAdmin):
admin user permissions and the application creator's status, so admin user permissions and the application creator's status, so
we'll use the baseline readonly_fields and extend it as needed. we'll use the baseline readonly_fields and extend it as needed.
""" """
readonly_fields = list(self.readonly_fields) readonly_fields = list(self.readonly_fields)
# Check if the creator is restricted # Check if the creator is restricted
@ -1018,8 +1095,8 @@ class DomainInformationInline(admin.StackedInline):
return formfield return formfield
def formfield_for_foreignkey(self, db_field, request, **kwargs): def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""customize the behavior of formfields with foreign key relationships. this will customize """Customize the behavior of formfields with foreign key relationships. This will customize
the behavior of selects. customized behavior includes sorting of objects in list""" the behavior of selects. Customized behavior includes sorting of objects in list."""
queryset = AdminSortFields.get_queryset(db_field) queryset = AdminSortFields.get_queryset(db_field)
if queryset: if queryset:
kwargs["queryset"] = queryset kwargs["queryset"] = queryset
@ -1073,6 +1150,26 @@ class DomainAdmin(ListHeaderAdmin):
# Table ordering # Table ordering
ordering = ["name"] ordering = ["name"]
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
"""Custom changeform implementation to pass in context information"""
if extra_context is None:
extra_context = {}
# Pass in what the an extended expiration date would be for the expiration date modal
if object_id is not None:
domain = Domain.objects.get(pk=object_id)
years_to_extend_by = self._get_calculated_years_for_exp_date(domain)
curr_exp_date = domain.registry_expiration_date
if curr_exp_date < date.today():
extra_context["extended_expiration_date"] = date.today() + relativedelta(years=years_to_extend_by)
else:
new_date = domain.registry_expiration_date + relativedelta(years=years_to_extend_by)
extra_context["extended_expiration_date"] = new_date
else:
extra_context["extended_expiration_date"] = None
return super().changeform_view(request, object_id, form_url, extra_context)
def export_data_type(self, request): def export_data_type(self, request):
# match the CSV example with all the fields # match the CSV example with all the fields
response = HttpResponse(content_type="text/csv") response = HttpResponse(content_type="text/csv")
@ -1131,6 +1228,7 @@ class DomainAdmin(ListHeaderAdmin):
"_edit_domain": self.do_edit_domain, "_edit_domain": self.do_edit_domain,
"_delete_domain": self.do_delete_domain, "_delete_domain": self.do_delete_domain,
"_get_status": self.do_get_status, "_get_status": self.do_get_status,
"_extend_expiration_date": self.do_extend_expiration_date,
} }
# Check which action button was pressed and call the corresponding function # Check which action button was pressed and call the corresponding function
@ -1141,6 +1239,81 @@ class DomainAdmin(ListHeaderAdmin):
# If no matching action button is found, return the super method # If no matching action button is found, return the super method
return super().response_change(request, obj) return super().response_change(request, obj)
def do_extend_expiration_date(self, request, obj):
"""Extends a domains expiration date by one year from the current date"""
# Make sure we're dealing with a Domain
if not isinstance(obj, Domain):
self.message_user(request, "Object is not of type Domain.", messages.ERROR)
return None
years = self._get_calculated_years_for_exp_date(obj)
# Renew the domain.
try:
obj.renew_domain(length=years)
self.message_user(
request,
"Successfully extended the expiration date.",
)
except RegistryError as err:
if err.is_connection_error():
error_message = "Error connecting to the registry."
else:
error_message = f"Error extending this domain: {err}."
self.message_user(request, error_message, messages.ERROR)
except KeyError:
# In normal code flow, a keyerror can only occur when
# fresh data can't be pulled from the registry, and thus there is no cache.
self.message_user(
request,
"Error connecting to the registry. No expiration date was found.",
messages.ERROR,
)
except Exception as err:
logger.error(err, stack_info=True)
self.message_user(request, "Could not delete: An unspecified error occured", messages.ERROR)
return HttpResponseRedirect(".")
def _get_calculated_years_for_exp_date(self, obj, extension_period: int = 1):
"""Given the current date, an extension period, and a registry_expiration_date
on the domain object, calculate the number of years needed to extend the
current expiration date by the extension period.
"""
# Get the date we want to update to
desired_date = self._get_current_date() + relativedelta(years=extension_period)
# Grab the current expiration date
try:
exp_date = obj.registry_expiration_date
except KeyError:
# if no expiration date from registry, set it to today
logger.warning("current expiration date not set; setting to today")
exp_date = self._get_current_date()
# If the expiration date is super old (2020, for example), we need to
# "catch up" to the current year, so we add the difference.
# If both years match, then lets just proceed as normal.
calculated_exp_date = exp_date + relativedelta(years=extension_period)
year_difference = desired_date.year - exp_date.year
years = extension_period
if desired_date > calculated_exp_date:
# Max probably isn't needed here (no code flow), but it guards against negative and 0.
# In both of those cases, we just want to extend by the extension_period.
years = max(extension_period, year_difference)
return years
# Workaround for unit tests, as we cannot mock date directly.
# it is immutable. Rather than dealing with a convoluted workaround,
# lets wrap this in a function.
def _get_current_date(self):
"""Gets the current date"""
return date.today()
def do_delete_domain(self, request, obj): def do_delete_domain(self, request, obj):
if not isinstance(obj, Domain): if not isinstance(obj, Domain):
# Could be problematic if the type is similar, # Could be problematic if the type is similar,
@ -1299,6 +1472,10 @@ class DraftDomainAdmin(ListHeaderAdmin):
search_fields = ["name"] search_fields = ["name"]
search_help_text = "Search by draft domain name." search_help_text = "Search by draft domain name."
# this ordering effects the ordering of results
# in autocomplete_fields for user
ordering = ["name"]
class VerifiedByStaffAdmin(ListHeaderAdmin): class VerifiedByStaffAdmin(ListHeaderAdmin):
list_display = ("email", "requestor", "truncated_notes", "created_at") list_display = ("email", "requestor", "truncated_notes", "created_at")

View file

@ -23,6 +23,33 @@ function openInNewTab(el, removeAttribute = false){
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Initialization code. // Initialization code.
/** An IIFE for pages in DjangoAdmin that use modals.
* Dja strips out form elements, and modals generate their content outside
* of the current form scope, so we need to "inject" these inputs.
*/
(function (){
function createPhantomModalFormButtons(){
let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"]');
form = document.querySelector("form")
submitButtons.forEach((button) => {
let input = document.createElement("input");
input.type = "submit";
input.name = button.name;
input.value = button.value;
input.style.display = "none"
// Add the hidden input to the form
form.appendChild(input);
button.addEventListener("click", () => {
console.log("clicking")
input.click();
})
})
}
createPhantomModalFormButtons();
})();
/** An IIFE for pages in DjangoAdmin which may need custom JS implementation. /** An IIFE for pages in DjangoAdmin which may need custom JS implementation.
* Currently only appends target="_blank" to the domain_form object, * Currently only appends target="_blank" to the domain_form object,
* but this can be expanded. * but this can be expanded.
@ -41,8 +68,8 @@ function openInNewTab(el, removeAttribute = false){
let domainFormElement = document.getElementById("domain_form"); let domainFormElement = document.getElementById("domain_form");
let domainSubmitButton = document.getElementById("manageDomainSubmitButton"); let domainSubmitButton = document.getElementById("manageDomainSubmitButton");
if(domainSubmitButton && domainFormElement){ if(domainSubmitButton && domainFormElement){
domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true)); domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true));
domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false)); domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false));
} }
} }

View file

@ -183,6 +183,10 @@ h1, h2, h3,
.submit-row div.spacer { .submit-row div.spacer {
flex-grow: 1; flex-grow: 1;
} }
.submit-row .mini-spacer{
margin-left: 2px;
margin-right: 2px;
}
.submit-row span { .submit-row span {
margin-top: units(1); margin-top: units(1);
} }
@ -270,3 +274,31 @@ h1, h2, h3,
margin: 0!important; margin: 0!important;
} }
} }
// Hides the "clear" button on autocomplete, as we already have one to use
.select2-selection__clear {
display: none;
}
// Fixes a display issue where the list was entirely white, or had too much whitespace
.select2-dropdown {
display: inline-grid !important;
}
input.admin-confirm-button {
text-transform: none;
}
// Button groups in /admin incorrectly have bullets.
// Remove that!
.usa-modal__footer .usa-button-group__item {
list-style-type: none;
}
// USWDS media checks are overzealous in this situation,
// we should manually define this behaviour.
@media (max-width: 768px) {
.button-list-mobile {
display: contents !important;
}
}

View file

@ -265,15 +265,17 @@ class Domain(TimeStampedModel, DomainHelper):
Default length and unit of time are 1 year. Default length and unit of time are 1 year.
""" """
# if no expiration date from registry, set to today
# If no date is specified, grab the registry_expiration_date
try: try:
cur_exp_date = self.registry_expiration_date exp_date = self.registry_expiration_date
except KeyError: except KeyError:
# if no expiration date from registry, set it to today
logger.warning("current expiration date not set; setting to today") logger.warning("current expiration date not set; setting to today")
cur_exp_date = date.today() exp_date = date.today()
# create RenewDomain request # create RenewDomain request
request = commands.RenewDomain(name=self.name, cur_exp_date=cur_exp_date, period=epp.Period(length, unit)) request = commands.RenewDomain(name=self.name, cur_exp_date=exp_date, period=epp.Period(length, unit))
try: try:
# update expiration date in registry, and set the updated # update expiration date in registry, and set the updated

View file

@ -589,7 +589,9 @@ class DomainApplication(TimeStampedModel):
logger.error(err) logger.error(err)
logger.error(f"Can't query an approved domain while attempting {called_from}") logger.error(f"Can't query an approved domain while attempting {called_from}")
def _send_status_update_email(self, new_status, email_template, email_template_subject, send_email=True, bcc_address=''): def _send_status_update_email(
self, new_status, email_template, email_template_subject, send_email=True, bcc_address=""
):
"""Send a status update email to the submitter. """Send a status update email to the submitter.
The email goes to the email address that the submitter gave as their The email goes to the email address that the submitter gave as their
@ -656,7 +658,7 @@ class DomainApplication(TimeStampedModel):
# Limit email notifications to transitions from Started and Withdrawn # Limit email notifications to transitions from Started and Withdrawn
limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN] limited_statuses = [self.ApplicationStatus.STARTED, self.ApplicationStatus.WITHDRAWN]
bcc_address = '' bcc_address = ""
if settings.IS_PRODUCTION: if settings.IS_PRODUCTION:
bcc_address = settings.DEFAULT_FROM_EMAIL bcc_address = settings.DEFAULT_FROM_EMAIL

View file

@ -0,0 +1,37 @@
"""This file contains general purpose helpers that don't belong in any specific location"""
import time
import logging
logger = logging.getLogger(__name__)
class Timer:
"""
This class is used to measure execution time for performance profiling.
__enter__ and __exit__ is used such that you can wrap any code you want
around a with statement. After this exits, logger.info will print
the execution time in seconds.
Note that this class does not account for general randomness as more
robust libraries do, so there is some tiny amount of latency involved
in using this, but it is minimal enough that for most applications it is not
noticable.
Usage:
with Timer():
...some code
"""
def __enter__(self):
"""Starts the timer"""
self.start = time.time()
# This allows usage of the instance within the with block
return self
def __exit__(self, *args):
"""Ends the timer and logs what happened"""
self.end = time.time()
self.duration = self.end - self.start
logger.info(f"Execution time: {self.duration} seconds")

View file

@ -18,11 +18,12 @@
<link rel="apple-touch-icon" size="180x180" <link rel="apple-touch-icon" size="180x180"
href="{% static 'img/registrar/favicons/favicon-180.png' %}" href="{% static 'img/registrar/favicons/favicon-180.png' %}"
> >
<script src="{% static 'js/uswds-init.min.js' %}" defer></script>
<script src="{% static 'js/uswds.min.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script> <script type="application/javascript" src="{% static 'js/get-gov-admin.js' %}" defer></script>
{% endblock %} {% endblock %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
{% block extrastyle %}{{ block.super }} {% block extrastyle %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static 'css/styles.css' %}" /> <link rel="stylesheet" type="text/css" href="{% static 'css/styles.css' %}" />
{% endblock %} {% endblock %}

View file

@ -2,21 +2,117 @@
{% load i18n static %} {% load i18n static %}
{% block field_sets %} {% block field_sets %}
<div class="submit-row"> <div class="display-flex flex-row flex-justify submit-row">
<input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain"> <div class="flex-align-self-start button-list-mobile">
<input type="submit" value="Get registry status" name="_get_status"> <input id="manageDomainSubmitButton" type="submit" value="Manage domain" name="_edit_domain">
<div class="spacer"></div> {# Dja has margin styles defined on inputs as is. Lets work with it, rather than fight it. #}
{% if original.state == original.State.READY %} <span class="mini-spacer"></span>
<input type="submit" value="Place hold" name="_place_client_hold" class="custom-link-button"> <input type="submit" value="Get registry status" name="_get_status">
{% elif original.state == original.State.ON_HOLD %} </div>
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button"> <div class="desktop:flex-align-self-end">
{% endif %} {% if original.state != original.State.DELETED %}
{% if original.state == original.State.READY or original.state == original.State.ON_HOLD %} <a
<span> | </span> class="text-middle"
{% endif %} href="#toggle-extend-expiration-alert"
{% if original.state != original.State.DELETED %} aria-controls="toggle-extend-expiration-alert"
<input type="submit" value="Remove from registry" name="_delete_domain" class="custom-link-button"> data-open-modal
>
Extend expiration date
</a>
<span class="margin-left-05 margin-right-05 text-middle"> | </span>
{% endif %}
{% if original.state == original.State.READY %}
<input type="submit" value="Place hold" name="_place_client_hold" class="custom-link-button">
{% elif original.state == original.State.ON_HOLD %}
<input type="submit" value="Remove hold" name="_remove_client_hold" class="custom-link-button">
{% endif %}
{% if original.state == original.State.READY or original.state == original.State.ON_HOLD %}
<span class="margin-left-05 margin-right-05 text-middle"> | </span>
{% endif %}
{% if original.state != original.State.DELETED %}
<input type="submit" value="Remove from registry" name="_delete_domain" class="custom-link-button">
{% endif %} {% endif %}
</div>
</div> </div>
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock %}
{% block submit_buttons_bottom %}
{% comment %}
Modals behave very weirdly in django admin.
They tend to "strip out" any injected form elements, leaving only the main form.
In addition, USWDS handles modals by first destroying the element, then repopulating it toward the end of the page.
In effect, this means that the modal is not, and cannot, be surrounded by any form element at compile time.
The current workaround for this is to use javascript to inject a hidden input, and bind submit of that
element to the click of the confirmation button within this modal.
This is controlled by the class `dja-form-placeholder` on the button.
In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions
of the application, so this means that it will briefly "populate", causing unintended visual effects.
{% endcomment %}
<div
class="usa-modal"
id="toggle-extend-expiration-alert"
aria-labelledby="Are you sure you want to extend the expiration date?"
aria-describedby="This expiration date will be extended."
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to extend the expiration date?
</h2>
<div class="usa-prose">
<p>
This will extend the expiration date by one year.
{# Acts as a <br> #}
<div class="display-inline"></div>
This action cannot be undone.
</p>
<p>
Domain: <b>{{ original.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New expiration date: <b>{{ extended_expiration_date }}</b>
{{test}}
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button dja-form-placeholder"
name="_extend_expiration_date"
>
Yes, extend date
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -231,6 +231,7 @@ class AuditedAdminMockData:
first_name="{} first_name:{}".format(item_name, short_hand), first_name="{} first_name:{}".format(item_name, short_hand),
last_name="{} last_name:{}".format(item_name, short_hand), last_name="{} last_name:{}".format(item_name, short_hand),
username="{} username:{}".format(item_name + str(uuid.uuid4())[:8], short_hand), username="{} username:{}".format(item_name + str(uuid.uuid4())[:8], short_hand),
is_staff=True,
)[0] )[0]
return user return user
@ -921,6 +922,11 @@ class MockEppLib(TestCase):
ex_date=datetime.date(2023, 5, 25), ex_date=datetime.date(2023, 5, 25),
) )
mockButtonRenewedDomainExpDate = fakedEppObject(
"fake.gov",
ex_date=datetime.date(2025, 5, 25),
)
mockDnsNeededRenewedDomainExpDate = fakedEppObject( mockDnsNeededRenewedDomainExpDate = fakedEppObject(
"fakeneeded.gov", "fakeneeded.gov",
ex_date=datetime.date(2023, 2, 15), ex_date=datetime.date(2023, 2, 15),
@ -1049,11 +1055,19 @@ class MockEppLib(TestCase):
res_data=[self.mockMaximumRenewedDomainExpDate], res_data=[self.mockMaximumRenewedDomainExpDate],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
) )
else: elif getattr(_request, "name", None) == "fake.gov":
return MagicMock( period = getattr(_request, "period", None)
res_data=[self.mockRenewedDomainExpDate], extension_period = getattr(period, "length", None)
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY, if extension_period == 2:
) return MagicMock(
res_data=[self.mockButtonRenewedDomainExpDate],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
else:
return MagicMock(
res_data=[self.mockRenewedDomainExpDate],
code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
)
def mockInfoDomainCommands(self, _request, cleaned): def mockInfoDomainCommands(self, _request, cleaned):
request_name = getattr(_request, "name", None) request_name = getattr(_request, "name", None)

View file

@ -1,6 +1,8 @@
from datetime import date
from django.test import TestCase, RequestFactory, Client, override_settings from django.test import TestCase, RequestFactory, Client, override_settings
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from contextlib import ExitStack from contextlib import ExitStack
from django_webtest import WebTest # type: ignore
from django.contrib import messages from django.contrib import messages
from django.urls import reverse from django.urls import reverse
from registrar.admin import ( from registrar.admin import (
@ -35,7 +37,7 @@ from .common import (
) )
from django.contrib.sessions.backends.db import SessionStore from django.contrib.sessions.backends.db import SessionStore
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from unittest.mock import patch from unittest.mock import ANY, call, patch
from unittest import skip from unittest import skip
from django.conf import settings from django.conf import settings
@ -45,7 +47,11 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TestDomainAdmin(MockEppLib): class TestDomainAdmin(MockEppLib, WebTest):
# csrf checks do not work with WebTest.
# We disable them here. TODO for another ticket.
csrf_checks = False
def setUp(self): def setUp(self):
self.site = AdminSite() self.site = AdminSite()
self.admin = DomainAdmin(model=Domain, admin_site=self.site) self.admin = DomainAdmin(model=Domain, admin_site=self.site)
@ -53,8 +59,177 @@ class TestDomainAdmin(MockEppLib):
self.superuser = create_superuser() self.superuser = create_superuser()
self.staffuser = create_user() self.staffuser = create_user()
self.factory = RequestFactory() self.factory = RequestFactory()
self.app.set_user(self.superuser.username)
self.client.force_login(self.superuser)
super().setUp() super().setUp()
@skip("TODO for another ticket. This test case is grabbing old db data.")
@patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1))
def test_extend_expiration_date_button(self, mock_date_today):
"""
Tests if extend_expiration_date button extends correctly
"""
# Create a ready domain with a preset expiration date
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
# Make sure the ex date is what we expect it to be
domain_ex_date = Domain.objects.get(id=domain.id).expiration_date
self.assertEqual(domain_ex_date, date(2023, 5, 25))
# Make sure that the page is loading as expected
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Grab the form to submit
form = response.forms["domain_form"]
with patch("django.contrib.messages.add_message") as mock_add_message:
# Submit the form
response = form.submit("_extend_expiration_date")
# Follow the response
response = response.follow()
# refresh_from_db() does not work for objects with protected=True.
# https://github.com/viewflow/django-fsm/issues/89
new_domain = Domain.objects.get(id=domain.id)
# Check that the current expiration date is what we expect
self.assertEqual(new_domain.expiration_date, date(2025, 5, 25))
# Assert that everything on the page looks correct
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Ensure the message we recieve is in line with what we expect
expected_message = "Successfully extended the expiration date."
expected_call = call(
# The WGSI request doesn't need to be tested
ANY,
messages.INFO,
expected_message,
extra_tags="",
fail_silently=False,
)
mock_add_message.assert_has_calls([expected_call], 1)
@patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2024, 1, 1))
def test_extend_expiration_date_button_epp(self, mock_date_today):
"""
Tests if extend_expiration_date button sends the right epp command
"""
# Create a ready domain with a preset expiration date
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
# Make sure that the page is loading as expected
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Grab the form to submit
form = response.forms["domain_form"]
with patch("django.contrib.messages.add_message") as mock_add_message:
with patch("registrar.models.Domain.renew_domain") as renew_mock:
# Submit the form
response = form.submit("_extend_expiration_date")
# Follow the response
response = response.follow()
# This value is based off of the current year - the expiration date.
# We "freeze" time to 2024, so 2024 - 2023 will always result in an
# "extension" of 2, as that will be one year of extension from that date.
extension_length = 2
# Assert that it is calling the function with the right extension length.
# We only need to test the value that EPP sends, as we can assume the other
# test cases cover the "renew" function.
renew_mock.assert_has_calls([call(length=extension_length)], any_order=False)
# We should not make duplicate calls
self.assertEqual(renew_mock.call_count, 1)
# Assert that everything on the page looks correct
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Ensure the message we recieve is in line with what we expect
expected_message = "Successfully extended the expiration date."
expected_call = call(
# The WGSI request doesn't need to be tested
ANY,
messages.INFO,
expected_message,
extra_tags="",
fail_silently=False,
)
mock_add_message.assert_has_calls([expected_call], 1)
@patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2023, 1, 1))
def test_extend_expiration_date_button_date_matches_epp(self, mock_date_today):
"""
Tests if extend_expiration_date button sends the right epp command
when the current year matches the expiration date
"""
# Create a ready domain with a preset expiration date
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
# Make sure that the page is loading as expected
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Grab the form to submit
form = response.forms["domain_form"]
with patch("django.contrib.messages.add_message") as mock_add_message:
with patch("registrar.models.Domain.renew_domain") as renew_mock:
# Submit the form
response = form.submit("_extend_expiration_date")
# Follow the response
response = response.follow()
extension_length = 1
# Assert that it is calling the function with the right extension length.
# We only need to test the value that EPP sends, as we can assume the other
# test cases cover the "renew" function.
renew_mock.assert_has_calls([call(length=extension_length)], any_order=False)
# We should not make duplicate calls
self.assertEqual(renew_mock.call_count, 1)
# Assert that everything on the page looks correct
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Ensure the message we recieve is in line with what we expect
expected_message = "Successfully extended the expiration date."
expected_call = call(
# The WGSI request doesn't need to be tested
ANY,
messages.INFO,
expected_message,
extra_tags="",
fail_silently=False,
)
mock_add_message.assert_has_calls([expected_call], 1)
def test_short_org_name_in_domains_list(self): def test_short_org_name_in_domains_list(self):
""" """
Make sure the short name is displaying in admin on the list page Make sure the short name is displaying in admin on the list page
@ -426,7 +601,9 @@ class TestDomainApplicationAdmin(MockEppLib):
# Use the model admin's save_model method # Use the model admin's save_model method
self.admin.save_model(request, application, form=None, change=True) self.admin.save_model(request, application, form=None, change=True)
def assert_email_is_accurate(self, expected_string, email_index, email_address, test_that_no_bcc=False, bcc_email_address=''): def assert_email_is_accurate(
self, expected_string, email_index, email_address, test_that_no_bcc=False, bcc_email_address=""
):
"""Helper method for the email test cases. """Helper method for the email test cases.
email_index is the index of the email in mock_client.""" email_index is the index of the email in mock_client."""
@ -446,11 +623,11 @@ class TestDomainApplicationAdmin(MockEppLib):
self.assertIn(expected_string, email_body) self.assertIn(expected_string, email_body)
if test_that_no_bcc: if test_that_no_bcc:
_ = '' _ = ""
with self.assertRaises(KeyError): with self.assertRaises(KeyError):
with less_console_noise(): with less_console_noise():
_ = kwargs["Destination"]["BccAddresses"][0] _ = kwargs["Destination"]["BccAddresses"][0]
self.assertEqual(_, '') self.assertEqual(_, "")
if bcc_email_address: if bcc_email_address:
bcc_email = kwargs["Destination"]["BccAddresses"][0] bcc_email = kwargs["Destination"]["BccAddresses"][0]
@ -462,7 +639,7 @@ class TestDomainApplicationAdmin(MockEppLib):
When transitioning to submitted from dns needed or in review on a domain request, When transitioning to submitted from dns needed or in review on a domain request,
no email is sent out. no email is sent out.
Also test that the default email set in settings is NOT BCCd on non-prod whenever Also test that the default email set in settings is NOT BCCd on non-prod whenever
an email does go out.""" an email does go out."""
@ -516,7 +693,7 @@ class TestDomainApplicationAdmin(MockEppLib):
When transitioning to submitted from dns needed or in review on a domain request, When transitioning to submitted from dns needed or in review on a domain request,
no email is sent out. no email is sent out.
Also test that the default email set in settings IS BCCd on prod whenever Also test that the default email set in settings IS BCCd on prod whenever
an email does go out.""" an email does go out."""
@ -1012,8 +1189,17 @@ class TestDomainApplicationAdmin(MockEppLib):
investigator_field = DomainApplication._meta.get_field("investigator") investigator_field = DomainApplication._meta.get_field("investigator")
# We should only be displaying staff users, in alphabetical order # We should only be displaying staff users, in alphabetical order
expected_dropdown = list(User.objects.filter(is_staff=True)) sorted_fields = ["first_name", "last_name", "email"]
current_dropdown = list(self.admin.formfield_for_foreignkey(investigator_field, request).queryset) expected_dropdown = list(User.objects.filter(is_staff=True).order_by(*sorted_fields))
# Grab the current dropdown. We do an API call to autocomplete to get this info.
application_queryset = self.admin.formfield_for_foreignkey(investigator_field, request).queryset
user_request = self.factory.post(
"/admin/autocomplete/?app_label=registrar&model_name=domainapplication&field_name=investigator"
)
user_admin = MyUserAdmin(User, self.site)
user_queryset = user_admin.get_search_results(user_request, application_queryset, None)[0]
current_dropdown = list(user_queryset)
self.assertEqual(expected_dropdown, current_dropdown) self.assertEqual(expected_dropdown, current_dropdown)
@ -1047,7 +1233,13 @@ class TestDomainApplicationAdmin(MockEppLib):
self.client.login(username="staffuser", password=p) self.client.login(username="staffuser", password=p)
request = RequestFactory().get("/") request = RequestFactory().get("/")
expected_list = list(User.objects.filter(is_staff=True).order_by("first_name", "last_name", "email")) # These names have metadata embedded in them. :investigator implicitly tests if
# these are actually from the attribute "investigator".
expected_list = [
"AGuy AGuy last_name:investigator",
"FinalGuy FinalGuy last_name:investigator",
"SomeGuy first_name:investigator SomeGuy last_name:investigator",
]
# Get the actual sorted list of investigators from the lookups method # Get the actual sorted list of investigators from the lookups method
actual_list = [item for _, item in self.admin.InvestigatorFilter.lookups(self, request, self.admin)] actual_list = [item for _, item in self.admin.InvestigatorFilter.lookups(self, request, self.admin)]
@ -1467,11 +1659,46 @@ class AuditedAdminTest(TestCase):
return ordered_list return ordered_list
def test_alphabetically_sorted_domain_application_investigator(self):
"""Tests if the investigator field is alphabetically sorted by mimicking
the call event flow"""
# Creates multiple domain applications - review status does not matter
applications = multiple_unalphabetical_domain_objects("application")
# Create a mock request
application_request = self.factory.post(
"/admin/registrar/domainapplication/{}/change/".format(applications[0].pk)
)
# Get the formfield data from the application page
application_admin = AuditedAdmin(DomainApplication, self.site)
field = DomainApplication.investigator.field
application_queryset = application_admin.formfield_for_foreignkey(field, application_request).queryset
request = self.factory.post(
"/admin/autocomplete/?app_label=registrar&model_name=domainapplication&field_name=investigator"
)
sorted_fields = ["first_name", "last_name", "email"]
desired_sort_order = list(User.objects.filter(is_staff=True).order_by(*sorted_fields))
# Grab the data returned from get search results
admin = MyUserAdmin(User, self.site)
search_queryset = admin.get_search_results(request, application_queryset, None)[0]
current_sort_order = list(search_queryset)
self.assertEqual(
desired_sort_order,
current_sort_order,
"Investigator is not ordered alphabetically",
)
# This test case should be refactored in general, as it is too overly specific and engineered
def test_alphabetically_sorted_fk_fields_domain_application(self): def test_alphabetically_sorted_fk_fields_domain_application(self):
tested_fields = [ tested_fields = [
DomainApplication.authorizing_official.field, DomainApplication.authorizing_official.field,
DomainApplication.submitter.field, DomainApplication.submitter.field,
DomainApplication.investigator.field, # DomainApplication.investigator.field,
DomainApplication.creator.field, DomainApplication.creator.field,
DomainApplication.requested_domain.field, DomainApplication.requested_domain.field,
] ]
@ -1489,39 +1716,40 @@ class AuditedAdminTest(TestCase):
# but both fields are of a fixed length. # but both fields are of a fixed length.
# For test case purposes, this should be performant. # For test case purposes, this should be performant.
for field in tested_fields: for field in tested_fields:
isNamefield: bool = field == DomainApplication.requested_domain.field with self.subTest(field=field):
if isNamefield: isNamefield: bool = field == DomainApplication.requested_domain.field
sorted_fields = ["name"] if isNamefield:
else: sorted_fields = ["name"]
sorted_fields = ["first_name", "last_name"]
# We want both of these to be lists, as it is richer test wise.
desired_order = self.order_by_desired_field_helper(model_admin, request, field.name, *sorted_fields)
current_sort_order = list(model_admin.formfield_for_foreignkey(field, request).queryset)
# Conforms to the same object structure as desired_order
current_sort_order_coerced_type = []
# This is necessary as .queryset and get_queryset
# return lists of different types/structures.
# We need to parse this data and coerce them into the same type.
for contact in current_sort_order:
if not isNamefield:
first = contact.first_name
last = contact.last_name
else: else:
first = contact.name sorted_fields = ["first_name", "last_name"]
last = None # We want both of these to be lists, as it is richer test wise.
name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":") desired_order = self.order_by_desired_field_helper(model_admin, request, field.name, *sorted_fields)
if name_tuple is not None: current_sort_order = list(model_admin.formfield_for_foreignkey(field, request).queryset)
current_sort_order_coerced_type.append(name_tuple)
self.assertEqual( # Conforms to the same object structure as desired_order
desired_order, current_sort_order_coerced_type = []
current_sort_order_coerced_type,
"{} is not ordered alphabetically".format(field.name), # This is necessary as .queryset and get_queryset
) # return lists of different types/structures.
# We need to parse this data and coerce them into the same type.
for contact in current_sort_order:
if not isNamefield:
first = contact.first_name
last = contact.last_name
else:
first = contact.name
last = None
name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":")
if name_tuple is not None:
current_sort_order_coerced_type.append(name_tuple)
self.assertEqual(
desired_order,
current_sort_order_coerced_type,
"{} is not ordered alphabetically".format(field.name),
)
def test_alphabetically_sorted_fk_fields_domain_information(self): def test_alphabetically_sorted_fk_fields_domain_information(self):
tested_fields = [ tested_fields = [

View file

@ -51,6 +51,7 @@ class TestWithUser(MockEppLib):
DomainInformation.objects.all().delete() DomainInformation.objects.all().delete()
self.user.delete() self.user.delete()
class TestEnvironmentVariablesEffects(TestCase): class TestEnvironmentVariablesEffects(TestCase):
def setUp(self): def setUp(self):
self.client = Client() self.client = Client()
@ -77,4 +78,4 @@ class TestEnvironmentVariablesEffects(TestCase):
def test_non_production_environment(self): def test_non_production_environment(self):
"""Banner on non-prod.""" """Banner on non-prod."""
home_page = self.client.get("/") home_page = self.client.get("/")
self.assertContains(home_page, "You are on a test site.") self.assertContains(home_page, "You are on a test site.")

View file

@ -15,7 +15,7 @@ class EmailSendingError(RuntimeError):
pass pass
def send_templated_email(template_name: str, subject_template_name: str, to_address: str, bcc_address='', context={}): def send_templated_email(template_name: str, subject_template_name: str, to_address: str, bcc_address="", context={}):
"""Send an email built from a template to one email address. """Send an email built from a template to one email address.
template_name and subject_template_name are relative to the same template template_name and subject_template_name are relative to the same template
@ -39,7 +39,7 @@ def send_templated_email(template_name: str, subject_template_name: str, to_addr
) )
except Exception as exc: except Exception as exc:
raise EmailSendingError("Could not access the SES client.") from exc raise EmailSendingError("Could not access the SES client.") from exc
destination = {"ToAddresses": [to_address]} destination = {"ToAddresses": [to_address]}
if bcc_address: if bcc_address:
destination["BccAddresses"] = [bcc_address] destination["BccAddresses"] = [bcc_address]