diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 15f1ccb79..8718da9ba 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -9,6 +9,7 @@ from django.conf import settings from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField from registrar.models.domain_information import DomainInformation +from registrar.models.domain_invitation import DomainInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active @@ -1564,7 +1565,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): modified_fieldsets = [] for name, data in fieldsets: fields = data.get("fields", []) - fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields) + fields = [field for field in fields if field not in DomainInformationAdmin.superuser_only_fields] modified_fieldsets.append((name, {**data, "fields": fields})) return modified_fieldsets return fieldsets @@ -2299,10 +2300,58 @@ class DomainInformationInline(admin.StackedInline): template = "django/admin/includes/domain_info_inline_stacked.html" model = models.DomainInformation - fieldsets = DomainInformationAdmin.fieldsets - readonly_fields = DomainInformationAdmin.readonly_fields - analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields - autocomplete_fields = DomainInformationAdmin.autocomplete_fields + fieldsets = copy.deepcopy(list(DomainInformationAdmin.fieldsets)) + analyst_readonly_fields = copy.deepcopy(DomainInformationAdmin.analyst_readonly_fields) + autocomplete_fields = copy.deepcopy(DomainInformationAdmin.autocomplete_fields) + + def get_domain_managers(self, obj): + user_domain_roles = UserDomainRole.objects.filter(domain=obj.domain) + user_ids = user_domain_roles.values_list("user_id", flat=True) + domain_managers = User.objects.filter(id__in=user_ids) + return domain_managers + + def get_domain_invitations(self, obj): + domain_invitations = DomainInvitation.objects.filter( + domain=obj.domain, status=DomainInvitation.DomainInvitationStatus.INVITED + ) + return domain_invitations + + def domain_managers(self, obj): + """Get domain managers for the domain, unpack and return an HTML block.""" + domain_managers = self.get_domain_managers(obj) + if not domain_managers: + return "No domain managers found." + + domain_manager_details = "" + for domain_manager in domain_managers: + full_name = domain_manager.get_formatted_name() + change_url = reverse("admin:registrar_user_change", args=[domain_manager.pk]) + domain_manager_details += "" + domain_manager_details += f'" + domain_manager_details += f"" + domain_manager_details += "" + domain_manager_details += "
UIDNameEmail
{escape(domain_manager.username)}' + domain_manager_details += f"{escape(full_name)}{escape(domain_manager.email)}
" + return format_html(domain_manager_details) + + domain_managers.short_description = "Domain managers" # type: ignore + + def invited_domain_managers(self, obj): + """Get emails which have been invited to the domain, unpack and return an HTML block.""" + domain_invitations = self.get_domain_invitations(obj) + if not domain_invitations: + return "No invited domain managers found." + + domain_invitation_details = "" + "" + for domain_invitation in domain_invitations: + domain_invitation_details += "" + domain_invitation_details += f"" + domain_invitation_details += f"" + domain_invitation_details += "" + domain_invitation_details += "
EmailStatus
{escape(domain_invitation.email)}{escape(domain_invitation.status.capitalize())}
" + return format_html(domain_invitation_details) + + invited_domain_managers.short_description = "Invited domain managers" # type: ignore def has_change_permission(self, request, obj=None): """Custom has_change_permission override so that we can specify that @@ -2342,7 +2391,9 @@ class DomainInformationInline(admin.StackedInline): return super().formfield_for_foreignkey(db_field, request, **kwargs) def get_readonly_fields(self, request, obj=None): - return DomainInformationAdmin.get_readonly_fields(self, request, obj=None) + readonly_fields = copy.deepcopy(DomainInformationAdmin.get_readonly_fields(self, request, obj=None)) + readonly_fields.extend(["domain_managers", "invited_domain_managers"]) # type: ignore + return readonly_fields # Re-route the get_fieldsets method to utilize DomainInformationAdmin.get_fieldsets # since that has all the logic for excluding certain fields according to user permissions. @@ -2351,13 +2402,34 @@ class DomainInformationInline(admin.StackedInline): def get_fieldsets(self, request, obj=None): # Grab fieldsets from DomainInformationAdmin so that it handles all logic # for permission-based field visibility. - modified_fieldsets = DomainInformationAdmin.get_fieldsets(self, request, obj=None) + modified_fieldsets = copy.deepcopy(DomainInformationAdmin.get_fieldsets(self, request, obj=None)) - # remove .gov domain from fieldset + # Modify fieldset sections in place + for index, (title, options) in enumerate(modified_fieldsets): + if title is None: + options["fields"] = [ + field for field in options["fields"] if field not in ["creator", "domain_request", "notes"] + ] + elif title == "Contacts": + options["fields"] = [ + field + for field in options["fields"] + if field not in ["other_contacts", "no_other_contacts_rationale"] + ] + options["fields"].extend(["domain_managers", "invited_domain_managers"]) # type: ignore + elif title == "Background info": + # move domain request and notes to background + options["fields"].extend(["domain_request", "notes"]) # type: ignore + + # Remove or remove fieldset sections for index, (title, f) in enumerate(modified_fieldsets): if title == ".gov domain": - del modified_fieldsets[index] - break + # remove .gov domain from fieldset + modified_fieldsets.pop(index) + elif title == "Background info": + # move Background info to the bottom of the list + fieldsets_to_move = modified_fieldsets.pop(index) + modified_fieldsets.append(fieldsets_to_move) return modified_fieldsets @@ -2415,13 +2487,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): fieldsets = ( ( None, - {"fields": ["name", "state", "expiration_date", "first_ready", "deleted"]}, + {"fields": ["state", "expiration_date", "first_ready", "deleted", "dnssecdata", "nameservers"]}, ), ) - # this ordering effects the ordering of results in autocomplete_fields for domain - ordering = ["name"] - def generic_org_type(self, obj): return obj.domain_info.get_generic_org_type_display() @@ -2442,6 +2511,28 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): organization_name.admin_order_field = "domain_info__organization_name" # type: ignore + def dnssecdata(self, obj): + return "Yes" if obj.dnssecdata else "No" + + dnssecdata.short_description = "DNSSEC enabled" # type: ignore + + # Custom method to display formatted nameservers + def nameservers(self, obj): + if not obj.nameservers: + return "No nameservers" + + formatted_nameservers = [] + for server, ip_list in obj.nameservers: + server_display = str(server) + if ip_list: + server_display += f" [{', '.join(ip_list)}]" + formatted_nameservers.append(server_display) + + # Join the formatted strings with line breaks + return "\n".join(formatted_nameservers) + + nameservers.short_description = "Name servers" # type: ignore + def custom_election_board(self, obj): domain_info = getattr(obj, "domain_info", None) if domain_info: @@ -2468,7 +2559,15 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): search_fields = ["name"] search_help_text = "Search by domain name." change_form_template = "django/admin/domain_change_form.html" - readonly_fields = ("state", "expiration_date", "first_ready", "deleted", "federal_agency") + readonly_fields = ( + "state", + "expiration_date", + "first_ready", + "deleted", + "federal_agency", + "dnssecdata", + "nameservers", + ) # Table ordering ordering = ["name"] diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 0540a7b60..bee42d24e 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -119,7 +119,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endfor %} {% endwith %} - {% elif field.field.name == "display_admins" %} + {% elif field.field.name == "display_admins" or field.field.name == "domain_managers" or field.field.namd == "invited_domain_managers" %}
{{ field.contents|safe }}
{% elif field.field.name == "display_members" %}
diff --git a/src/registrar/tests/test_admin_domain.py b/src/registrar/tests/test_admin_domain.py index 49f095a25..a9b94781f 100644 --- a/src/registrar/tests/test_admin_domain.py +++ b/src/registrar/tests/test_admin_domain.py @@ -167,12 +167,6 @@ class TestDomainAdminAsStaff(MockEppLib): expected_organization_name = "MonkeySeeMonkeyDo" self.assertContains(response, expected_organization_name) - # clean up this test's data - domain.delete() - domain_information.delete() - _domain_request.delete() - _creator.delete() - @less_console_noise_decorator def test_deletion_is_successful(self): """ @@ -227,9 +221,6 @@ class TestDomainAdminAsStaff(MockEppLib): self.assertEqual(domain.state, Domain.State.DELETED) - # clean up data within this test - domain.delete() - @less_console_noise_decorator def test_deletion_ready_fsm_failure(self): """ @@ -269,9 +260,6 @@ class TestDomainAdminAsStaff(MockEppLib): self.assertEqual(domain.state, Domain.State.READY) - # delete data created in this test - domain.delete() - @less_console_noise_decorator def test_analyst_deletes_domain_idempotent(self): """ @@ -330,8 +318,130 @@ class TestDomainAdminAsStaff(MockEppLib): ) self.assertEqual(domain.state, Domain.State.DELETED) - # delete data created in this test - domain.delete() + +class TestDomainInformationInline(MockEppLib): + """Test DomainAdmin class, specifically the DomainInformationInline class, as staff user. + + Notes: + all tests share staffuser; do not change staffuser model in tests + tests have available staffuser, client, and admin + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.staffuser = create_user() + cls.site = AdminSite() + cls.admin = DomainAdmin(model=Domain, admin_site=cls.site) + cls.factory = RequestFactory() + + def setUp(self): + self.client = Client(HTTP_HOST="localhost:8080") + self.client.force_login(self.staffuser) + super().setUp() + + def tearDown(self): + super().tearDown() + Host.objects.all().delete() + UserDomainRole.objects.all().delete() + Domain.objects.all().delete() + DomainInformation.objects.all().delete() + DomainRequest.objects.all().delete() + + @classmethod + def tearDownClass(cls): + User.objects.all().delete() + super().tearDownClass() + + @less_console_noise_decorator + def test_domain_managers_display(self): + """Tests the custom domain managers field""" + admin_user_1 = User.objects.create( + username="testuser1", + first_name="Gerald", + last_name="Meoward", + email="meoward@gov.gov", + ) + + domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=self.staffuser, name="fake.gov" + ) + domain_request.approve() + _domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() + domain = Domain.objects.filter(domain_info=_domain_info).get() + + UserDomainRole.objects.get_or_create(user=admin_user_1, domain=domain, role=UserDomainRole.Roles.MANAGER) + + admin_user_2 = User.objects.create( + username="testuser2", + first_name="Arnold", + last_name="Poopy", + email="poopy@gov.gov", + ) + + UserDomainRole.objects.get_or_create(user=admin_user_2, domain=domain, role=UserDomainRole.Roles.MANAGER) + + # Get the first inline (DomainInformationInline) + inline_instance = self.admin.inlines[0](self.admin.model, self.admin.admin_site) + + # Call the domain_managers method + domain_managers = inline_instance.domain_managers(domain.domain_info) + + self.assertIn( + f'testuser1', + domain_managers, + ) + self.assertIn("Gerald Meoward", domain_managers) + self.assertIn("meoward@gov.gov", domain_managers) + self.assertIn(f'testuser2', domain_managers) + self.assertIn("Arnold Poopy", domain_managers) + self.assertIn("poopy@gov.gov", domain_managers) + + @less_console_noise_decorator + def test_invited_domain_managers_display(self): + """Tests the custom invited domain managers field""" + admin_user_1 = User.objects.create( + username="testuser1", + first_name="Gerald", + last_name="Meoward", + email="meoward@gov.gov", + ) + + domain_request = completed_domain_request( + status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=self.staffuser, name="fake.gov" + ) + domain_request.approve() + _domain_info = DomainInformation.objects.filter(domain=domain_request.approved_domain).get() + domain = Domain.objects.filter(domain_info=_domain_info).get() + + # domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY) + UserDomainRole.objects.get_or_create(user=admin_user_1, domain=domain, role=UserDomainRole.Roles.MANAGER) + + admin_user_2 = User.objects.create( + username="testuser2", + first_name="Arnold", + last_name="Poopy", + email="poopy@gov.gov", + ) + + UserDomainRole.objects.get_or_create(user=admin_user_2, domain=domain, role=UserDomainRole.Roles.MANAGER) + + # Get the first inline (DomainInformationInline) + inline_instance = self.admin.inlines[0](self.admin.model, self.admin.admin_site) + + # Call the domain_managers method + domain_managers = inline_instance.domain_managers(domain.domain_info) + # domain_managers = self.admin.get_inlinesdomain_managers(self.domain) + + self.assertIn( + f'testuser1', + domain_managers, + ) + self.assertIn("Gerald Meoward", domain_managers) + self.assertIn("meoward@gov.gov", domain_managers) + self.assertIn(f'testuser2', domain_managers) + self.assertIn("Arnold Poopy", domain_managers) + self.assertIn("poopy@gov.gov", domain_managers) class TestDomainAdminWithClient(TestCase): @@ -415,17 +525,6 @@ class TestDomainAdminWithClient(TestCase): self.assertContains(response, domain.name) # Check that the fields have the right values. - # == Check for the creator == # - - # Check for the right title, email, and phone number in the response. - # We only need to check for the end tag - # (Otherwise this test will fail if we change classes, etc) - self.assertContains(response, "Treat inspector") - self.assertContains(response, "meoward.jones@igorville.gov") - self.assertContains(response, "(555) 123 12345") - - # Check for the field itself - self.assertContains(response, "Meoward Jones") # == Check for the senior_official == # self.assertContains(response, "testy@town.com") @@ -435,11 +534,6 @@ class TestDomainAdminWithClient(TestCase): # Includes things like readonly fields self.assertContains(response, "Testy Tester") - # == Test the other_employees field == # - self.assertContains(response, "testy2@town.com") - self.assertContains(response, "Another Tester") - self.assertContains(response, "(555) 555 5557") - # Test for the copy link self.assertContains(response, "button--clipboard")