diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 074bf8d80..e5042fd48 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -175,174 +175,62 @@ class DomainAdmin(ListHeaderAdmin): change_form_template = "django/admin/domain_change_form.html" readonly_fields = ["state"] - def response_change(self, request, obj): # noqa - GET_SECURITY_EMAIL = "_get_security_email" - SET_SECURITY_CONTACT = "_set_security_contact" - MAKE_DOMAIN = "_make_domain_in_registry" - MAKE_NAMESERVERS = "_make_nameservers" - GET_NAMESERVERS = "_get_nameservers" - GET_STATUS = "_get_status" - SET_CLIENT_HOLD = "_set_client_hold" - REMOVE_CLIENT_HOLD = "_rem_client_hold" - DELETE_DOMAIN = "_delete_domain" + def response_change(self, request, obj): + # Create dictionary of action functions + ACTION_FUNCTIONS = { + "_place_client_hold": self.do_place_client_hold, + "_remove_client_hold": self.do_remove_client_hold, + "_edit_domain": self.do_edit_domain, + "_delete_domain": self.do_delete_domain, + "_get_status": self.do_get_status + } - PLACE_HOLD = "_place_client_hold" - EDIT_DOMAIN = "_edit_domain" - if PLACE_HOLD in request.POST: - try: - obj.place_client_hold() - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ( - "%s is in client hold. This domain is no longer accessible on" - " the public internet." - ) - % obj.name, - ) - return HttpResponseRedirect(".") + # Check which action button was pressed and call the corresponding function + for action, function in ACTION_FUNCTIONS.items(): + if action in request.POST: + return function(request, obj) - if GET_SECURITY_EMAIL in request.POST: - try: - contacts = obj._get_property("contacts") - email = None - for contact in contacts: - if ["type", "email"] in contact.keys() and contact[ - "type" - ] == "security": - email = contact["email"] - if email is None: - raise ValueError( - "Security contact type is not available on this domain" - ) - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ("The security email is %s" ". Thanks!") % email, - ) - return HttpResponseRedirect(".") - - elif SET_SECURITY_CONTACT in request.POST: - try: - fake_email = "manuallyEnteredEmail@test.gov" - if PublicContact.objects.filter( - domain=obj, contact_type="security" - ).exists(): - sec_contact = PublicContact.objects.filter( - domain=obj, contact_type="security" - ).get() - else: - sec_contact = obj.get_default_security_contact() - - sec_contact.email = fake_email - sec_contact.save() - - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - "The security email is %s. Thanks!" % fake_email, - ) - - elif MAKE_DOMAIN in request.POST: - try: - obj._get_or_create_domain() - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ("Domain created with %s" ". Thanks!") % obj.name, - ) - return HttpResponseRedirect(".") - - elif MAKE_NAMESERVERS in request.POST: - try: - hosts = [("ns1.example.com", None), ("ns2.example.com", None)] - obj.nameservers = hosts - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ("Hosts set to be %s" ". Thanks!") % hosts, - ) - return HttpResponseRedirect(".") - elif GET_NAMESERVERS in request.POST: - try: - nameservers = obj.nameservers - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ("Nameservers are %s" ". Thanks!") % nameservers, - ) - return HttpResponseRedirect(".") - - elif GET_STATUS in request.POST: - try: - statuses = obj.statuses - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ("Domain statuses are %s" ". Thanks!") % statuses, - ) - return HttpResponseRedirect(".") - - elif SET_CLIENT_HOLD in request.POST: - try: - obj.clientHold() - obj.save() - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ("Domain %s is now in clientHold") % obj.name, - ) - return HttpResponseRedirect(".") - - elif REMOVE_CLIENT_HOLD in request.POST: - try: - obj.revertClientHold() - obj.save() - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ("Domain %s will now have client hold removed") % obj.name, - ) - return HttpResponseRedirect(".") - - elif DELETE_DOMAIN in request.POST: - try: - obj.deleted() - obj.save() - except Exception as err: - self.message_user(request, err, messages.ERROR) - else: - self.message_user( - request, - ("Domain %s Should now be deleted " ". Thanks!") % obj.name, - ) - return HttpResponseRedirect(".") - elif EDIT_DOMAIN in request.POST: - # We want to know, globally, when an edit action occurs - request.session["analyst_action"] = "edit" - # Restricts this action to this domain (pk) only - request.session["analyst_action_location"] = obj.id - return HttpResponseRedirect(reverse("domain", args=(obj.id,))) + # If no matching action button is found, return the super method return super().response_change(request, obj) + def do_place_client_hold(self, request, obj): + try: + obj.place_client_hold() + obj.save() + except Exception as err: + self.message_user(request, err, messages.ERROR) + else: + self.message_user( + request, + ( + "%s is in client hold. This domain is no longer accessible on" + " the public internet." + ) + % obj.name, + ) + return HttpResponseRedirect(".") + + def do_remove_client_hold(self, request, obj): + try: + obj.revertClientHold() + obj.save() + except Exception as err: + self.message_user(request, err, messages.ERROR) + else: + self.message_user( + request, + ("%s is ready. This domain is accessible on the public internet.") + % obj.name, + ) + return HttpResponseRedirect(".") + + def do_edit_domain(self, request, obj): + # We want to know, globally, when an edit action occurs + request.session["analyst_action"] = "edit" + # Restricts this action to this domain (pk) only + request.session["analyst_action_location"] = obj.id + return HttpResponseRedirect(reverse("domain", args=(obj.id,))) + def change_view(self, request, object_id): # If the analyst was recently editing a domain page, # delete any associated session values @@ -554,3 +442,4 @@ admin.site.register(models.Nameserver, MyHostAdmin) admin.site.register(models.Website, AuditedAdmin) admin.site.register(models.PublicContact, AuditedAdmin) admin.site.register(models.DomainApplication, DomainApplicationAdmin) +admin.site.register(models.TransitionDomain, AuditedAdmin) diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index f37474e71..30924b8bf 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -131,7 +131,7 @@ class UserFixture: { "username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3", "first_name": "Nicolle-Analyst", - "last_name": "LeClair", + "last_name": "LeClair-Analyst", "email": "nicolle.leclair@ecstech.com", }, ] diff --git a/src/registrar/migrations/0031_alter_domain_state.py b/src/registrar/migrations/0031_alter_domain_state.py new file mode 100644 index 000000000..2545adb27 --- /dev/null +++ b/src/registrar/migrations/0031_alter_domain_state.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.1 on 2023-09-07 17:53 + +from django.db import migrations +import django_fsm + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0030_alter_user_status"), + ] + + operations = [ + migrations.AlterField( + model_name="domain", + name="state", + field=django_fsm.FSMField( + choices=[ + ("created", "Created"), + ("deleted", "Deleted"), + ("unknown", "Unknown"), + ("ready", "Ready"), + ("onhold", "Onhold"), + ], + default="unknown", + help_text="Very basic info about the lifecycle of this domain object", + max_length=21, + protected=True, + ), + ), + ] diff --git a/src/registrar/migrations/0031_transitiondomain.py b/src/registrar/migrations/0031_transitiondomain.py new file mode 100644 index 000000000..e72a8d85a --- /dev/null +++ b/src/registrar/migrations/0031_transitiondomain.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.1 on 2023-09-11 14:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0030_alter_user_status"), + ] + + operations = [ + migrations.CreateModel( + name="TransitionDomain", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "username", + models.TextField( + help_text="Username - this will be an email address", + verbose_name="Username", + ), + ), + ( + "domain_name", + models.TextField(blank=True, null=True, verbose_name="Domain name"), + ), + ( + "status", + models.CharField( + blank=True, + choices=[("created", "Created"), ("hold", "Hold")], + help_text="domain status during the transfer", + max_length=255, + verbose_name="Status", + ), + ), + ( + "email_sent", + models.BooleanField( + default=False, + help_text="indicates whether email was sent", + verbose_name="email sent", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/registrar/migrations/0032_merge_0031_alter_domain_state_0031_transitiondomain.py b/src/registrar/migrations/0032_merge_0031_alter_domain_state_0031_transitiondomain.py new file mode 100644 index 000000000..4c0a38427 --- /dev/null +++ b/src/registrar/migrations/0032_merge_0031_alter_domain_state_0031_transitiondomain.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.1 on 2023-09-12 14:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0031_alter_domain_state"), + ("registrar", "0031_transitiondomain"), + ] + + operations = [] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index 542cb00e1..fa4ce7e2a 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -13,6 +13,7 @@ from .user_domain_role import UserDomainRole from .public_contact import PublicContact from .user import User from .website import Website +from .transition_domain import TransitionDomain __all__ = [ "Contact", @@ -28,6 +29,7 @@ __all__ = [ "PublicContact", "User", "Website", + "TransitionDomain", ] auditlog.register(Contact) @@ -42,3 +44,4 @@ auditlog.register(UserDomainRole) auditlog.register(PublicContact) auditlog.register(User) auditlog.register(Website) +auditlog.register(TransitionDomain) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 5a6d4627f..fc240c158 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -121,6 +121,15 @@ class Domain(TimeStampedModel, DomainHelper): # previously existed but has been deleted from the registry DELETED = "deleted" + # the state is indeterminate + UNKNOWN = "unknown" + + # the ready state for a domain object + READY = "ready" + + # when a domain is on hold + ONHOLD = "onhold" + class Cache(property): """ Python descriptor to turn class methods into properties. @@ -639,12 +648,14 @@ class Domain(TimeStampedModel, DomainHelper): def clientHoldStatus(self): return epp.Status(state=self.Status.CLIENT_HOLD, description="", lang="en") + @transition(field="state", source=[State.READY], target=State.ONHOLD) def _place_client_hold(self): """This domain should not be active. may raises RegistryError, should be caught or handled correctly by caller""" request = commands.UpdateDomain(name=self.name, add=[self.clientHoldStatus()]) registry.send(request) + @transition(field="state", source=[State.ONHOLD], target=State.READY) def _remove_client_hold(self): """This domain is okay to be active. may raises RegistryError, should be caught or handled correctly by caller""" diff --git a/src/registrar/models/transition_domain.py b/src/registrar/models/transition_domain.py new file mode 100644 index 000000000..31da70704 --- /dev/null +++ b/src/registrar/models/transition_domain.py @@ -0,0 +1,42 @@ +from django.db import models + +from .utility.time_stamped_model import TimeStampedModel + + +class TransitionDomain(TimeStampedModel): + """Transition Domain model stores information about the + state of a domain upon transition between registry + providers""" + + class StatusChoices(models.TextChoices): + CREATED = "created", "Created" + HOLD = "hold", "Hold" + + username = models.TextField( + null=False, + blank=False, + verbose_name="Username", + help_text="Username - this will be an email address", + ) + domain_name = models.TextField( + null=True, + blank=True, + verbose_name="Domain name", + ) + status = models.CharField( + max_length=255, + null=False, + blank=True, + choices=StatusChoices.choices, + verbose_name="Status", + help_text="domain status during the transfer", + ) + email_sent = models.BooleanField( + null=False, + default=False, + verbose_name="email sent", + help_text="indicates whether email was sent", + ) + + def __str__(self): + return self.username diff --git a/src/registrar/templates/application_status.html b/src/registrar/templates/application_status.html index 2d59a32eb..67e8e7664 100644 --- a/src/registrar/templates/application_status.html +++ b/src/registrar/templates/application_status.html @@ -6,6 +6,15 @@ {% block content %}
+ + + +

+ Back to manage your domains +

+

Domain request for {{ domainapplication.requested_domain.name }}

+ {% if original.state == original.State.READY %} + + {% elif original.state == original.State.ONHOLD %} + + {% endif %} - - - - - - - -
{{ block.super }} diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index 940183646..c6cd8ebfd 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -430,10 +430,16 @@ def create_user(): return User.objects.create_user( username="staffuser", email="user@example.com", + is_staff=True, password=p, ) +def create_ready_domain(): + domain, _ = Domain.objects.get_or_create(name="city.gov", state=Domain.State.READY) + return domain + + def completed_application( has_other_contacts=True, has_current_website=True, diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 8cd1988f2..28d407a35 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -10,11 +10,11 @@ from registrar.admin import ( AuditedAdmin, ) from registrar.models import ( + Domain, DomainApplication, DomainInformation, User, DomainInvitation, - Domain, ) from .common import ( completed_application, @@ -22,11 +22,13 @@ from .common import ( mock_user, create_superuser, create_user, + create_ready_domain, multiple_unalphabetical_domain_objects, ) from django.contrib.sessions.backends.db import SessionStore from django.contrib.auth import get_user_model from unittest.mock import patch +from unittest import skip from django.conf import settings from unittest.mock import MagicMock @@ -36,6 +38,60 @@ import logging logger = logging.getLogger(__name__) +class TestDomainAdmin(TestCase): + def setUp(self): + self.site = AdminSite() + self.admin = DomainAdmin(model=Domain, admin_site=self.site) + self.client = Client(HTTP_HOST="localhost:8080") + self.superuser = create_superuser() + self.staffuser = create_user() + + def test_place_and_remove_hold(self): + domain = create_ready_domain() + + # get admin page and assert Place Hold button + p = "userpass" + self.client.login(username="staffuser", password=p) + 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, "Place hold") + self.assertNotContains(response, "Remove hold") + + # submit place_client_hold and assert Remove Hold button + response = self.client.post( + "/admin/registrar/domain/{}/change/".format(domain.pk), + {"_place_client_hold": "Place hold", "name": domain.name}, + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Remove hold") + self.assertNotContains(response, "Place hold") + + # submit remove client hold and assert Place hold button + response = self.client.post( + "/admin/registrar/domain/{}/change/".format(domain.pk), + {"_remove_client_hold": "Remove hold", "name": domain.name}, + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, domain.name) + self.assertContains(response, "Place hold") + self.assertNotContains(response, "Remove hold") + + @skip("Waiting on epp lib to implement") + def test_place_and_remove_hold_epp(self): + raise + + def tearDown(self): + Domain.objects.all().delete() + User.objects.all().delete() + + class TestDomainApplicationAdmin(TestCase): def setUp(self): self.site = AdminSite()