diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 1b13d8e74..76fad8975 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1,18 +1,20 @@
+from datetime import date
import logging
+
from django import forms
-from django.db.models.functions import Concat
-from django.http import HttpResponse
+from django.db.models.functions import Concat, Coalesce
+from django.db.models import Value, CharField
+from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
-from django.http.response import HttpResponseRedirect
from django.urls import reverse
+from dateutil.relativedelta import relativedelta # type: ignore
from epplibwrapper.errors import ErrorCode, RegistryError
-from registrar.models.domain import Domain
-from registrar.models.user import User
+from registrar.models import Contact, Domain, DomainApplication, DraftDomain, User, Website
from registrar.utility import csv_export
from registrar.views.utility.mixins import OrderableFieldsMixin
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.html import escape
+
logger = logging.getLogger(__name__)
@@ -118,41 +121,52 @@ class CustomLogEntryAdmin(LogEntryAdmin):
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"""
- # customize sorting
- if db_field.name in (
- "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:
+ queryset_info = cls.sort_mapping.get(db_field.name, None)
+ if queryset_info is 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):
"""Custom admin to make auditing easier."""
@@ -170,9 +184,14 @@ class AuditedAdmin(admin.ModelAdmin):
def formfield_for_manytomany(self, db_field, request, **kwargs):
"""customize the behavior of formfields with manytomany relationships. the customized
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)
if queryset:
kwargs["queryset"] = queryset
+
formfield = super().formfield_for_manytomany(db_field, request, **kwargs)
# customize the help text for all formfields for manytomany
formfield.help_text = (
@@ -182,11 +201,16 @@ class AuditedAdmin(admin.ModelAdmin):
return formfield
def formfield_for_foreignkey(self, db_field, request, **kwargs):
- """customize the behavior of formfields with foreign key relationships. this will customize
- the behavior of selects. customized behavior includes sorting of objects in list"""
+ """Customize the behavior of formfields with foreign key relationships. This will customize
+ 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)
if queryset:
kwargs["queryset"] = queryset
+
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@@ -221,7 +245,6 @@ class ListHeaderAdmin(AuditedAdmin, OrderableFieldsMixin):
parameter_value: string}
TODO: convert investigator id to investigator username
"""
-
filters = []
# Retrieve the filter parameters
for param in request.GET.keys():
@@ -265,6 +288,14 @@ class UserContactInline(admin.StackedInline):
class MyUserAdmin(BaseUserAdmin):
"""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]
list_display = (
@@ -353,6 +384,42 @@ class MyUserAdmin(BaseUserAdmin):
# in autocomplete_fields for user
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
# (which should in theory be the ONLY group)
def group(self, obj):
@@ -417,6 +484,9 @@ class ContactAdmin(ListHeaderAdmin):
"contact",
"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
# is not allowing a short_description attr on it
@@ -752,8 +822,23 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"""Lookup reimplementation, gets users of is_staff.
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")
- return [(user.id, user) for user in privileged_users]
+ # Select all investigators that are staff, then order by name and email
+ 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):
"""Custom queryset implementation, filters by investigator"""
@@ -853,26 +938,19 @@ class DomainApplicationAdmin(ListHeaderAdmin):
"anything_else",
"is_policy_acknowledged",
]
-
+ autocomplete_fields = [
+ "approved_domain",
+ "requested_domain",
+ "submitter",
+ "creator",
+ "authorizing_official",
+ "investigator",
+ ]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
# Table ordering
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
def save_model(self, request, obj, form, change):
if obj and obj.creator.status != models.User.RESTRICTED:
@@ -957,7 +1035,6 @@ class DomainApplicationAdmin(ListHeaderAdmin):
admin user permissions and the application creator's status, so
we'll use the baseline readonly_fields and extend it as needed.
"""
-
readonly_fields = list(self.readonly_fields)
# Check if the creator is restricted
@@ -1035,8 +1112,8 @@ class DomainInformationInline(admin.StackedInline):
return formfield
def formfield_for_foreignkey(self, db_field, request, **kwargs):
- """customize the behavior of formfields with foreign key relationships. this will customize
- the behavior of selects. customized behavior includes sorting of objects in list"""
+ """Customize the behavior of formfields with foreign key relationships. This will customize
+ the behavior of selects. Customized behavior includes sorting of objects in list."""
queryset = AdminSortFields.get_queryset(db_field)
if queryset:
kwargs["queryset"] = queryset
@@ -1090,6 +1167,26 @@ class DomainAdmin(ListHeaderAdmin):
# Table ordering
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):
# match the CSV example with all the fields
response = HttpResponse(content_type="text/csv")
@@ -1148,6 +1245,7 @@ class DomainAdmin(ListHeaderAdmin):
"_edit_domain": self.do_edit_domain,
"_delete_domain": self.do_delete_domain,
"_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
@@ -1158,6 +1256,81 @@ class DomainAdmin(ListHeaderAdmin):
# If no matching action button is found, return the super method
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):
if not isinstance(obj, Domain):
# Could be problematic if the type is similar,
@@ -1316,6 +1489,10 @@ class DraftDomainAdmin(ListHeaderAdmin):
search_fields = ["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):
list_display = ("email", "requestor", "truncated_notes", "created_at")
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 9239a0488..fc6af408a 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -23,6 +23,33 @@ function openInNewTab(el, removeAttribute = false){
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// 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.
* Currently only appends target="_blank" to the domain_form object,
* but this can be expanded.
@@ -41,8 +68,8 @@ function openInNewTab(el, removeAttribute = false){
let domainFormElement = document.getElementById("domain_form");
let domainSubmitButton = document.getElementById("manageDomainSubmitButton");
if(domainSubmitButton && domainFormElement){
- domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true));
- domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false));
+ domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true));
+ domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false));
}
}
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index 760c4f13a..b57c6a015 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -183,6 +183,10 @@ h1, h2, h3,
.submit-row div.spacer {
flex-grow: 1;
}
+.submit-row .mini-spacer{
+ margin-left: 2px;
+ margin-right: 2px;
+}
.submit-row span {
margin-top: units(1);
}
@@ -270,3 +274,31 @@ h1, h2, h3,
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;
+ }
+}
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 7ebe3dc34..020ba43c0 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -265,15 +265,17 @@ class Domain(TimeStampedModel, DomainHelper):
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:
- cur_exp_date = self.registry_expiration_date
+ exp_date = self.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")
- cur_exp_date = date.today()
+ exp_date = date.today()
# 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:
# update expiration date in registry, and set the updated
diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py
index acaa330bb..1a50efe2c 100644
--- a/src/registrar/models/domain_information.py
+++ b/src/registrar/models/domain_information.py
@@ -255,6 +255,14 @@ class DomainInformation(TimeStampedModel):
else:
da_many_to_many_dict[field] = getattr(domain_application, field).all()
+ # This will not happen in normal code flow, but having some redundancy doesn't hurt.
+ # da_dict should not have "id" under any circumstances.
+ # If it does have it, then this indicates that common_fields is overzealous in the data
+ # that it is returning. Try looking in DomainHelper.get_common_fields.
+ if "id" in da_dict:
+ logger.warning("create_from_da() -> Found attribute 'id' when trying to create")
+ da_dict.pop("id", None)
+
# Create a placeholder DomainInformation object
domain_info = DomainInformation(**da_dict)
diff --git a/src/registrar/models/utility/domain_helper.py b/src/registrar/models/utility/domain_helper.py
index 230b23e16..9e3559676 100644
--- a/src/registrar/models/utility/domain_helper.py
+++ b/src/registrar/models/utility/domain_helper.py
@@ -180,8 +180,8 @@ class DomainHelper:
"""
# Get a list of the existing fields on model_1 and model_2
- model_1_fields = set(field.name for field in model_1._meta.get_fields() if field != "id")
- model_2_fields = set(field.name for field in model_2._meta.get_fields() if field != "id")
+ model_1_fields = set(field.name for field in model_1._meta.get_fields() if field.name != "id")
+ model_2_fields = set(field.name for field in model_2._meta.get_fields() if field.name != "id")
# Get the fields that exist on both DomainApplication and DomainInformation
common_fields = model_1_fields & model_2_fields
diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py
new file mode 100644
index 000000000..01d4e6b33
--- /dev/null
+++ b/src/registrar/models/utility/generic_helper.py
@@ -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")
diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html
index f9ff23455..73e9ba1f0 100644
--- a/src/registrar/templates/admin/base_site.html
+++ b/src/registrar/templates/admin/base_site.html
@@ -18,11 +18,12 @@
+
+
{% endblock %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
-
{% block extrastyle %}{{ block.super }}
{% endblock %}
diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html
index c4461d07f..67c5ac291 100644
--- a/src/registrar/templates/django/admin/domain_change_form.html
+++ b/src/registrar/templates/django/admin/domain_change_form.html
@@ -2,21 +2,117 @@
{% load i18n static %}
{% block field_sets %}
-
{{ block.super }}
{% 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 %}
+
+
+
+
+ Are you sure you want to extend the expiration date?
+
+
+
+ This will extend the expiration date by one year.
+ {# Acts as a #}
+