From c6baf9c98bcc999e12de5c8ec3bbba044544f82f Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Wed, 27 Sep 2023 14:45:33 -0600
Subject: [PATCH 01/16] Preliminary changes
---
src/registrar/admin.py | 43 ++++++-
src/registrar/models/domain.py | 47 +++++---
src/registrar/models/domain_application.py | 8 +-
src/registrar/tests/common.py | 7 ++
src/registrar/tests/test_models_domain.py | 128 ++++++++++++++++++---
5 files changed, 197 insertions(+), 36 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index e99e767bd..6ec27a085 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -6,11 +6,12 @@
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.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__)
@@ -717,10 +718,46 @@ def response_change(self, request, obj):
def do_delete_domain(self, request, obj):
try:
- obj.deleted()
+ obj.deletedInEpp()
obj.save()
- except Exception as err:
+ except RegistryError as err:
+ if err.is_connection_error():
+ self.message_user(
+ request,
+ "Error connecting to the registry",
+ messages.ERROR,
+ )
+ elif err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION:
+ self.message_user(
+ request,
+ "Error deleting this Domain: "
+ f"Cannot delete Domain when in status {obj.status}",
+ messages.ERROR,
+ )
+ elif err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION:
+ self.message_user(
+ request,
+ "Error deleting this Domain: "
+ f" This subdomain is being used as a hostname on another domain",
+ messages.ERROR,
+ )
+ elif err.code:
+ self.message_user(
+ request,
+ f"Error deleting this Domain: {err}",
+ messages.ERROR,
+ )
+ else:
+ # all other type error messages, display the error
+ self.message_user(request, err, messages.ERROR)
+ except ValueError as err:
self.message_user(request, err, messages.ERROR)
+ except TransitionNotAllowed
+ self.message_user(
+ request,
+ f"Error deleting this Domain: {err}",
+ messages.ERROR,
+ )
else:
self.message_user(
request,
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 2c7f8703c..827d26073 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -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,8 @@ 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)
+ response = registry.send(request, cleaned=True)
+ return response
def __str__(self) -> str:
return self.name
@@ -773,6 +769,8 @@ def pendingCreate(self):
self.addAllDefaults()
+
+
def addAllDefaults(self):
security_contact = self.get_default_security_contact()
security_contact.save()
@@ -805,15 +803,34 @@ def revert_client_hold(self):
# 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
+ def deletedInEpp(self):
+ """domain is deleted in epp but is saved in our database.
+ Returns the request_code"""
+ valid_delete_states = [
+ self.State.ON_HOLD,
+ self.State.DNS_NEEDED
+ ]
+ # Check that the domain contacts a valid status
+ if (self.state not in valid_delete_states):
+ raise ValueError(
+ f"Invalid domain state of {self.state}. Cannot delete."
+ )
+
+ 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 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
index 7df51baf4..c9d32d978 100644
--- a/src/registrar/models/domain_application.py
+++ b/src/registrar/models/domain_application.py
@@ -598,7 +598,11 @@ def withdraw(self):
"emails/domain_request_withdrawn.txt",
"emails/domain_request_withdrawn_subject.txt",
)
-
+
+ # TODO
+ #def delete(self, *args, **kwargs):
+ #super().delete(*args, **kwargs)
+
@transition(
field="status",
source=[IN_REVIEW, APPROVED],
@@ -612,7 +616,7 @@ def reject(self):
(will cascade), and send an email notification."""
if self.status == self.APPROVED:
- self.approved_domain.delete_request()
+ self.approved_domain.deletedInEpp()
self.approved_domain.delete()
self.approved_domain = None
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
index fe41647f9..9fc85b76b 100644
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -604,6 +604,13 @@ 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) == "fail.gov"
+ ):
+ raise RegistryError(
+ code=ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION
+ )
return MagicMock(res_data=[self.mockDataInfoHosts])
def setUp(self):
diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py
index 54045bb32..842f2e116 100644
--- a/src/registrar/tests/test_models_domain.py
+++ b/src/registrar/tests/test_models_domain.py
@@ -940,39 +940,95 @@ def test_update_is_unsuccessful(self):
raise
-class TestAnalystDelete(TestCase):
+class TestAnalystDelete(MockEppLib):
"""Rule: Analysts may delete a domain"""
-
def setUp(self):
- """
- Background:
- Given the analyst is logged in
- And a domain exists in the registry
- """
- pass
+ """
+ Background:
+ Given the analyst is logged in
+ And a domain exists in the registry
+ """
+ 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,
+ )
+ ]
+ )
+ # 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)
- @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
+ When `domain.deletedInEpp()` is called
Then `commands.DeleteDomain` is sent to the registry
And Domain returns normally (without error)
"""
- 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,
+ )
+ ]
+ )
+ # 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)
+
+ # Delete it again - monitoring for errors
+ try:
+ self.domain.deletedInEpp()
+ except Exception as err:
+ self.fail("deletedInEpp() threw an error")
+ raise err
+
+ self.mockedSendFunction.assert_has_calls(
+ [
+ call(
+ commands.DeleteDomain(name="fake.gov"),
+ cleaned=True,
+ )
+ ]
+ )
+ # 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)
- @skip("not implemented yet")
def test_deletion_is_unsuccessful(self):
"""
Scenario: Domain deletion is unsuccessful
@@ -980,4 +1036,44 @@ def test_deletion_is_unsuccessful(self):
Then a user-friendly error message is returned for displaying on the web
And `state` is not set to `DELETED`
"""
- raise
+ domain, _ = Domain.objects.get_or_create(
+ name="fail.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_STATUS_PROHIBITS_OPERATION
+ )
+ # TODO - check UI for error
+ # Domain itself should not be deleted
+ self.assertNotEqual(domain, None)
+ # State should not have changed
+ self.assertEqual(domain.state, Domain.State.ON_HOLD)
+
+ @skip("not implemented yet")
+ 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
+ Then a user-friendly error message is returned for displaying on the web
+ And `state` is not set to `DELETED`
+ """
+ self.domain.deletedInEpp()
+ self.mockedSendFunction.assert_has_calls(
+ [
+ call(
+ commands.DeleteDomain(name="fake.gov", auth_info=None),
+ cleaned=True,
+ )
+ ]
+ )
+ # Domain should not be deleted
+ self.assertNotEqual(self.domain, None)
+ # Domain should have the right state
+ self.assertEqual(self.domain.state, "DELETED")
From 59b095beab06df382b31069413a1a9b077ccf289 Mon Sep 17 00:00:00 2001
From: zandercymatics <141044360+zandercymatics@users.noreply.github.com>
Date: Thu, 28 Sep 2023 13:53:21 -0600
Subject: [PATCH 02/16] Commit for #901
---
src/registrar/admin.py | 57 ++++---
src/registrar/models/domain.py | 27 ++--
.../django/admin/domain_change_form.html | 4 +-
src/registrar/templates/home.html | 3 +
src/registrar/tests/common.py | 11 +-
src/registrar/tests/test_admin.py | 145 ++++++++++++++++++
src/registrar/tests/test_models_domain.py | 88 ++++-------
7 files changed, 223 insertions(+), 112 deletions(-)
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 6ec27a085..415289e42 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -7,6 +7,7 @@
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
@@ -717,51 +718,47 @@ 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) so we err out.
+ # We do not want to accidentally delete records.
+ raise ValueError("Object is not of type Domain")
try:
obj.deletedInEpp()
obj.save()
except RegistryError as err:
- if err.is_connection_error():
- self.message_user(
- request,
- "Error connecting to the registry",
- messages.ERROR,
- )
- elif err.code == ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION:
- self.message_user(
- request,
- "Error deleting this Domain: "
+ # Human-readable mappings of ErrorCodes. Can be expanded.
+ error_messages = {
+ ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION:
f"Cannot delete Domain when in status {obj.status}",
- messages.ERROR,
- )
- elif err.code == ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION:
+ ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION:
+ "This subdomain is being used as a hostname on another domain"
+ }
+
+ 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 as err:
+ if obj.state == Domain.State.DELETED:
self.message_user(
request,
- "Error deleting this Domain: "
- f" This subdomain is being used as a hostname on another domain",
- messages.ERROR,
+ f"This domain is already deleted",
+ messages.INFO,
)
- elif err.code:
+ else:
self.message_user(
request,
- f"Error deleting this Domain: {err}",
+ "Error deleting this Domain: "
+ f"Can't switch from state '{obj.state}' to 'deleted'"
+ ,
messages.ERROR,
)
- else:
- # all other type error messages, display the error
- self.message_user(request, err, messages.ERROR)
- except ValueError as err:
- self.message_user(request, err, messages.ERROR)
- except TransitionNotAllowed
- self.message_user(
- request,
- f"Error deleting this Domain: {err}",
- 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(".")
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 827d26073..e264d4aa9 100644
--- 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
@@ -802,20 +802,14 @@ 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)
+ @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.
- Returns the request_code"""
- valid_delete_states = [
- self.State.ON_HOLD,
- self.State.DNS_NEEDED
- ]
- # Check that the domain contacts a valid status
- if (self.state not in valid_delete_states):
- raise ValueError(
- f"Invalid domain state of {self.state}. Cannot delete."
- )
-
+ """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()
@@ -824,6 +818,11 @@ def deletedInEpp(self):
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}"
diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html
index 1b8b90930..ca44aa03c 100644
--- a/src/registrar/templates/django/admin/domain_change_form.html
+++ b/src/registrar/templates/django/admin/domain_change_form.html
@@ -8,9 +8,9 @@
{% block field_sets %}