diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 8df9ef380..78e756044 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -238,129 +238,6 @@ class MyHostAdmin(AuditedAdmin): inlines = [HostIPInline] -class DomainAdmin(ListHeaderAdmin): - """Custom domain admin class to add extra buttons.""" - - # Columns - list_display = [ - "name", - "organization_type", - "state", - ] - - def organization_type(self, obj): - return obj.domain_info.organization_type - - organization_type.admin_order_field = ( # type: ignore - "domain_info__organization_type" - ) - - # Filters - list_filter = ["domain_info__organization_type"] - - search_fields = ["name"] - search_help_text = "Search by domain name." - change_form_template = "django/admin/domain_change_form.html" - readonly_fields = ["state"] - - 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, - } - - # 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_delete_domain(self, request, obj): - 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(".") - - def do_get_status(self, request, obj): - 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(".") - - 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.revert_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 - if "analyst_action" in request.session: - del request.session["analyst_action"] - del request.session["analyst_action_location"] - return super().change_view(request, object_id) - - def has_change_permission(self, request, obj=None): - # Fixes a bug wherein users which are only is_staff - # can access 'change' when GET, - # but cannot access this page when it is a request of type POST. - if request.user.is_staff: - return True - return super().has_change_permission(request, obj) - - class ContactAdmin(ListHeaderAdmin): """Custom contact admin class to add search.""" @@ -456,6 +333,81 @@ class DomainInformationAdmin(ListHeaderAdmin): ] search_help_text = "Search by domain." + fieldsets = [ + (None, {"fields": ["creator", "domain_application"]}), + ( + "Type of organization", + { + "fields": [ + "organization_type", + "federally_recognized_tribe", + "state_recognized_tribe", + "tribe_name", + "federal_agency", + "federal_type", + "is_election_board", + "about_your_organization", + ] + }, + ), + ( + "Organization name and mailing address", + { + "fields": [ + "organization_name", + "address_line1", + "address_line2", + "city", + "state_territory", + "zipcode", + "urbanization", + ] + }, + ), + ("Authorizing official", {"fields": ["authorizing_official"]}), + (".gov domain", {"fields": ["domain"]}), + ("Your contact information", {"fields": ["submitter"]}), + ("Other employees from your organization?", {"fields": ["other_contacts"]}), + ( + "No other employees from your organization?", + {"fields": ["no_other_contacts_rationale"]}, + ), + ("Anything else we should know?", {"fields": ["anything_else"]}), + ( + "Requirements for operating .gov domains", + {"fields": ["is_policy_acknowledged"]}, + ), + ] + + # Read only that we'll leverage for CISA Analysts + analyst_readonly_fields = [ + "creator", + "type_of_work", + "more_organization_information", + "address_line1", + "address_line2", + "zipcode", + "domain", + "submitter", + "no_other_contacts_rationale", + "anything_else", + "is_policy_acknowledged", + ] + + def get_readonly_fields(self, request, obj=None): + """Set the read-only state on form elements. + We have 1 conditions that determine which fields are read-only: + admin user permissions. + """ + + readonly_fields = list(self.readonly_fields) + + if request.user.is_superuser: + return readonly_fields + else: + readonly_fields.extend([field for field in self.analyst_readonly_fields]) + return readonly_fields + class DomainApplicationAdminForm(forms.ModelForm): """Custom form to limit transitions to available transitions""" @@ -492,10 +444,6 @@ class DomainApplicationAdmin(ListHeaderAdmin): """Custom domain applications admin class.""" - # Set multi-selects 'read-only' (hide selects and show data) - # based on user perms and application creator's status - # form = DomainApplicationForm - # Columns list_display = [ "requested_domain", @@ -705,6 +653,154 @@ class TransitionDomainAdmin(ListHeaderAdmin): search_fields = ["username", "domain_name"] search_help_text = "Search by user or domain name." + +class DomainInformationInline(admin.StackedInline): + """Edit a domain information on the domain page. + We had issues inheriting from both StackedInline + and the source DomainInformationAdmin since these + classes conflict, so we'll just pull what we need + from DomainInformationAdmin""" + + model = models.DomainInformation + + fieldsets = DomainInformationAdmin.fieldsets + analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields + + def get_readonly_fields(self, request, obj=None): + return DomainInformationAdmin.get_readonly_fields(self, request, obj=None) + + +class DomainAdmin(ListHeaderAdmin): + """Custom domain admin class to add extra buttons.""" + + inlines = [DomainInformationInline] + + # Columns + list_display = [ + "name", + "organization_type", + "state", + ] + + def organization_type(self, obj): + return obj.domain_info.organization_type + + organization_type.admin_order_field = ( # type: ignore + "domain_info__organization_type" + ) + + # Filters + list_filter = ["domain_info__organization_type", "state"] + + search_fields = ["name"] + search_help_text = "Search by domain name." + change_form_template = "django/admin/domain_change_form.html" + readonly_fields = ["state"] + + 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, + } + + # 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_delete_domain(self, request, obj): + 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(".") + + def do_get_status(self, request, obj): + 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(".") + + 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.revert_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 + if "analyst_action" in request.session: + del request.session["analyst_action"] + del request.session["analyst_action_location"] + return super().change_view(request, object_id) + + def has_change_permission(self, request, obj=None): + # Fixes a bug wherein users which are only is_staff + # can access 'change' when GET, + # but cannot access this page when it is a request of type POST. + if request.user.is_staff: + return True + return super().has_change_permission(request, obj) + + +class DraftDomainAdmin(ListHeaderAdmin): + """Custom draft domain admin class.""" + + search_fields = ["name"] + search_help_text = "Search by draft domain name." + admin.site.unregister(LogEntry) # Unregister the default registration admin.site.register(LogEntry, CustomLogEntryAdmin) @@ -714,6 +810,7 @@ admin.site.register(models.Contact, ContactAdmin) admin.site.register(models.DomainInvitation, DomainInvitationAdmin) admin.site.register(models.DomainInformation, DomainInformationAdmin) admin.site.register(models.Domain, DomainAdmin) +admin.site.register(models.DraftDomain, DraftDomainAdmin) admin.site.register(models.Host, MyHostAdmin) admin.site.register(models.Nameserver, MyHostAdmin) admin.site.register(models.Website, WebsiteAdmin) diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 923719326..a4e75dd2e 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -143,12 +143,22 @@ class UserFixture: "permissions": ["view_logentry"], }, {"app_label": "registrar", "model": "contact", "permissions": ["view_contact"]}, + { + "app_label": "registrar", + "model": "domaininformation", + "permissions": ["change_domaininformation"], + }, { "app_label": "registrar", "model": "domainapplication", "permissions": ["change_domainapplication"], }, {"app_label": "registrar", "model": "domain", "permissions": ["view_domain"]}, + { + "app_label": "registrar", + "model": "draftdomain", + "permissions": ["change_draftdomain"], + }, {"app_label": "registrar", "model": "user", "permissions": ["change_user"]}, ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index b0bf00082..13405d9bb 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -332,24 +332,23 @@ class Domain(TimeStampedModel, DomainHelper): @Cache def statuses(self) -> list[str]: """ - Get or set the domain `status` elements from the registry. + Get the domain `status` elements from the registry. A domain's status indicates various properties. See Domain.Status. """ - # implementation note: the Status object from EPP stores the string in - # a dataclass property `state`, not to be confused with the `state` field here - if "statuses" not in self._cache: - self._fetch_cache() - if "statuses" not in self._cache: - raise Exception("Can't retreive status from domain info") - else: - return self._cache["statuses"] + try: + return self._get_property("statuses") + except KeyError: + logger.error("Can't retrieve status from domain info") + return [] @statuses.setter # type: ignore def statuses(self, statuses: list[str]): - # TODO: there are a long list of rules in the RFC about which statuses - # can be combined; check that here and raise errors for invalid combinations - - # some statuses cannot be set by the client at all + """ + We will not implement this. Statuses are set by the registry + when we run delete and client hold, and these are the only statuses + we will be triggering. + """ raise NotImplementedError() @Cache diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 5cf1dd71f..5b04c628d 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -45,7 +45,7 @@ class User(AbstractUser): def __str__(self): # this info is pulled from Login.gov if self.first_name or self.last_name: - return f"{self.first_name or ''} {self.last_name or ''}" + return f"{self.first_name or ''} {self.last_name or ''} {self.email or ''}" elif self.email: return self.email else: diff --git a/src/registrar/templates/application_done.html b/src/registrar/templates/application_done.html index 5df03d698..a9ee55b47 100644 --- a/src/registrar/templates/application_done.html +++ b/src/registrar/templates/application_done.html @@ -26,7 +26,7 @@
  • Domain meets our naming requirements
  • -

    You can check the status +

    You can check the status of your request at any time. We'll email you with any questions or when we complete our review.

    diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html index 9c261306a..6a6a0b729 100644 --- a/src/registrar/templates/domain_nameservers.html +++ b/src/registrar/templates/domain_nameservers.html @@ -14,8 +14,6 @@

    Before your domain can be used we'll need information about your domain name servers.

    -

    Get help with domain servers.

    - {% include "includes/required_fields.html" %}
    diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py index e21431321..66d9c2db1 100644 --- a/src/registrar/tests/common.py +++ b/src/registrar/tests/common.py @@ -547,17 +547,29 @@ class MockEppLib(TestCase): class fakedEppObject(object): """""" - def __init__(self, auth_info=..., cr_date=..., contacts=..., hosts=...): + def __init__( + self, + auth_info=..., + cr_date=..., + contacts=..., + hosts=..., + statuses=..., + ): self.auth_info = auth_info self.cr_date = cr_date self.contacts = contacts self.hosts = hosts + self.statuses = statuses mockDataInfoDomain = fakedEppObject( "fakepw", cr_date=datetime.datetime(2023, 5, 25, 19, 45, 35), contacts=[common.DomainContact(contact="123", type="security")], hosts=["fake.host.com"], + statuses=[ + common.Status(state="serverTransferProhibited", description="", lang="en"), + common.Status(state="inactive", description="", lang="en"), + ], ) infoDomainNoContact = fakedEppObject( "security", diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py index 9aaac7321..d35b0ba96 100644 --- a/src/registrar/tests/test_models_domain.py +++ b/src/registrar/tests/test_models_domain.py @@ -34,6 +34,8 @@ class TestDomainCache(MockEppLib): # (see InfoDomainResult) self.assertEquals(domain._cache["auth_info"], self.mockDataInfoDomain.auth_info) self.assertEquals(domain._cache["cr_date"], self.mockDataInfoDomain.cr_date) + status_list = [status.state for status in self.mockDataInfoDomain.statuses] + self.assertEquals(domain._cache["statuses"], status_list) self.assertFalse("avail" in domain._cache.keys()) # using a setter should clear the cache @@ -49,7 +51,8 @@ class TestDomainCache(MockEppLib): ), call(commands.InfoContact(id="123", auth_info=None), cleaned=True), call(commands.InfoHost(name="fake.host.com"), cleaned=True), - ] + ], + any_order=False, # Ensure calls are in the specified order ) def test_cache_used_when_avail(self): @@ -106,16 +109,14 @@ class TestDomainCache(MockEppLib): domain._get_property("hosts") self.assertEqual(domain._cache["hosts"], [expectedHostsDict]) + def tearDown(self) -> None: + Domain.objects.all().delete() + super().tearDown() -class TestDomainCreation(TestCase): + +class TestDomainCreation(MockEppLib): """Rule: An approved domain application must result in a domain""" - def setUp(self): - """ - Background: - Given that a valid domain application exists - """ - def test_approved_application_creates_domain_locally(self): """ Scenario: Analyst approves a domain application @@ -123,8 +124,6 @@ class TestDomainCreation(TestCase): Then a Domain exists in the database with the same `name` But a domain object does not exist in the registry """ - patcher = patch("registrar.models.domain.Domain._get_or_create_domain") - mocked_domain_creation = patcher.start() draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov") user, _ = User.objects.get_or_create() application = DomainApplication.objects.create( @@ -137,19 +136,46 @@ class TestDomainCreation(TestCase): # should hav information present for this domain domain = Domain.objects.get(name="igorville.gov") self.assertTrue(domain) - mocked_domain_creation.assert_not_called() + self.mockedSendFunction.assert_not_called() - @skip("not implemented yet") def test_accessing_domain_properties_creates_domain_in_registry(self): """ Scenario: A registrant checks the status of a newly approved domain Given that no domain object exists in the registry When a property is accessed Then Domain sends `commands.CreateDomain` to the registry - And `domain.state` is set to `CREATED` + And `domain.state` is set to `UNKNOWN` And `domain.is_active()` returns False """ - raise + domain = Domain.objects.create(name="beef-tongue.gov") + # trigger getter + _ = domain.statuses + + # contacts = PublicContact.objects.filter(domain=domain, + # type=PublicContact.ContactTypeChoices.REGISTRANT).get() + + # Called in _fetch_cache + self.mockedSendFunction.assert_has_calls( + [ + # TODO: due to complexity of the test, will return to it in + # a future ticket + # call( + # commands.CreateDomain(name="beef-tongue.gov", + # id=contact.registry_id, auth_info=None), + # cleaned=True, + # ), + call( + commands.InfoDomain(name="beef-tongue.gov", auth_info=None), + cleaned=True, + ), + call(commands.InfoContact(id="123", auth_info=None), cleaned=True), + call(commands.InfoHost(name="fake.host.com"), cleaned=True), + ], + any_order=False, # Ensure calls are in the specified order + ) + + self.assertEqual(domain.state, Domain.State.UNKNOWN) + self.assertEqual(domain.is_active(), False) @skip("assertion broken with mock addition") def test_empty_domain_creation(self): @@ -168,20 +194,71 @@ class TestDomainCreation(TestCase): with self.assertRaisesRegex(IntegrityError, "name"): Domain.objects.create(name="igorville.gov") - @skip("cannot activate a domain without mock registry") - def test_get_status(self): - """Returns proper status based on `state`.""" - domain = Domain.objects.create(name="igorville.gov") - domain.save() - self.assertEqual(None, domain.status) - domain.activate() - domain.save() - self.assertIn("ok", domain.status) - def tearDown(self) -> None: DomainInformation.objects.all().delete() DomainApplication.objects.all().delete() Domain.objects.all().delete() + super().tearDown() + + +class TestDomainStatuses(MockEppLib): + """Domain statuses are set by the registry""" + + def test_get_status(self): + """Domain 'statuses' getter returns statuses by calling epp""" + domain, _ = Domain.objects.get_or_create(name="chicken-liver.gov") + # trigger getter + _ = domain.statuses + status_list = [status.state for status in self.mockDataInfoDomain.statuses] + self.assertEquals(domain._cache["statuses"], status_list) + + # Called in _fetch_cache + self.mockedSendFunction.assert_has_calls( + [ + call( + commands.InfoDomain(name="chicken-liver.gov", auth_info=None), + cleaned=True, + ), + call(commands.InfoContact(id="123", auth_info=None), cleaned=True), + call(commands.InfoHost(name="fake.host.com"), cleaned=True), + ], + any_order=False, # Ensure calls are in the specified order + ) + + def test_get_status_returns_empty_list_when_value_error(self): + """Domain 'statuses' getter returns an empty list + when value error""" + domain, _ = Domain.objects.get_or_create(name="pig-knuckles.gov") + + def side_effect(self): + raise KeyError + + patcher = patch("registrar.models.domain.Domain._get_property") + mocked_get = patcher.start() + mocked_get.side_effect = side_effect + + # trigger getter + _ = domain.statuses + + with self.assertRaises(KeyError): + _ = domain._cache["statuses"] + self.assertEquals(_, []) + + patcher.stop() + + @skip("not implemented yet") + def test_place_client_hold_sets_status(self): + """Domain 'place_client_hold' method causes the registry to change statuses""" + raise + + @skip("not implemented yet") + def test_revert_client_hold_sets_status(self): + """Domain 'revert_client_hold' method causes the registry to change statuses""" + raise + + def tearDown(self) -> None: + Domain.objects.all().delete() + super().tearDown() class TestRegistrantContacts(MockEppLib):