Repository: cisagov/manage.get.gov

Issue:
Add sortable columns on the Domain and Domain Application table (see list)
### Story

As a CISA analyst
I want some additional columns (with sorting) on the Domain and Domain Application table (listed below) so that that I can prioritize the most significant requests.

### Acceptance Criteria

As a CISA analyst, I can view and sort by:
- [ ] agency name
- [ ] federal branch
- [ ]  organization
- [ ] election office
- [ ]  city
- [ ]  state

### Additional Context

[Slack discussion](https://cisa-corp.slack.com/archives/C066ZBWFLRW/p1708006622516019)

### Issue Links

_No response_


Discussion:

Resolving patch:
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -3,7 +3,7 @@
 
 from django import forms
 from django.db.models.functions import Concat, Coalesce
-from django.db.models import Value, CharField
+from django.db.models import Value, CharField, Q
 from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import redirect
 from django_fsm import get_available_FIELD_transitions
@@ -24,7 +24,7 @@
 from django_fsm import TransitionNotAllowed  # type: ignore
 from django.utils.safestring import mark_safe
 from django.utils.html import escape
-
+from django.utils.translation import gettext_lazy as _
 
 logger = logging.getLogger(__name__)
 
@@ -855,11 +855,35 @@ def queryset(self, request, queryset):
             else:
                 return queryset.filter(investigator__id__exact=self.value())
 
+    class ElectionOfficeFilter(admin.SimpleListFilter):
+        """Define a custom filter for is_election_board"""
+
+        title = _("election office")
+        parameter_name = "is_election_board"
+
+        def lookups(self, request, model_admin):
+            return (
+                ("1", _("Yes")),
+                ("0", _("No")),
+            )
+
+        def queryset(self, request, queryset):
+            if self.value() == "1":
+                return queryset.filter(is_election_board=True)
+            if self.value() == "0":
+                return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
+
     # Columns
     list_display = [
         "requested_domain",
         "status",
         "organization_type",
+        "federal_type",
+        "federal_agency",
+        "organization_name",
+        "custom_election_board",
+        "city",
+        "state_territory",
         "created_at",
         "submitter",
         "investigator",
@@ -871,8 +895,21 @@ def queryset(self, request, queryset):
         ("investigator", ["first_name", "last_name"]),
     ]
 
+    def custom_election_board(self, obj):
+        return "Yes" if obj.is_election_board else "No"
+
+    custom_election_board.admin_order_field = "is_election_board"  # type: ignore
+    custom_election_board.short_description = "Election office"  # type: ignore
+
     # Filters
-    list_filter = ("status", "organization_type", "rejection_reason", InvestigatorFilter)
+    list_filter = (
+        "status",
+        "organization_type",
+        "federal_type",
+        ElectionOfficeFilter,
+        "rejection_reason",
+        InvestigatorFilter,
+    )
 
     # Search
     search_fields = [
@@ -1142,12 +1179,37 @@ def get_readonly_fields(self, request, obj=None):
 class DomainAdmin(ListHeaderAdmin):
     """Custom domain admin class to add extra buttons."""
 
+    class ElectionOfficeFilter(admin.SimpleListFilter):
+        """Define a custom filter for is_election_board"""
+
+        title = _("election office")
+        parameter_name = "is_election_board"
+
+        def lookups(self, request, model_admin):
+            return (
+                ("1", _("Yes")),
+                ("0", _("No")),
+            )
+
+        def queryset(self, request, queryset):
+            logger.debug(self.value())
+            if self.value() == "1":
+                return queryset.filter(domain_info__is_election_board=True)
+            if self.value() == "0":
+                return queryset.filter(Q(domain_info__is_election_board=False) | Q(domain_info__is_election_board=None))
+
     inlines = [DomainInformationInline]
 
     # Columns
     list_display = [
         "name",
         "organization_type",
+        "federal_type",
+        "federal_agency",
+        "organization_name",
+        "custom_election_board",
+        "city",
+        "state_territory",
         "state",
         "expiration_date",
         "created_at",
@@ -1171,8 +1233,42 @@ def organization_type(self, obj):
 
     organization_type.admin_order_field = "domain_info__organization_type"  # type: ignore
 
+    def federal_agency(self, obj):
+        return obj.domain_info.federal_agency if obj.domain_info else None
+
+    federal_agency.admin_order_field = "domain_info__federal_agency"  # type: ignore
+
+    def federal_type(self, obj):
+        return obj.domain_info.federal_type if obj.domain_info else None
+
+    federal_type.admin_order_field = "domain_info__federal_type"  # type: ignore
+
+    def organization_name(self, obj):
+        return obj.domain_info.organization_name if obj.domain_info else None
+
+    organization_name.admin_order_field = "domain_info__organization_name"  # type: ignore
+
+    def custom_election_board(self, obj):
+        domain_info = getattr(obj, "domain_info", None)
+        if domain_info:
+            return "Yes" if domain_info.is_election_board else "No"
+        return "No"
+
+    custom_election_board.admin_order_field = "domain_info__is_election_board"  # type: ignore
+    custom_election_board.short_description = "Election office"  # type: ignore
+
+    def city(self, obj):
+        return obj.domain_info.city if obj.domain_info else None
+
+    city.admin_order_field = "domain_info__city"  # type: ignore
+
+    def state_territory(self, obj):
+        return obj.domain_info.state_territory if obj.domain_info else None
+
+    state_territory.admin_order_field = "domain_info__state_territory"  # type: ignore
+
     # Filters
-    list_filter = ["domain_info__organization_type", "state"]
+    list_filter = ["domain_info__organization_type", "domain_info__federal_type", ElectionOfficeFilter, "state"]
 
     search_fields = ["name"]
     search_help_text = "Search by domain name."


Test patch:
diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py
--- a/src/registrar/tests/test_admin.py
+++ b/src/registrar/tests/test_admin.py
@@ -243,9 +243,9 @@ def test_short_org_name_in_domains_list(self):
 
             response = self.client.get("/admin/registrar/domain/")
 
-            # There are 3 template references to Federal (3) plus one reference in the table
+            # There are 4 template references to Federal (4) plus four references in the table
             # for our actual application
-            self.assertContains(response, "Federal", count=4)
+            self.assertContains(response, "Federal", count=8)
             # This may be a bit more robust
             self.assertContains(response, '<td class="field-organization_type">Federal</td>', count=1)
             # Now let's make sure the long description does not exist
@@ -532,7 +532,7 @@ def test_submitter_sortable(self):
 
             # Assert that our sort works correctly
             self.test_helper.assert_table_sorted(
-                "5",
+                "11",
                 (
                     "submitter__first_name",
                     "submitter__last_name",
@@ -541,7 +541,7 @@ def test_submitter_sortable(self):
 
             # Assert that sorting in reverse works correctly
             self.test_helper.assert_table_sorted(
-                "-5",
+                "-11",
                 (
                     "-submitter__first_name",
                     "-submitter__last_name",
@@ -586,9 +586,9 @@ def test_short_org_name_in_applications_list(self):
             self.client.force_login(self.superuser)
             completed_application()
             response = self.client.get("/admin/registrar/domainapplication/")
-            # There are 3 template references to Federal (3) plus one reference in the table
+            # There are 4 template references to Federal (4) plus two references in the table
             # for our actual application
-            self.assertContains(response, "Federal", count=4)
+            self.assertContains(response, "Federal", count=6)
             # This may be a bit more robust
             self.assertContains(response, '<td class="field-organization_type">Federal</td>', count=1)
             # Now let's make sure the long description does not exist
@@ -1070,8 +1070,8 @@ def test_save_model_sends_withdrawn_email(self):
             self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
 
     def test_save_model_sets_approved_domain(self):
-        # make sure there is no user with this email
         with less_console_noise():
+            # make sure there is no user with this email
             EMAIL = "mayor@igorville.gov"
             User.objects.filter(email=EMAIL).delete()
 
@@ -1082,12 +1082,11 @@ def test_save_model_sets_approved_domain(self):
             request = self.factory.post("/admin/registrar/domainapplication/{}/change/".format(application.pk))
 
             with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
-                with less_console_noise():
-                    # Modify the application's property
-                    application.status = DomainApplication.ApplicationStatus.APPROVED
+                # Modify the application's property
+                application.status = DomainApplication.ApplicationStatus.APPROVED
 
-                    # Use the model admin's save_model method
-                    self.admin.save_model(request, application, form=None, change=True)
+                # Use the model admin's save_model method
+                self.admin.save_model(request, application, form=None, change=True)
 
             # Test that approved domain exists and equals requested domain
             self.assertEqual(application.requested_domain.name, application.approved_domain.name)
@@ -1343,6 +1342,8 @@ def test_has_correct_filters(self):
             expected_fields = (
                 "status",
                 "organization_type",
+                "federal_type",
+                DomainApplicationAdmin.ElectionOfficeFilter,
                 "rejection_reason",
                 DomainApplicationAdmin.InvestigatorFilter,
             )
@@ -1622,8 +1623,8 @@ def tearDown(self):
         User.objects.all().delete()
 
     def test_readonly_fields_for_analyst(self):
+        """Ensures that analysts have their permissions setup correctly"""
         with less_console_noise():
-            """Ensures that analysts have their permissions setup correctly"""
             request = self.factory.get("/")
             request.user = self.staffuser
 
@@ -1931,15 +1932,16 @@ def setUp(self):
         self.client = Client(HTTP_HOST="localhost:8080")
 
     def order_by_desired_field_helper(self, obj_to_sort: AuditedAdmin, request, field_name, *obj_names):
-        formatted_sort_fields = []
-        for obj in obj_names:
-            formatted_sort_fields.append("{}__{}".format(field_name, obj))
+        with less_console_noise():
+            formatted_sort_fields = []
+            for obj in obj_names:
+                formatted_sort_fields.append("{}__{}".format(field_name, obj))
 
-        ordered_list = list(
-            obj_to_sort.get_queryset(request).order_by(*formatted_sort_fields).values_list(*formatted_sort_fields)
-        )
+            ordered_list = list(
+                obj_to_sort.get_queryset(request).order_by(*formatted_sort_fields).values_list(*formatted_sort_fields)
+            )
 
-        return ordered_list
+            return ordered_list
 
     def test_alphabetically_sorted_domain_application_investigator(self):
         """Tests if the investigator field is alphabetically sorted by mimicking
@@ -2007,32 +2009,32 @@ def test_alphabetically_sorted_fk_fields_domain_application(self):
                         sorted_fields = ["first_name", "last_name"]
                     # We want both of these to be lists, as it is richer test wise.
 
-                desired_order = self.order_by_desired_field_helper(model_admin, request, field.name, *sorted_fields)
-                current_sort_order = list(model_admin.formfield_for_foreignkey(field, request).queryset)
-
-                # Conforms to the same object structure as desired_order
-                current_sort_order_coerced_type = []
-
-                # This is necessary as .queryset and get_queryset
-                # return lists of different types/structures.
-                # We need to parse this data and coerce them into the same type.
-                for contact in current_sort_order:
-                    if not isNamefield:
-                        first = contact.first_name
-                        last = contact.last_name
-                    else:
-                        first = contact.name
-                        last = None
-
-                    name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":")
-                    if name_tuple is not None:
-                        current_sort_order_coerced_type.append(name_tuple)
-
-                self.assertEqual(
-                    desired_order,
-                    current_sort_order_coerced_type,
-                    "{} is not ordered alphabetically".format(field.name),
-                )
+                    desired_order = self.order_by_desired_field_helper(model_admin, request, field.name, *sorted_fields)
+                    current_sort_order = list(model_admin.formfield_for_foreignkey(field, request).queryset)
+
+                    # Conforms to the same object structure as desired_order
+                    current_sort_order_coerced_type = []
+
+                    # This is necessary as .queryset and get_queryset
+                    # return lists of different types/structures.
+                    # We need to parse this data and coerce them into the same type.
+                    for contact in current_sort_order:
+                        if not isNamefield:
+                            first = contact.first_name
+                            last = contact.last_name
+                        else:
+                            first = contact.name
+                            last = None
+
+                        name_tuple = self.coerced_fk_field_helper(first, last, field.name, ":")
+                        if name_tuple is not None:
+                            current_sort_order_coerced_type.append(name_tuple)
+
+                    self.assertEqual(
+                        desired_order,
+                        current_sort_order_coerced_type,
+                        "{} is not ordered alphabetically".format(field.name),
+                    )
 
     def test_alphabetically_sorted_fk_fields_domain_information(self):
         with less_console_noise():
@@ -2319,7 +2321,6 @@ def test_readonly_when_restricted_superuser(self):
     def test_change_view_for_joined_contact_five_or_less(self):
         """Create a contact, join it to 4 domain requests. The 5th join will be a user.
         Assert that the warning on the contact form lists 5 joins."""
-
         with less_console_noise():
             self.client.force_login(self.superuser)
 
diff --git a/src/registrar/tests/test_models_domain.py b/src/registrar/tests/test_models_domain.py
--- a/src/registrar/tests/test_models_domain.py
+++ b/src/registrar/tests/test_models_domain.py
@@ -321,21 +321,21 @@ def test_approved_application_creates_domain_locally(self):
             Then a Domain exists in the database with the same `name`
             But a domain object does not exist in the registry
         """
-        draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
-        user, _ = User.objects.get_or_create()
-        application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain)
+        with less_console_noise():
+            draft_domain, _ = DraftDomain.objects.get_or_create(name="igorville.gov")
+            user, _ = User.objects.get_or_create()
+            application = DomainApplication.objects.create(creator=user, requested_domain=draft_domain)
 
-        mock_client = MockSESClient()
-        with boto3_mocking.clients.handler_for("sesv2", mock_client):
-            with less_console_noise():
+            mock_client = MockSESClient()
+            with boto3_mocking.clients.handler_for("sesv2", mock_client):
                 # skip using the submit method
                 application.status = DomainApplication.ApplicationStatus.SUBMITTED
                 # transition to approve state
                 application.approve()
-        # should have information present for this domain
-        domain = Domain.objects.get(name="igorville.gov")
-        self.assertTrue(domain)
-        self.mockedSendFunction.assert_not_called()
+            # should have information present for this domain
+            domain = Domain.objects.get(name="igorville.gov")
+            self.assertTrue(domain)
+            self.mockedSendFunction.assert_not_called()
 
     def test_accessing_domain_properties_creates_domain_in_registry(self):
         """
@@ -346,33 +346,34 @@ def test_accessing_domain_properties_creates_domain_in_registry(self):
             And `domain.state` is set to `UNKNOWN`
             And `domain.is_active()` returns False
         """
-        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,
-                ),
-            ],
-            any_order=False,  # Ensure calls are in the specified order
-        )
+        with less_console_noise():
+            domain = Domain.objects.create(name="beef-tongue.gov")
+            # trigger getter
+            _ = domain.statuses
+
+            # contacts = PublicContact.objects.filter(domain=domain,
+            # type=PublicContact.ContactTypeChoices.REGISTRANT).get()
 
-        self.assertEqual(domain.state, Domain.State.UNKNOWN)
-        self.assertEqual(domain.is_active(), False)
+            # 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,
+                    ),
+                ],
+                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):
@@ -382,7 +383,8 @@ def test_empty_domain_creation(self):
 
     def test_minimal_creation(self):
         """Can create with just a name."""
-        Domain.objects.create(name="igorville.gov")
+        with less_console_noise():
+            Domain.objects.create(name="igorville.gov")
 
     @skip("assertion broken with mock addition")
     def test_duplicate_creation(self):
@@ -504,23 +506,24 @@ def side_effect(_request, cleaned):
                 res_data=[responses.check.CheckDomainResultData(name="available.gov", avail=True, reason=None)],
             )
 
-        patcher = patch("registrar.models.domain.registry.send")
-        mocked_send = patcher.start()
-        mocked_send.side_effect = side_effect
+        with less_console_noise():
+            patcher = patch("registrar.models.domain.registry.send")
+            mocked_send = patcher.start()
+            mocked_send.side_effect = side_effect
 
-        available = Domain.available("available.gov")
-        mocked_send.assert_has_calls(
-            [
-                call(
-                    commands.CheckDomain(
-                        ["available.gov"],
-                    ),
-                    cleaned=True,
-                )
-            ]
-        )
-        self.assertTrue(available)
-        patcher.stop()
+            available = Domain.available("available.gov")
+            mocked_send.assert_has_calls(
+                [
+                    call(
+                        commands.CheckDomain(
+                            ["available.gov"],
+                        ),
+                        cleaned=True,
+                    )
+                ]
+            )
+            self.assertTrue(available)
+            patcher.stop()
 
     def test_domain_unavailable(self):
         """
@@ -537,23 +540,24 @@ def side_effect(_request, cleaned):
                 res_data=[responses.check.CheckDomainResultData(name="unavailable.gov", avail=False, reason="In Use")],
             )
 
-        patcher = patch("registrar.models.domain.registry.send")
-        mocked_send = patcher.start()
-        mocked_send.side_effect = side_effect
+        with less_console_noise():
+            patcher = patch("registrar.models.domain.registry.send")
+            mocked_send = patcher.start()
+            mocked_send.side_effect = side_effect
 
-        available = Domain.available("unavailable.gov")
-        mocked_send.assert_has_calls(
-            [
-                call(
-                    commands.CheckDomain(
-                        ["unavailable.gov"],
-                    ),
-                    cleaned=True,
-                )
-            ]
-        )
-        self.assertFalse(available)
-        patcher.stop()
+            available = Domain.available("unavailable.gov")
+            mocked_send.assert_has_calls(
+                [
+                    call(
+                        commands.CheckDomain(
+                            ["unavailable.gov"],
+                        ),
+                        cleaned=True,
+                    )
+                ]
+            )
+            self.assertFalse(available)
+            patcher.stop()
 
     def test_domain_available_with_invalid_error(self):
         """
@@ -562,8 +566,9 @@ def test_domain_available_with_invalid_error(self):
 
             Validate InvalidDomainError is raised
         """
-        with self.assertRaises(errors.InvalidDomainError):
-            Domain.available("invalid-string")
+        with less_console_noise():
+            with self.assertRaises(errors.InvalidDomainError):
+                Domain.available("invalid-string")
 
     def test_domain_available_with_empty_string(self):
         """
@@ -572,8 +577,9 @@ def test_domain_available_with_empty_string(self):
 
             Validate InvalidDomainError is raised
         """
-        with self.assertRaises(errors.InvalidDomainError):
-            Domain.available("")
+        with less_console_noise():
+            with self.assertRaises(errors.InvalidDomainError):
+                Domain.available("")
 
     def test_domain_available_unsuccessful(self):
         """
@@ -585,13 +591,14 @@ def test_domain_available_unsuccessful(self):
         def side_effect(_request, cleaned):
             raise RegistryError(code=ErrorCode.COMMAND_SYNTAX_ERROR)
 
-        patcher = patch("registrar.models.domain.registry.send")
-        mocked_send = patcher.start()
-        mocked_send.side_effect = side_effect
+        with less_console_noise():
+            patcher = patch("registrar.models.domain.registry.send")
+            mocked_send = patcher.start()
+            mocked_send.side_effect = side_effect
 
-        with self.assertRaises(RegistryError):
-            Domain.available("raises-error.gov")
-        patcher.stop()
+            with self.assertRaises(RegistryError):
+                Domain.available("raises-error.gov")
+            patcher.stop()
 
 
 class TestRegistrantContacts(MockEppLib):