diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 36d6b4fd9..1ded3de82 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -1,17 +1,18 @@
+from datetime import date
import logging
from django import forms
from django.db.models.functions import Concat, Coalesce
from django.db.models import Value, CharField
-from django.http import HttpResponse
+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 import Contact, Domain, DomainApplication, DraftDomain, User, Website
from registrar.utility import csv_export
@@ -24,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__)
@@ -158,10 +160,8 @@ class AdminSortFields:
match db_field.name:
case "investigator":
# We should only return users who are staff.
- # Currently a fallback. Consider removing this if it is not needed.
return model.objects.filter(is_staff=True).order_by(*order_by)
case _:
- # If no case is defined, return the default
if isinstance(order_by, list) or isinstance(order_by, tuple):
return model.objects.order_by(*order_by)
else:
@@ -201,8 +201,8 @@ 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.
@@ -285,7 +285,7 @@ class UserContactInline(admin.StackedInline):
model = models.Contact
-class UserAdmin(BaseUserAdmin):
+class MyUserAdmin(BaseUserAdmin):
"""Custom user admin class to use our inlines."""
class Meta:
@@ -1111,8 +1111,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
@@ -1166,6 +1166,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")
@@ -1224,6 +1244,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
@@ -1234,6 +1255,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,
@@ -1422,7 +1518,7 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin)
-admin.site.register(models.User, UserAdmin)
+admin.site.register(models.User, MyUserAdmin)
# Unregister the built-in Group model
admin.site.unregister(Group)
# Register UserGroup
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 866c7bd7d..29aa9ce03 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 93a0d7338..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);
}
@@ -279,4 +283,22 @@ h1, h2, h3,
// Fixes a display issue where the list was entirely white, or had too much whitespace
.select2-dropdown {
display: inline-grid !important;
-}
\ No newline at end of file
+}
+
+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/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 #}
+