Repository: cisagov/manage.get.gov

Issue:
EPP Delete Domain
### Issue Description

Delete_Request Function is implemented in domain.py (Note: This may already may be stubbed)
Send a Delete request to EPP only if the status is clientHold or status DNS NEEDED
The domain should state transition to DELETED


### Acceptance Criteria:

1. delete_request function stubbed previously should be changed to the domain.deleted() function 
2. delete should invalidate the cache on the domain
3. deleted should now allow the states matched above
4. other functions that used delete_request should be updated to use domain.delete and check their unitests as mocks may need to be added
5. if when sending delete a registry error occurs then the user should get a human readable error on the admin page (see what has been done for clienthold). The registry error code and message should be logged.  
6. if the registry throws a specific error indicating that the subdomain is being used as a hostname on another domain, then have a human readable error show to the user to indicate this special case
7. unit test for the delete function see TestAnalystDelete in test_models_domain
### Additional Context (optional)

_No response_

Implementation notes:
in the case where a  subdomain is being used as a hostname on another domain, the error code may be 2304 or 2305. Test via epp commands to get this error to trigger and see what error code is returned. (Make two domains in the ready state, one should use a hostname that is a subdomain of the second domain. Attempt to delete the second domain should result in an error from the registry. Use this errorcode for number 6 above)
### Issue Links
Blocked by: #857
Relates to: #808


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
@@ -6,10 +6,13 @@
 from django.contrib.contenttypes.models import ContentType
 from django.http.response import HttpResponseRedirect
 from django.urls import reverse
+from epplibwrapper.errors import ErrorCode, RegistryError
+from registrar.models.domain import Domain
 from registrar.models.utility.admin_sort_fields import AdminSortFields
 from . import models
 from auditlog.models import LogEntry  # type: ignore
 from auditlog.admin import LogEntryAdmin  # type: ignore
+from django_fsm import TransitionNotAllowed  # type: ignore
 
 logger = logging.getLogger(__name__)
 
@@ -716,16 +719,61 @@ def response_change(self, request, obj):
         return super().response_change(request, obj)
 
     def do_delete_domain(self, request, obj):
+        if not isinstance(obj, Domain):
+            # Could be problematic if the type is similar,
+            # but not the same (same field/func names).
+            # We do not want to accidentally delete records.
+            self.message_user(request, "Object is not of type Domain", messages.ERROR)
+            return
+
         try:
-            obj.deleted()
+            obj.deletedInEpp()
             obj.save()
-        except Exception as err:
-            self.message_user(request, err, messages.ERROR)
+        except RegistryError as err:
+            # Using variables to get past the linter
+            message1 = f"Cannot delete Domain when in state {obj.state}"
+            message2 = "This subdomain is being used as a hostname on another domain"
+            # Human-readable mappings of ErrorCodes. Can be expanded.
+            error_messages = {
+                # noqa on these items as black wants to reformat to an invalid length
+                ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION: message1,
+                ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: message2,
+            }
+
+            message = "Cannot connect to the registry"
+            if not err.is_connection_error():
+                # If nothing is found, will default to returned err
+                message = error_messages.get(err.code, err)
+            self.message_user(
+                request, f"Error deleting this Domain: {message}", messages.ERROR
+            )
+        except TransitionNotAllowed:
+            if obj.state == Domain.State.DELETED:
+                self.message_user(
+                    request,
+                    "This domain is already deleted",
+                    messages.INFO,
+                )
+            else:
+                self.message_user(
+                    request,
+                    "Error deleting this Domain: "
+                    f"Can't switch from state '{obj.state}' to 'deleted'"
+                    ", must be either 'dns_needed' or 'on_hold'",
+                    messages.ERROR,
+                )
+        except Exception:
+            self.message_user(
+                request,
+                "Could not delete: An unspecified error occured",
+                messages.ERROR,
+            )
         else:
             self.message_user(
                 request,
-                ("Domain %s Should now be deleted " ". Thanks!") % obj.name,
+                ("Domain %s has been deleted. Thanks!") % obj.name,
             )
+
         return HttpResponseRedirect(".")
 
     def do_get_status(self, request, obj):
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
@@ -2,7 +2,7 @@
 
 from datetime import date
 from string import digits
-from django_fsm import FSMField, transition  # type: ignore
+from django_fsm import FSMField, transition, TransitionNotAllowed  # type: ignore
 
 from django.db import models
 
@@ -609,11 +609,6 @@ def is_active(self) -> bool:
         """
         return self.state == self.State.READY
 
-    def delete_request(self):
-        """Delete from host. Possibly a duplicate of _delete_host?"""
-        # TODO fix in ticket #901
-        pass
-
     def transfer(self):
         """Going somewhere. Not implemented."""
         raise NotImplementedError()
@@ -658,7 +653,7 @@ def _delete_domain(self):
         """This domain should be deleted from the registry
         may raises RegistryError, should be caught or handled correctly by caller"""
         request = commands.DeleteDomain(name=self.name)
-        registry.send(request)
+        registry.send(request, cleaned=True)
 
     def __str__(self) -> str:
         return self.name
@@ -804,16 +799,32 @@ def revert_client_hold(self):
         self._remove_client_hold()
         # TODO -on the client hold ticket any additional error handling here
 
-    @transition(field="state", source=State.ON_HOLD, target=State.DELETED)
-    def deleted(self):
-        """domain is deleted in epp but is saved in our database"""
-        # TODO Domains may not be deleted if:
-        #  a child host is being used by
-        # another .gov domains.  The host must be first removed
-        # and/or renamed before the parent domain may be deleted.
-        logger.info("pendingCreate()-> inside pending create")
-        self._delete_domain()
-        # TODO - delete ticket any additional error handling here
+    @transition(
+        field="state", source=[State.ON_HOLD, State.DNS_NEEDED], target=State.DELETED
+    )
+    def deletedInEpp(self):
+        """Domain is deleted in epp but is saved in our database.
+        Error handling should be provided by the caller."""
+        # While we want to log errors, we want to preserve
+        # that information when this function is called.
+        # Human-readable errors are introduced at the admin.py level,
+        # as doing everything here would reduce reliablity.
+        try:
+            logger.info("deletedInEpp()-> inside _delete_domain")
+            self._delete_domain()
+        except RegistryError as err:
+            logger.error(f"Could not delete domain. Registry returned error: {err}")
+            raise err
+        except TransitionNotAllowed as err:
+            logger.error("Could not delete domain. FSM failure: {err}")
+            raise err
+        except Exception as err:
+            logger.error(
+                f"Could not delete domain. An unspecified error occured: {err}"
+            )
+            raise err
+        else:
+            self._invalidate_cache()
 
     @transition(
         field="state",
diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py
--- a/src/registrar/models/domain_application.py
+++ b/src/registrar/models/domain_application.py
@@ -6,6 +6,7 @@
 from django.apps import apps
 from django.db import models
 from django_fsm import FSMField, transition  # type: ignore
+from registrar.models.domain import Domain
 
 from .utility.time_stamped_model import TimeStampedModel
 from ..utility.email import send_templated_email, EmailSendingError
@@ -610,9 +611,11 @@ def reject(self):
 
         As side effects this will delete the domain and domain_information
         (will cascade), and send an email notification."""
-
         if self.status == self.APPROVED:
-            self.approved_domain.delete_request()
+            domain_state = self.approved_domain.state
+            # Only reject if it exists on EPP
+            if domain_state != Domain.State.UNKNOWN:
+                self.approved_domain.deletedInEpp()
             self.approved_domain.delete()
             self.approved_domain = None
 
@@ -638,7 +641,10 @@ def reject_with_prejudice(self):
         and domain_information (will cascade) when they exist."""
 
         if self.status == self.APPROVED:
-            self.approved_domain.delete_request()
+            domain_state = self.approved_domain.state
+            # Only reject if it exists on EPP
+            if domain_state != Domain.State.UNKNOWN:
+                self.approved_domain.deletedInEpp()
             self.approved_domain.delete()
             self.approved_domain = None
 


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
@@ -604,6 +604,16 @@ def mockSend(self, _request, cleaned):
             # use this for when a contact is being updated
             # sets the second send() to fail
             raise RegistryError(code=ErrorCode.OBJECT_EXISTS)
+        elif (
+            isinstance(_request, commands.DeleteDomain)
+            and getattr(_request, "name", None) == "failDelete.gov"
+        ):
+            name = getattr(_request, "name", None)
+            fake_nameserver = "ns1.failDelete.gov"
+            if name in fake_nameserver:
+                raise RegistryError(
+                    code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION
+                )
         return MagicMock(res_data=[self.mockDataInfoHosts])
 
     def setUp(self):
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
@@ -49,6 +49,7 @@ def setUp(self):
         self.client = Client(HTTP_HOST="localhost:8080")
         self.superuser = create_superuser()
         self.staffuser = create_user()
+        self.factory = RequestFactory()
         super().setUp()
 
     def test_place_and_remove_hold(self):
@@ -87,6 +88,155 @@ def test_place_and_remove_hold(self):
         self.assertContains(response, "Place hold")
         self.assertNotContains(response, "Remove hold")
 
+    def test_deletion_is_successful(self):
+        """
+        Scenario: Domain deletion is unsuccessful
+            When the domain is deleted
+            Then a user-friendly success message is returned for displaying on the web
+            And `state` is et to `DELETED`
+        """
+        domain = create_ready_domain()
+        # Put in client hold
+        domain.place_client_hold()
+        p = "userpass"
+        self.client.login(username="staffuser", password=p)
+
+        # Ensure everything is displaying correctly
+        response = self.client.get(
+            "/admin/registrar/domain/{}/change/".format(domain.pk),
+            follow=True,
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, domain.name)
+        self.assertContains(response, "Delete Domain in Registry")
+
+        # Test the info dialog
+        request = self.factory.post(
+            "/admin/registrar/domain/{}/change/".format(domain.pk),
+            {"_delete_domain": "Delete Domain in Registry", "name": domain.name},
+            follow=True,
+        )
+        request.user = self.client
+
+        with patch("django.contrib.messages.add_message") as mock_add_message:
+            self.admin.do_delete_domain(request, domain)
+            mock_add_message.assert_called_once_with(
+                request,
+                messages.INFO,
+                "Domain city.gov has been deleted. Thanks!",
+                extra_tags="",
+                fail_silently=False,
+            )
+
+        self.assertEqual(domain.state, Domain.State.DELETED)
+
+    def test_deletion_ready_fsm_failure(self):
+        """
+        Scenario: Domain deletion is unsuccessful
+            When an error is returned from epplibwrapper
+            Then a user-friendly error message is returned for displaying on the web
+            And `state` is not set to `DELETED`
+        """
+        domain = create_ready_domain()
+        p = "userpass"
+        self.client.login(username="staffuser", password=p)
+
+        # Ensure everything is displaying correctly
+        response = self.client.get(
+            "/admin/registrar/domain/{}/change/".format(domain.pk),
+            follow=True,
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, domain.name)
+        self.assertContains(response, "Delete Domain in Registry")
+
+        # Test the error
+        request = self.factory.post(
+            "/admin/registrar/domain/{}/change/".format(domain.pk),
+            {"_delete_domain": "Delete Domain in Registry", "name": domain.name},
+            follow=True,
+        )
+        request.user = self.client
+
+        with patch("django.contrib.messages.add_message") as mock_add_message:
+            self.admin.do_delete_domain(request, domain)
+            mock_add_message.assert_called_once_with(
+                request,
+                messages.ERROR,
+                "Error deleting this Domain: "
+                "Can't switch from state 'ready' to 'deleted'"
+                ", must be either 'dns_needed' or 'on_hold'",
+                extra_tags="",
+                fail_silently=False,
+            )
+
+        self.assertEqual(domain.state, Domain.State.READY)
+
+    def test_analyst_deletes_domain_idempotent(self):
+        """
+        Scenario: Analyst tries to delete an already deleted domain
+            Given `state` is already `DELETED`
+            When `domain.deletedInEpp()` is called
+            Then `commands.DeleteDomain` is sent to the registry
+            And Domain returns normally without an error dialog
+        """
+        domain = create_ready_domain()
+        # Put in client hold
+        domain.place_client_hold()
+        p = "userpass"
+        self.client.login(username="staffuser", password=p)
+
+        # Ensure everything is displaying correctly
+        response = self.client.get(
+            "/admin/registrar/domain/{}/change/".format(domain.pk),
+            follow=True,
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, domain.name)
+        self.assertContains(response, "Delete Domain in Registry")
+
+        # Test the info dialog
+        request = self.factory.post(
+            "/admin/registrar/domain/{}/change/".format(domain.pk),
+            {"_delete_domain": "Delete Domain in Registry", "name": domain.name},
+            follow=True,
+        )
+        request.user = self.client
+
+        # Delete it once
+        with patch("django.contrib.messages.add_message") as mock_add_message:
+            self.admin.do_delete_domain(request, domain)
+            mock_add_message.assert_called_once_with(
+                request,
+                messages.INFO,
+                "Domain city.gov has been deleted. Thanks!",
+                extra_tags="",
+                fail_silently=False,
+            )
+
+        self.assertEqual(domain.state, Domain.State.DELETED)
+
+        # Try to delete it again
+        # Test the info dialog
+        request = self.factory.post(
+            "/admin/registrar/domain/{}/change/".format(domain.pk),
+            {"_delete_domain": "Delete Domain in Registry", "name": domain.name},
+            follow=True,
+        )
+        request.user = self.client
+
+        with patch("django.contrib.messages.add_message") as mock_add_message:
+            self.admin.do_delete_domain(request, domain)
+            mock_add_message.assert_called_once_with(
+                request,
+                messages.INFO,
+                "This domain is already deleted",
+                extra_tags="",
+                fail_silently=False,
+            )
+
+        self.assertEqual(domain.state, Domain.State.DELETED)
+
     @skip("Waiting on epp lib to implement")
     def test_place_and_remove_hold_epp(self):
         raise
@@ -138,8 +288,9 @@ def test_form_choices_when_ineligible(self):
         )
 
 
-class TestDomainApplicationAdmin(TestCase):
+class TestDomainApplicationAdmin(MockEppLib):
     def setUp(self):
+        super().setUp()
         self.site = AdminSite()
         self.factory = RequestFactory()
         self.admin = DomainApplicationAdmin(
@@ -690,6 +841,7 @@ def custom_is_active(self):
             domain_information.refresh_from_db()
 
     def tearDown(self):
+        super().tearDown()
         Domain.objects.all().delete()
         DomainInformation.objects.all().delete()
         DomainApplication.objects.all().delete()
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
@@ -16,7 +16,7 @@
 from registrar.models.public_contact import PublicContact
 from registrar.models.user import User
 from .common import MockEppLib
-
+from django_fsm import TransitionNotAllowed  # type: ignore
 from epplibwrapper import (
     commands,
     common,
@@ -940,7 +940,7 @@ def test_update_is_unsuccessful(self):
         raise
 
 
-class TestAnalystDelete(TestCase):
+class TestAnalystDelete(MockEppLib):
     """Rule: Analysts may delete a domain"""
 
     def setUp(self):
@@ -949,35 +949,99 @@ def setUp(self):
             Given the analyst is logged in
             And a domain exists in the registry
         """
-        pass
+        super().setUp()
+        self.domain, _ = Domain.objects.get_or_create(
+            name="fake.gov", state=Domain.State.READY
+        )
+        self.domain_on_hold, _ = Domain.objects.get_or_create(
+            name="fake-on-hold.gov", state=Domain.State.ON_HOLD
+        )
+
+    def tearDown(self):
+        Domain.objects.all().delete()
+        super().tearDown()
 
-    @skip("not implemented yet")
     def test_analyst_deletes_domain(self):
         """
         Scenario: Analyst permanently deletes a domain
-            When `domain.delete()` is called
+            When `domain.deletedInEpp()` is called
             Then `commands.DeleteDomain` is sent to the registry
             And `state` is set to `DELETED`
         """
-        raise
+        # Put the domain in client hold
+        self.domain.place_client_hold()
+        # Delete it...
+        self.domain.deletedInEpp()
+        self.mockedSendFunction.assert_has_calls(
+            [
+                call(
+                    commands.DeleteDomain(name="fake.gov"),
+                    cleaned=True,
+                )
+            ]
+        )
 
-    @skip("not implemented yet")
-    def test_analyst_deletes_domain_idempotent(self):
-        """
-        Scenario: Analyst tries to delete an already deleted domain
-            Given `state` is already `DELETED`
-            When `domain.delete()` is called
-            Then `commands.DeleteDomain` is sent to the registry
-            And Domain returns normally (without error)
-        """
-        raise
+        # Domain itself should not be deleted
+        self.assertNotEqual(self.domain, None)
+
+        # Domain should have the right state
+        self.assertEqual(self.domain.state, Domain.State.DELETED)
+
+        # Cache should be invalidated
+        self.assertEqual(self.domain._cache, {})
 
-    @skip("not implemented yet")
     def test_deletion_is_unsuccessful(self):
         """
         Scenario: Domain deletion is unsuccessful
-            When an error is returned from epplibwrapper
-            Then a user-friendly error message is returned for displaying on the web
+            When a subdomain exists
+            Then a client error is returned of code 2305
             And `state` is not set to `DELETED`
         """
-        raise
+        # Desired domain
+        domain, _ = Domain.objects.get_or_create(
+            name="failDelete.gov", state=Domain.State.ON_HOLD
+        )
+        # Put the domain in client hold
+        domain.place_client_hold()
+
+        # Delete it
+        with self.assertRaises(RegistryError) as err:
+            domain.deletedInEpp()
+            self.assertTrue(
+                err.is_client_error()
+                and err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION
+            )
+        self.mockedSendFunction.assert_has_calls(
+            [
+                call(
+                    commands.DeleteDomain(name="failDelete.gov"),
+                    cleaned=True,
+                )
+            ]
+        )
+
+        # Domain itself should not be deleted
+        self.assertNotEqual(domain, None)
+        # State should not have changed
+        self.assertEqual(domain.state, Domain.State.ON_HOLD)
+
+    def test_deletion_ready_fsm_failure(self):
+        """
+        Scenario: Domain deletion is unsuccessful due to FSM rules
+            Given state is 'ready'
+            When `domain.deletedInEpp()` is called
+            and domain is of `state` is `READY`
+            Then an FSM error is returned
+            And `state` is not set to `DELETED`
+        """
+        self.assertEqual(self.domain.state, Domain.State.READY)
+        with self.assertRaises(TransitionNotAllowed) as err:
+            self.domain.deletedInEpp()
+            self.assertTrue(
+                err.is_client_error()
+                and err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION
+            )
+        # Domain should not be deleted
+        self.assertNotEqual(self.domain, None)
+        # Domain should have the right state
+        self.assertEqual(self.domain.state, Domain.State.READY)