Repository: cisagov/manage.get.gov

Issue:
Notify analysts if changing a contact associated with multiple domains or requests
### Issue description

Right now if an analysts changes a contact on django admin, this will change the values for any request or domain that is using that contact, when this may not be the desired effect. For instance, if you click edit on an AO contact the contact form will not tell you if multiple existing domains are using that AO. This results in an analyst possibly making a change for others that they didn't want to happen. A simple solution is to notify the user that this is joined/linked to other applications or requests and show which ones so they can make an informed decision on what to do.

### Acceptance criteria

- [ ] when clicking the edit icon on a django admin contact form, the pop-up should include a notification on any linked requests or domain objects if they exist
- [ ] this same notification should appear on the contact form when you click a contact in the Contact django table, if joined or linked as mentioned above

### Additional context

_No response_

### Links to other issues

created as a result from: #1571 


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
@@ -20,6 +20,8 @@
 from auditlog.models import LogEntry  # type: ignore
 from auditlog.admin import LogEntryAdmin  # type: ignore
 from django_fsm import TransitionNotAllowed  # type: ignore
+from django.utils.safestring import mark_safe
+from django.utils.html import escape
 
 logger = logging.getLogger(__name__)
 
@@ -452,6 +454,60 @@ def get_readonly_fields(self, request, obj=None):
         readonly_fields.extend([field for field in self.analyst_readonly_fields])
         return readonly_fields  # Read-only fields for analysts
 
+    def change_view(self, request, object_id, form_url="", extra_context=None):
+        """Extend the change_view for Contact objects in django admin.
+        Customize to display related objects to the Contact. These will be passed
+        through the messages construct to the template for display to the user."""
+
+        # Fetch the Contact instance
+        contact = models.Contact.objects.get(pk=object_id)
+
+        # initialize related_objects array
+        related_objects = []
+        # for all defined fields in the model
+        for related_field in contact._meta.get_fields():
+            # if the field is a relation to another object
+            if related_field.is_relation:
+                # Check if the related field is not None
+                related_manager = getattr(contact, related_field.name)
+                if related_manager is not None:
+                    # Check if it's a ManyToManyField/reverse ForeignKey or a OneToOneField
+                    # Do this by checking for get_queryset method on the related_manager
+                    if hasattr(related_manager, "get_queryset"):
+                        # Handles ManyToManyRel and ManyToOneRel
+                        queryset = related_manager.get_queryset()
+                    else:
+                        # Handles OneToOne rels, ie. User
+                        queryset = [related_manager]
+
+                    for obj in queryset:
+                        # for each object, build the edit url in this view and add as tuple
+                        # to the related_objects array
+                        app_label = obj._meta.app_label
+                        model_name = obj._meta.model_name
+                        obj_id = obj.id
+                        change_url = reverse("admin:%s_%s_change" % (app_label, model_name), args=[obj_id])
+                        related_objects.append((change_url, obj))
+
+        if related_objects:
+            message = "<ul class='messagelist_content-list--unstyled'>"
+            for i, (url, obj) in enumerate(related_objects):
+                if i < 5:
+                    escaped_obj = escape(obj)
+                    message += f"<li>Joined to {obj.__class__.__name__}: <a href='{url}'>{escaped_obj}</a></li>"
+            message += "</ul>"
+            if len(related_objects) > 5:
+                related_objects_over_five = len(related_objects) - 5
+                message += f"<p class='font-sans-3xs'>And {related_objects_over_five} more...</p>"
+
+            message_html = mark_safe(message)  # nosec
+            messages.warning(
+                request,
+                message_html,
+            )
+
+        return super().change_view(request, object_id, form_url, extra_context=extra_context)
+
 
 class WebsiteAdmin(ListHeaderAdmin):
     """Custom website admin class."""


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
@@ -526,6 +526,7 @@ def completed_application(
     has_anything_else=True,
     status=DomainApplication.ApplicationStatus.STARTED,
     user=False,
+    submitter=False,
     name="city.gov",
 ):
     """A completed domain application."""
@@ -541,13 +542,14 @@ def completed_application(
     domain, _ = DraftDomain.objects.get_or_create(name=name)
     alt, _ = Website.objects.get_or_create(website="city1.gov")
     current, _ = Website.objects.get_or_create(website="city.com")
-    you, _ = Contact.objects.get_or_create(
-        first_name="Testy2",
-        last_name="Tester2",
-        title="Admin Tester",
-        email="mayor@igorville.gov",
-        phone="(555) 555 5556",
-    )
+    if not submitter:
+        submitter, _ = Contact.objects.get_or_create(
+            first_name="Testy2",
+            last_name="Tester2",
+            title="Admin Tester",
+            email="mayor@igorville.gov",
+            phone="(555) 555 5556",
+        )
     other, _ = Contact.objects.get_or_create(
         first_name="Testy",
         last_name="Tester",
@@ -567,7 +569,7 @@ def completed_application(
         zipcode="10002",
         authorizing_official=ao,
         requested_domain=domain,
-        submitter=you,
+        submitter=submitter,
         creator=user,
         status=status,
     )
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
@@ -1737,7 +1737,86 @@ def test_readonly_when_restricted_superuser(self):
 
         self.assertEqual(readonly_fields, expected_fields)
 
+    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."""
+
+        self.client.force_login(self.superuser)
+
+        # Create an instance of the model
+        contact, _ = Contact.objects.get_or_create(user=self.staffuser)
+
+        # join it to 4 domain requests. The 5th join will be a user.
+        application1 = completed_application(submitter=contact, name="city1.gov")
+        application2 = completed_application(submitter=contact, name="city2.gov")
+        application3 = completed_application(submitter=contact, name="city3.gov")
+        application4 = completed_application(submitter=contact, name="city4.gov")
+
+        with patch("django.contrib.messages.warning") as mock_warning:
+            # Use the test client to simulate the request
+            response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk]))
+
+            # Assert that the error message was called with the correct argument
+            # Note: The 5th join will be a user.
+            mock_warning.assert_called_once_with(
+                response.wsgi_request,
+                "<ul class='messagelist_content-list--unstyled'>"
+                "<li>Joined to DomainApplication: <a href='/admin/registrar/"
+                f"domainapplication/{application1.pk}/change/'>city1.gov</a></li>"
+                "<li>Joined to DomainApplication: <a href='/admin/registrar/"
+                f"domainapplication/{application2.pk}/change/'>city2.gov</a></li>"
+                "<li>Joined to DomainApplication: <a href='/admin/registrar/"
+                f"domainapplication/{application3.pk}/change/'>city3.gov</a></li>"
+                "<li>Joined to DomainApplication: <a href='/admin/registrar/"
+                f"domainapplication/{application4.pk}/change/'>city4.gov</a></li>"
+                "<li>Joined to User: <a href='/admin/registrar/"
+                f"user/{self.staffuser.pk}/change/'>staff@example.com</a></li>"
+                "</ul>",
+            )
+
+    def test_change_view_for_joined_contact_five_or_more(self):
+        """Create a contact, join it to 5 domain requests. The 6th join will be a user.
+        Assert that the warning on the contact form lists 5 joins and a '1 more' ellispsis."""
+
+        self.client.force_login(self.superuser)
+
+        # Create an instance of the model
+        # join it to 5 domain requests. The 6th join will be a user.
+        contact, _ = Contact.objects.get_or_create(user=self.staffuser)
+        application1 = completed_application(submitter=contact, name="city1.gov")
+        application2 = completed_application(submitter=contact, name="city2.gov")
+        application3 = completed_application(submitter=contact, name="city3.gov")
+        application4 = completed_application(submitter=contact, name="city4.gov")
+        application5 = completed_application(submitter=contact, name="city5.gov")
+
+        with patch("django.contrib.messages.warning") as mock_warning:
+            # Use the test client to simulate the request
+            response = self.client.get(reverse("admin:registrar_contact_change", args=[contact.pk]))
+
+            logger.info(mock_warning)
+
+            # Assert that the error message was called with the correct argument
+            # Note: The 6th join will be a user.
+            mock_warning.assert_called_once_with(
+                response.wsgi_request,
+                "<ul class='messagelist_content-list--unstyled'>"
+                "<li>Joined to DomainApplication: <a href='/admin/registrar/"
+                f"domainapplication/{application1.pk}/change/'>city1.gov</a></li>"
+                "<li>Joined to DomainApplication: <a href='/admin/registrar/"
+                f"domainapplication/{application2.pk}/change/'>city2.gov</a></li>"
+                "<li>Joined to DomainApplication: <a href='/admin/registrar/"
+                f"domainapplication/{application3.pk}/change/'>city3.gov</a></li>"
+                "<li>Joined to DomainApplication: <a href='/admin/registrar/"
+                f"domainapplication/{application4.pk}/change/'>city4.gov</a></li>"
+                "<li>Joined to DomainApplication: <a href='/admin/registrar/"
+                f"domainapplication/{application5.pk}/change/'>city5.gov</a></li>"
+                "</ul>"
+                "<p class='font-sans-3xs'>And 1 more...</p>",
+            )
+
     def tearDown(self):
+        DomainApplication.objects.all().delete()
+        Contact.objects.all().delete()
         User.objects.all().delete()