Repository: cisagov/manage.get.gov

Issue:
Implement setting registry expiration date via EPP
### Issue description

The registry has an expiration date but we are also storing the expiration date on the domain.py model. There  are property tagged functions for getting and setting the **registry** expiration date with only the getter implemented (though not tested). This should not be confused with the stored expiration date on the domain that appears in the model table. We need a way for the **registry** expiration date to updated from our registrar, thus we need to implement the setter portion. 

We do not need to provide a way on django admin for an analyst or user to update this expiration date as that is still under discussion as how best to do this. Right now NO one should be able to actually change the expiration date via the model

### Acceptance criteria

- [ ]  update registry expiration date getter as needed
- [ ] implement setting the registry expiration date 
- [ ] on django admin don't allow expiration date to be editable
- [ ] add unitest for the setter
- [ ] in the setter update the domain.expiration date with this new set date

### Additional context
Goal of this ticket is  to be used for the setting expiration on epp via a script in the blocked ticket. 

_No response_

### Links to other issues

blocked by: #1215 
blocks: #1212


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
@@ -736,7 +736,7 @@ def organization_type(self, obj):
     search_help_text = "Search by domain name."
     change_form_template = "django/admin/domain_change_form.html"
     change_list_template = "django/admin/domain_change_list.html"
-    readonly_fields = ["state"]
+    readonly_fields = ["state", "expiration_date"]
 
     def export_data_type(self, request):
         # match the CSV example with all the fields
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
@@ -211,12 +211,56 @@ def last_updated_date(self) -> date:
 
     @Cache
     def registry_expiration_date(self) -> date:
-        """Get or set the `ex_date` element from the registry."""
-        return self._get_property("ex_date")
+        """Get or set the `ex_date` element from the registry.
+        Additionally, update the expiration date in the registrar"""
+        try:
+            self.expiration_date = self._get_property("ex_date")
+            self.save()
+            return self.expiration_date
+        except Exception as e:
+            # exception raised during the save to registrar
+            logger.error(f"error updating expiration date in registrar: {e}")
+            raise (e)
 
     @registry_expiration_date.setter  # type: ignore
     def registry_expiration_date(self, ex_date: date):
-        pass
+        """
+        Direct setting of the expiration date in the registry is not implemented.
+
+        To update the expiration date, use renew_domain method."""
+        raise NotImplementedError()
+
+    def renew_domain(self, length: int = 1, unit: epp.Unit = epp.Unit.YEAR):
+        """
+        Renew the domain to a length and unit of time relative to the current
+        expiration date.
+
+        Default length and unit of time are 1 year.
+        """
+        # if no expiration date from registry, set to today
+        try:
+            cur_exp_date = self.registry_expiration_date
+        except KeyError:
+            logger.warning("current expiration date not set; setting to today")
+            cur_exp_date = date.today()
+
+        # create RenewDomain request
+        request = commands.RenewDomain(name=self.name, cur_exp_date=cur_exp_date, period=epp.Period(length, unit))
+
+        try:
+            # update expiration date in registry, and set the updated
+            # expiration date in the registrar, and in the cache
+            self._cache["ex_date"] = registry.send(request, cleaned=True).res_data[0].ex_date
+            self.expiration_date = self._cache["ex_date"]
+            self.save()
+        except RegistryError as err:
+            # if registry error occurs, log the error, and raise it as well
+            logger.error(f"registry error renewing domain: {err}")
+            raise (err)
+        except Exception as e:
+            # exception raised during the save to registrar
+            logger.error(f"error updating expiration date in registrar: {e}")
+            raise (e)
 
     @Cache
     def password(self) -> str:


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
@@ -556,6 +556,7 @@ def __init__(
             avail=...,
             addrs=...,
             registrant=...,
+            ex_date=...,
         ):
             self.auth_info = auth_info
             self.cr_date = cr_date
@@ -565,6 +566,7 @@ def __init__(
             self.avail = avail  # use for CheckDomain
             self.addrs = addrs
             self.registrant = registrant
+            self.ex_date = ex_date
 
         def dummyInfoContactResultData(
             self,
@@ -811,6 +813,11 @@ def dummyInfoContactResultData(
         ],
     )
 
+    mockRenewedDomainExpDate = fakedEppObject(
+        "fake.gov",
+        ex_date=datetime.date(2023, 5, 25),
+    )
+
     def _mockDomainName(self, _name, _avail=False):
         return MagicMock(
             res_data=[
@@ -870,6 +877,8 @@ def mockSend(self, _request, cleaned):
                 return self.mockCheckDomainCommand(_request, cleaned)
             case commands.DeleteDomain:
                 return self.mockDeleteDomainCommands(_request, cleaned)
+            case commands.RenewDomain:
+                return self.mockRenewDomainCommand(_request, cleaned)
             case _:
                 return MagicMock(res_data=[self.mockDataInfoHosts])
 
@@ -890,6 +899,15 @@ def mockDeleteDomainCommands(self, _request, cleaned):
                 raise RegistryError(code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION)
         return None
 
+    def mockRenewDomainCommand(self, _request, cleaned):
+        if getattr(_request, "name", None) == "fake-error.gov":
+            raise RegistryError(code=ErrorCode.PARAMETER_VALUE_RANGE_ERROR)
+        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_models_domain.py b/src/registrar/tests/test_models_domain.py
--- a/src/registrar/tests/test_models_domain.py
+++ b/src/registrar/tests/test_models_domain.py
@@ -56,7 +56,7 @@ def test_cache_sets_resets(self):
         self.assertFalse("avail" in domain._cache.keys())
 
         # using a setter should clear the cache
-        domain.registry_expiration_date = datetime.date.today()
+        domain.dnssecdata = []
         self.assertEquals(domain._cache, {})
 
         # send should have been called only once
@@ -1953,6 +1953,41 @@ def test_update_is_unsuccessful(self):
             self.assertTrue(err.is_client_error() or err.is_session_error() or err.is_server_error())
 
 
+class TestExpirationDate(MockEppLib):
+    """User may renew expiration date by a number of units of time"""
+
+    def setUp(self):
+        """
+        Domain exists in registry
+        """
+        super().setUp()
+        # for the tests, need a domain in the ready state
+        self.domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
+        # for the test, need a domain that will raise an exception
+        self.domain_w_error, _ = Domain.objects.get_or_create(name="fake-error.gov", state=Domain.State.READY)
+
+    def tearDown(self):
+        Domain.objects.all().delete()
+        super().tearDown()
+
+    def test_expiration_date_setter_not_implemented(self):
+        """assert that the setter for expiration date is not implemented and will raise error"""
+        with self.assertRaises(NotImplementedError):
+            self.domain.registry_expiration_date = datetime.date.today()
+
+    def test_renew_domain(self):
+        """assert that the renew_domain sets new expiration date in cache and saves to registrar"""
+        self.domain.renew_domain()
+        test_date = datetime.date(2023, 5, 25)
+        self.assertEquals(self.domain._cache["ex_date"], test_date)
+        self.assertEquals(self.domain.expiration_date, test_date)
+
+    def test_renew_domain_error(self):
+        """assert that the renew_domain raises an exception when registry raises error"""
+        with self.assertRaises(RegistryError):
+            self.domain_w_error.renew_domain()
+
+
 class TestAnalystClientHold(MockEppLib):
     """Rule: Analysts may suspend or restore a domain by using client hold"""