Repository: cisagov/manage.get.gov

Issue:
Dev: List all associated domains and requests for `users`
### Issue description

As an analyst
I want to be able to view all the existing domains or requests associated with a particular **user**
so that I can adjudicate requests much easier

### Acceptance criteria
In Django admin:
- [ ] On an individual user record, below contact information, list all domains a user is associated with EXCEPT those in "deleted" state. Other criteria include:
  - [ ] Each domain links to the corresponding "domains" record
  - [ ] Each domain also displays its current state in parenthesis. For example: domain.gov (Ready)
  - [ ] Domains are listed in alphabetical order (starting with numerical, if applicable)
- [ ] On an individual user record, below list of domains, list all domain requests a user is associated with EXCEPT those in "started" or "withdrawn" status. Other criteria include:
  - [ ] Each request links to the corresponding "domain request" record
  - [ ] Each request also displays its current status in parenthesis. For example: domain.gov (In Review)
  - [ ] Domains are listed in alphabetical order (starting with numerical, if applicable)
- [ ] Before creating PR, confirm implementation plan with designer and share early iterations of the layout.


### Additional context

**NOTE:** A user can be associated with dozens of domains, so the design should accommodate that; explore ways to display many values without creating a long scrolling page. 

[Slack discussion thread
](https://cisa-corp.slack.com/archives/C05TS76RVS6/p1715179482192209)

### Links to other issues

related to: #1668 (this ticket was closed; design phased was skipped)



Discussion:
"Blocked" tag added: dependency on design ticket
Moved to "Backlog" status because it was split from #1668.
Blocked label: this is blocked by design ticket which is currently scheduled for sprint 45
Per agreement from design and dev (in refinement meeting), to merge the design (#1668) and this dev tickets into one ticket given the straight-forwardness of the design.
"refinement" label added: questions in slack discussion need to be addressed by product.
@Katherine-Osos agreed to update the ACs with notes from the refinement meeting and the Slack channel.

Resolving patch:
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -15,6 +15,7 @@
 from django.urls import reverse
 from dateutil.relativedelta import relativedelta  # type: ignore
 from epplibwrapper.errors import ErrorCode, RegistryError
+from registrar.models.user_domain_role import UserDomainRole
 from waffle.admin import FlagAdmin
 from waffle.models import Sample, Switch
 from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website
@@ -555,6 +556,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
     resource_classes = [UserResource]
 
     form = MyUserAdminForm
+    change_form_template = "django/admin/user_change_form.html"
 
     class Meta:
         """Contains meta information about this class"""
@@ -594,7 +596,7 @@ def overridden_email_field(self, obj):
             None,
             {"fields": ("username", "password", "status", "verification_type")},
         ),
-        ("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
+        ("Personal info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
         (
             "Permissions",
             {
@@ -673,8 +675,6 @@ def overridden_email_field(self, obj):
     ordering = ["first_name", "last_name", "email"]
     search_help_text = "Search by first name, last name, or email."
 
-    change_form_template = "django/admin/email_clipboard_change_form.html"
-
     def get_search_results(self, request, queryset, search_term):
         """
         Override for get_search_results. This affects any upstream model using autocomplete_fields,
@@ -754,6 +754,23 @@ def get_readonly_fields(self, request, obj=None):
             # users who might not belong to groups
             return self.analyst_readonly_fields
 
+    def change_view(self, request, object_id, form_url="", extra_context=None):
+        """Add user's related domains and requests to context"""
+        obj = self.get_object(request, object_id)
+
+        domain_requests = DomainRequest.objects.filter(creator=obj).exclude(
+            Q(status=DomainRequest.DomainRequestStatus.STARTED) | Q(status=DomainRequest.DomainRequestStatus.WITHDRAWN)
+        )
+        sort_by = request.GET.get("sort_by", "requested_domain__name")
+        domain_requests = domain_requests.order_by(sort_by)
+
+        user_domain_roles = UserDomainRole.objects.filter(user=obj)
+        domain_ids = user_domain_roles.values_list("domain_id", flat=True)
+        domains = Domain.objects.filter(id__in=domain_ids).exclude(state=Domain.State.DELETED)
+
+        extra_context = {"domain_requests": domain_requests, "domains": domains}
+        return super().change_view(request, object_id, form_url, extra_context)
+
 
 class HostIPInline(admin.StackedInline):
     """Edit an ip address on the host page."""


Test patch:
diff --git a/src/registrar/tests/common.py b/src/registrar/tests/common.py
--- a/src/registrar/tests/common.py
+++ b/src/registrar/tests/common.py
@@ -667,7 +667,7 @@ def setUp(self):
             is_election_board=False,
         )
 
-        meoward_user = get_user_model().objects.create(
+        self.meoward_user = get_user_model().objects.create(
             username="meoward_username", first_name="first_meoward", last_name="last_meoward", email="meoward@rocks.com"
         )
 
@@ -676,7 +676,7 @@ def setUp(self):
         )
 
         _, created = UserDomainRole.objects.get_or_create(
-            user=meoward_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER
+            user=self.meoward_user, domain=self.domain_1, role=UserDomainRole.Roles.MANAGER
         )
 
         _, created = UserDomainRole.objects.get_or_create(
@@ -688,19 +688,21 @@ def setUp(self):
         )
 
         _, created = UserDomainRole.objects.get_or_create(
-            user=meoward_user, domain=self.domain_2, role=UserDomainRole.Roles.MANAGER
+            user=self.meoward_user, domain=self.domain_2, role=UserDomainRole.Roles.MANAGER
         )
 
         _, created = UserDomainRole.objects.get_or_create(
-            user=meoward_user, domain=self.domain_11, role=UserDomainRole.Roles.MANAGER
+            user=self.meoward_user, domain=self.domain_11, role=UserDomainRole.Roles.MANAGER
         )
 
         _, created = UserDomainRole.objects.get_or_create(
-            user=meoward_user, domain=self.domain_12, role=UserDomainRole.Roles.MANAGER
+            user=self.meoward_user, domain=self.domain_12, role=UserDomainRole.Roles.MANAGER
         )
 
         _, created = DomainInvitation.objects.get_or_create(
-            email=meoward_user.email, domain=self.domain_1, status=DomainInvitation.DomainInvitationStatus.RETRIEVED
+            email=self.meoward_user.email,
+            domain=self.domain_1,
+            status=DomainInvitation.DomainInvitationStatus.RETRIEVED,
         )
 
         _, created = DomainInvitation.objects.get_or_create(
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
@@ -47,6 +47,7 @@
 from registrar.models.user_domain_role import UserDomainRole
 from registrar.models.verified_by_staff import VerifiedByStaff
 from .common import (
+    MockDb,
     MockSESClient,
     AuditedAdminMockData,
     completed_domain_request,
@@ -3438,16 +3439,19 @@ def tearDown(self):
         User.objects.all().delete()
 
 
-class TestMyUserAdmin(TestCase):
+class TestMyUserAdmin(MockDb):
     def setUp(self):
+        super().setUp()
         admin_site = AdminSite()
         self.admin = MyUserAdmin(model=get_user_model(), admin_site=admin_site)
         self.client = Client(HTTP_HOST="localhost:8080")
         self.superuser = create_superuser()
+        self.staffuser = create_user()
         self.test_helper = GenericTestHelper(admin=self.admin)
 
     def tearDown(self):
         super().tearDown()
+        DomainRequest.objects.all().delete()
         User.objects.all().delete()
 
     @less_console_noise_decorator
@@ -3472,7 +3476,7 @@ def test_helper_text(self):
         """
         Tests for the correct helper text on this page
         """
-        user = create_user()
+        user = self.staffuser
 
         p = "adminpass"
         self.client.login(username="superuser", password=p)
@@ -3493,10 +3497,11 @@ def test_helper_text(self):
         ]
         self.test_helper.assert_response_contains_distinct_values(response, expected_values)
 
+    @less_console_noise_decorator
     def test_list_display_without_username(self):
         with less_console_noise():
             request = self.client.request().wsgi_request
-            request.user = create_user()
+            request.user = self.staffuser
 
             list_display = self.admin.get_list_display(request)
             expected_list_display = [
@@ -3522,7 +3527,7 @@ def test_get_fieldsets_superuser(self):
     def test_get_fieldsets_cisa_analyst(self):
         with less_console_noise():
             request = self.client.request().wsgi_request
-            request.user = create_user()
+            request.user = self.staffuser
             fieldsets = self.admin.get_fieldsets(request)
             expected_fieldsets = (
                 (
@@ -3540,6 +3545,97 @@ def test_get_fieldsets_cisa_analyst(self):
             )
             self.assertEqual(fieldsets, expected_fieldsets)
 
+    def test_analyst_can_see_related_domains_and_requests_in_user_form(self):
+        """Tests if an analyst can see the related domains and domain requests for a user in that user's form"""
+
+        # From MockDb, we have self.meoward_user which we'll use as creator
+        # Create fake domain requests
+        domain_request_started = completed_domain_request(
+            status=DomainRequest.DomainRequestStatus.STARTED, user=self.meoward_user, name="started.gov"
+        )
+        domain_request_submitted = completed_domain_request(
+            status=DomainRequest.DomainRequestStatus.SUBMITTED, user=self.meoward_user, name="submitted.gov"
+        )
+        domain_request_in_review = completed_domain_request(
+            status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=self.meoward_user, name="in-review.gov"
+        )
+        domain_request_withdrawn = completed_domain_request(
+            status=DomainRequest.DomainRequestStatus.WITHDRAWN, user=self.meoward_user, name="withdrawn.gov"
+        )
+        domain_request_approved = completed_domain_request(
+            status=DomainRequest.DomainRequestStatus.APPROVED, user=self.meoward_user, name="approved.gov"
+        )
+        domain_request_rejected = completed_domain_request(
+            status=DomainRequest.DomainRequestStatus.REJECTED, user=self.meoward_user, name="rejected.gov"
+        )
+        domain_request_ineligible = completed_domain_request(
+            status=DomainRequest.DomainRequestStatus.INELIGIBLE, user=self.meoward_user, name="ineligible.gov"
+        )
+
+        # From MockDb, we have sel.meoward_user who's admin on
+        # self.domain_1 - READY
+        # self.domain_2 - DNS_NEEDED
+        # self.domain_11 - READY
+        # self.domain_12 - READY
+        # DELETED:
+        domain_deleted, _ = Domain.objects.get_or_create(
+            name="domain_deleted.gov", state=Domain.State.DELETED, deleted=timezone.make_aware(datetime(2024, 4, 2))
+        )
+        _, created = UserDomainRole.objects.get_or_create(
+            user=self.meoward_user, domain=domain_deleted, role=UserDomainRole.Roles.MANAGER
+        )
+
+        p = "userpass"
+        self.client.login(username="staffuser", password=p)
+        response = self.client.get(
+            "/admin/registrar/user/{}/change/".format(self.meoward_user.id),
+            follow=True,
+        )
+
+        # Make sure the page loaded and contains the expected domain request names and links to the domain requests
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(response, domain_request_submitted.requested_domain.name)
+        expected_href = reverse("admin:registrar_domainrequest_change", args=[domain_request_submitted.pk])
+        self.assertContains(response, expected_href)
+
+        self.assertContains(response, domain_request_in_review.requested_domain.name)
+        expected_href = reverse("admin:registrar_domainrequest_change", args=[domain_request_in_review.pk])
+        self.assertContains(response, expected_href)
+
+        self.assertContains(response, domain_request_approved.requested_domain.name)
+        expected_href = reverse("admin:registrar_domainrequest_change", args=[domain_request_approved.pk])
+        self.assertContains(response, expected_href)
+
+        self.assertContains(response, domain_request_rejected.requested_domain.name)
+        expected_href = reverse("admin:registrar_domainrequest_change", args=[domain_request_rejected.pk])
+        self.assertContains(response, expected_href)
+
+        self.assertContains(response, domain_request_ineligible.requested_domain.name)
+        expected_href = reverse("admin:registrar_domainrequest_change", args=[domain_request_ineligible.pk])
+        self.assertContains(response, expected_href)
+
+        # We filter out those requests
+        # STARTED
+        self.assertNotContains(response, domain_request_started.requested_domain.name)
+        expected_href = reverse("admin:registrar_domainrequest_change", args=[domain_request_started.pk])
+        self.assertNotContains(response, expected_href)
+
+        # WITHDRAWN
+        self.assertNotContains(response, domain_request_withdrawn.requested_domain.name)
+        expected_href = reverse("admin:registrar_domainrequest_change", args=[domain_request_withdrawn.pk])
+        self.assertNotContains(response, expected_href)
+
+        # Make sure the page contains the expected domain names and links to the domains
+        self.assertContains(response, self.domain_1.name)
+        expected_href = reverse("admin:registrar_domain_change", args=[self.domain_1.pk])
+        self.assertContains(response, expected_href)
+
+        # We filter out DELETED
+        self.assertNotContains(response, domain_deleted.name)
+        expected_href = reverse("admin:registrar_domain_change", args=[domain_deleted.pk])
+        self.assertNotContains(response, expected_href)
+
 
 class AuditedAdminTest(TestCase):
     def setUp(self):