Repository: cisagov/manage.get.gov

Issue:
For analysts in /admin, make it easy to extend a domain's expiration by a year
### Issue description

As an analyst
I need to extend domain expiration dates in the registrar
So users can see their domain is in good standing

### Acceptance criteria

- [ ] In /admin, for individual domains, an analyst can extend an expiration date for a year from the current date (date of action, not based on the existing expiration date)
- [ ] Add confirmation "Are you sure? This cannot be undone" type of message.

### Additional context

We currently have several domains that are technically expired but should realistically be considered in good standing because we've confirmed good contacts. The issue is that the registrants missed the November 1 cutoff for automatic renewal and now cannot take action to fix their expiration date until we design our expiration experience (#1226).

Please collaborate with design/dev to arrive at a suitable solution.

**Confirm that we can specify an explicit expiration date thru EPP commands, rather than just incrementing the date**

### Links to other issues

⛔ Relates to: #1806 


Discussion:

Resolving patch:
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
--- 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.utils.safestring import mark_safe
 from django.utils.html import escape
 
+
 logger = logging.getLogger(__name__)
 
 
@@ -1148,6 +1150,26 @@ def organization_type(self, obj):
     # 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")
@@ -1206,6 +1228,7 @@ def response_change(self, request, obj):
             "_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
@@ -1216,6 +1239,81 @@ def response_change(self, request, obj):
         # 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,
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -265,15 +265,17 @@ def renew_domain(self, length: int = 1, unit: epp.Unit = epp.Unit.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:
-            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


Test patch:
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -922,6 +922,11 @@ def dummyInfoContactResultData(
         ex_date=datetime.date(2023, 5, 25),
     )
 
+    mockButtonRenewedDomainExpDate = fakedEppObject(
+        "fake.gov",
+        ex_date=datetime.date(2025, 5, 25),
+    )
+
     mockDnsNeededRenewedDomainExpDate = fakedEppObject(
         "fakeneeded.gov",
         ex_date=datetime.date(2023, 2, 15),
@@ -1050,11 +1055,19 @@ def mockRenewDomainCommand(self, _request, cleaned):
                 res_data=[self.mockMaximumRenewedDomainExpDate],
                 code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
             )
-        else:
-            return MagicMock(
-                res_data=[self.mockRenewedDomainExpDate],
-                code=ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY,
-            )
+        elif getattr(_request, "name", None) == "fake.gov":
+            period = getattr(_request, "period", None)
+            extension_period = getattr(period, "length", None)
+            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):
         request_name = getattr(_request, "name", None)
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -1,6 +1,8 @@
+from datetime import date
 from django.test import TestCase, RequestFactory, Client
 from django.contrib.admin.sites import AdminSite
 from contextlib import ExitStack
+from django_webtest import WebTest  # type: ignore
 from django.contrib import messages
 from django.urls import reverse
 from registrar.admin import (
@@ -35,7 +37,7 @@
 )
 from django.contrib.sessions.backends.db import SessionStore
 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 django.conf import settings
@@ -45,7 +47,11 @@
 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):
         self.site = AdminSite()
         self.admin = DomainAdmin(model=Domain, admin_site=self.site)
@@ -53,8 +59,177 @@ def setUp(self):
         self.superuser = create_superuser()
         self.staffuser = create_user()
         self.factory = RequestFactory()
+        self.app.set_user(self.superuser.username)
+        self.client.force_login(self.superuser)
         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):
         """
         Make sure the short name is displaying in admin on the list page