diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 65b5d123a..a373757cd 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -177,31 +177,59 @@ class DomainAdmin(ListHeaderAdmin): readonly_fields = ["state"] def response_change(self, request, obj): - 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(".") - 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,))) + # 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, + } + + # 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 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.remove_client_hold() + 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 @@ -438,3 +466,4 @@ admin.site.register(models.Host, MyHostAdmin) admin.site.register(models.Nameserver, MyHostAdmin) admin.site.register(models.Website, 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 76b01abf7..30924b8bf 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -77,6 +77,11 @@ class UserFixture: "first_name": "David", "last_name": "Kennedy", }, + { + "username": "f14433d8-f0e9-41bf-9c72-b99b110e665d", + "first_name": "Nicolle", + "last_name": "LeClair", + }, ] STAFF = [ @@ -123,6 +128,12 @@ class UserFixture: "last_name": "DiSarli-Analyst", "email": "gaby@truss.works", }, + { + "username": "cfe7c2fc-e24a-480e-8b78-28645a1459b3", + "first_name": "Nicolle-Analyst", + "last_name": "LeClair-Analyst", + "email": "nicolle.leclair@ecstech.com", + }, ] STAFF_PERMISSIONS = [ 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 59563d3d8..306f895c6 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -2,7 +2,7 @@ import logging from datetime import date from string import digits -from django_fsm import FSMField # type: ignore +from django_fsm import FSMField, transition # type: ignore from django.db import models @@ -114,6 +114,12 @@ class Domain(TimeStampedModel, DomainHelper): # 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. @@ -311,13 +317,17 @@ class Domain(TimeStampedModel, DomainHelper): """Time to renew. Not implemented.""" raise NotImplementedError() + @transition(field="state", source=[State.READY], target=State.ONHOLD) def place_client_hold(self): """This domain should not be active.""" - raise NotImplementedError("This is not implemented yet.") + # This method is changing the state of the domain in registrar + # TODO: implement EPP call + @transition(field="state", source=[State.ONHOLD], target=State.READY) def remove_client_hold(self): """This domain is okay to be active.""" - raise NotImplementedError() + # This method is changing the state of the domain in registrar + # TODO: implement EPP call def __str__(self) -> str: return self.name 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 }} {% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/domain_authorizing_official.html b/src/registrar/templates/domain_authorizing_official.html index c08dbb237..c12f1f290 100644 --- a/src/registrar/templates/domain_authorizing_official.html +++ b/src/registrar/templates/domain_authorizing_official.html @@ -10,8 +10,7 @@

Authorizing official

Your authorizing official is the person within your organization who can - authorize domain requests. This is generally the highest-ranking or - highest-elected official in your organization. Read more about who can serve as an authorizing official.

+ authorize domain requests. This person must be in a role of significant, executive responsibility within the organization. Read more about who can serve as an authorizing official.

{% include "includes/required_fields.html" %} 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 1d7915bf3..f2225d877 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -11,11 +11,11 @@ from registrar.admin import ( AuditedAdmin, ) from registrar.models import ( + Domain, DomainApplication, DomainInformation, User, DomainInvitation, - Domain, ) from .common import ( completed_application, @@ -23,11 +23,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 @@ -37,6 +39,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 TestDomainApplicationAdminForm(TestCase): def setUp(self): # Create a test application with an initial state of started