Repository: nautobot/nautobot

Issue:
ContactAssociation make Role required
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Nautobot version (Docker tag too if applicable): 2.2.0a1
* Python version:
* Database platform, version:
* Middleware(s):

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Create a contact association to an object without selecting a role
2. Assign that same contact to the same object with the same status and no role

<!-- What did you expect to happen? -->
### Expected Behavior
Failure because contact already exists on that object

<!-- What happened instead? -->
### Observed Behavior
Multiple associations are created because `null` != `null` on the `role` field

![image](https://github.com/nautobot/nautobot/assets/75227981/f9771055-127c-436c-adcf-909053f81f0b)





Discussion:
As discussed, make role required

Resolving patch:
diff --git a/nautobot/extras/migrations/0104_contact_contactassociation_team.py b/nautobot/extras/migrations/0104_contact_contactassociation_team.py
--- a/nautobot/extras/migrations/0104_contact_contactassociation_team.py
+++ b/nautobot/extras/migrations/0104_contact_contactassociation_team.py
@@ -124,8 +124,6 @@ class Migration(migrations.Migration):
                 (
                     "role",
                     nautobot.extras.models.roles.RoleField(
-                        blank=True,
-                        null=True,
                         on_delete=django.db.models.deletion.PROTECT,
                         related_name="contact_associations",
                         to="extras.role",
diff --git a/nautobot/extras/models/contacts.py b/nautobot/extras/models/contacts.py
--- a/nautobot/extras/models/contacts.py
+++ b/nautobot/extras/models/contacts.py
@@ -86,7 +86,7 @@ class ContactAssociation(OrganizationalModel):
     associated_object_id = models.UUIDField(db_index=True)
     associated_object = GenericForeignKey(ct_field="associated_object_type", fk_field="associated_object_id")
 
-    role = RoleField(blank=True, null=True)
+    role = RoleField(blank=False, null=False)
     status = StatusField(blank=False, null=False)
 
     class Meta:
diff --git a/nautobot/extras/views.py b/nautobot/extras/views.py
--- a/nautobot/extras/views.py
+++ b/nautobot/extras/views.py
@@ -1907,6 +1907,8 @@ def get(self, request, model, **kwargs):
                 "table": objectchanges_table,
                 "base_template": self.base_template,
                 "active_tab": "changelog",
+                # Currently only Contact and Team models are not contact_associatable.
+                "is_contact_associatable_model": type(obj) not in [Contact, Team],
             },
         )
 
@@ -1988,6 +1990,8 @@ def get(self, request, model, **kwargs):
                 "base_template": self.base_template,
                 "active_tab": "notes",
                 "form": notes_form,
+                # Currently only Contact and Team models are not contact_associatable.
+                "is_contact_associatable_model": type(obj) not in [Contact, Team],
             },
         )
 


Test patch:
diff --git a/nautobot/core/testing/views.py b/nautobot/core/testing/views.py
--- a/nautobot/core/testing/views.py
+++ b/nautobot/core/testing/views.py
@@ -255,9 +255,16 @@ class GetObjectChangelogViewTestCase(ModelViewTestCase):
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
         def test_get_object_changelog(self):
-            url = self._get_url("changelog", self._get_queryset().first())
+            obj = self._get_queryset().first()
+            url = self._get_url("changelog", obj)
             response = self.client.get(url)
             self.assertHttpStatus(response, 200)
+            response_data = response.content.decode(response.charset)
+            if type(obj) not in [extras_models.Contact, extras_models.Team]:
+                self.assertInHTML(
+                    f'<a href="{obj.get_absolute_url()}#contacts" onclick="switch_tab(this.href)" aria-controls="contacts" role="tab" data-toggle="tab">Contacts</a>',
+                    response_data,
+                )
 
     class GetObjectNotesViewTestCase(ModelViewTestCase):
         """
@@ -267,9 +274,16 @@ class GetObjectNotesViewTestCase(ModelViewTestCase):
         @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
         def test_get_object_notes(self):
             if hasattr(self.model, "notes"):
-                url = self._get_url("notes", self._get_queryset().first())
+                obj = self._get_queryset().first()
+                url = self._get_url("notes", obj)
                 response = self.client.get(url)
                 self.assertHttpStatus(response, 200)
+                response_data = response.content.decode(response.charset)
+                if type(obj) not in [extras_models.Contact, extras_models.Team]:
+                    self.assertInHTML(
+                        f'<a href="{obj.get_absolute_url()}#contacts" onclick="switch_tab(this.href)" aria-controls="contacts" role="tab" data-toggle="tab">Contacts</a>',
+                        response_data,
+                    )
 
     class CreateObjectViewTestCase(ModelViewTestCase):
         """
diff --git a/nautobot/extras/tests/test_api.py b/nautobot/extras/tests/test_api.py
--- a/nautobot/extras/tests/test_api.py
+++ b/nautobot/extras/tests/test_api.py
@@ -427,31 +427,33 @@ class ContactAssociationTestCase(APIViewTestCases.APIViewTestCase):
     def setUpTestData(cls):
         roles = Role.objects.get_for_model(ContactAssociation)
         statuses = Status.objects.get_for_model(ContactAssociation)
+        ip_addresses = IPAddress.objects.all()
+        devices = Device.objects.all()
         ContactAssociation.objects.create(
             contact=Contact.objects.first(),
             associated_object_type=ContentType.objects.get_for_model(IPAddress),
-            associated_object_id=IPAddress.objects.first().pk,
-            role=None,
+            associated_object_id=ip_addresses[0].pk,
+            role=roles[0],
             status=statuses[0],
         )
         ContactAssociation.objects.create(
             contact=Contact.objects.last(),
             associated_object_type=ContentType.objects.get_for_model(IPAddress),
-            associated_object_id=IPAddress.objects.first().pk,
-            role=None,
+            associated_object_id=ip_addresses[1].pk,
+            role=roles[1],
             status=statuses[1],
         )
         ContactAssociation.objects.create(
             team=Team.objects.first(),
             associated_object_type=ContentType.objects.get_for_model(IPAddress),
-            associated_object_id=IPAddress.objects.first().pk,
-            role=roles[0],
+            associated_object_id=ip_addresses[2].pk,
+            role=roles[1],
             status=statuses[0],
         )
         ContactAssociation.objects.create(
             team=Team.objects.last(),
             associated_object_type=ContentType.objects.get_for_model(IPAddress),
-            associated_object_id=IPAddress.objects.first().pk,
+            associated_object_id=ip_addresses[3].pk,
             role=roles[2],
             status=statuses[1],
         )
@@ -460,37 +462,37 @@ def setUpTestData(cls):
                 "contact": Contact.objects.first().pk,
                 "team": None,
                 "associated_object_type": "ipam.ipaddress",
-                "associated_object_id": IPAddress.objects.first().pk,
-                "role": None,
+                "associated_object_id": ip_addresses[4].pk,
+                "role": roles[3].pk,
                 "status": statuses[0].pk,
             },
             {
                 "contact": Contact.objects.last().pk,
                 "team": None,
                 "associated_object_type": "dcim.device",
-                "associated_object_id": Device.objects.first().pk,
-                "role": roles[1].pk,
+                "associated_object_id": devices[0].pk,
+                "role": roles[3].pk,
                 "status": statuses[0].pk,
             },
             {
                 "contact": None,
                 "team": Team.objects.first().pk,
                 "associated_object_type": "ipam.ipaddress",
-                "associated_object_id": IPAddress.objects.first().pk,
-                "role": roles[2].pk,
+                "associated_object_id": ip_addresses[5].pk,
+                "role": roles[3].pk,
                 "status": statuses[2].pk,
             },
             {
                 "contact": None,
                 "team": Team.objects.last().pk,
                 "associated_object_type": "dcim.device",
-                "associated_object_id": Device.objects.first().pk,
-                "role": roles[1].pk,
+                "associated_object_id": devices[1].pk,
+                "role": roles[3].pk,
                 "status": statuses[0].pk,
             },
         ]
         cls.bulk_update_data = {
-            "role": roles[2].pk,
+            "role": roles[4].pk,
             "status": statuses[1].pk,
         }
 
diff --git a/nautobot/extras/tests/test_views.py b/nautobot/extras/tests/test_views.py
--- a/nautobot/extras/tests/test_views.py
+++ b/nautobot/extras/tests/test_views.py
@@ -425,6 +425,7 @@ class ContactAssociationTestCase(
     def setUpTestData(cls):
         roles = Role.objects.get_for_model(ContactAssociation)
         statuses = Status.objects.get_for_model(ContactAssociation)
+        ip_addresses = IPAddress.objects.all()
         cls.form_data = {
             "contact": Contact.objects.first().pk,
             "team": None,
@@ -440,28 +441,28 @@ def setUpTestData(cls):
         ContactAssociation.objects.create(
             contact=Contact.objects.first(),
             associated_object_type=ContentType.objects.get_for_model(IPAddress),
-            associated_object_id=IPAddress.objects.first().pk,
+            associated_object_id=ip_addresses[0].pk,
             role=roles[2],
             status=statuses[1],
         )
         ContactAssociation.objects.create(
             contact=Contact.objects.last(),
             associated_object_type=ContentType.objects.get_for_model(IPAddress),
-            associated_object_id=IPAddress.objects.first().pk,
+            associated_object_id=ip_addresses[1].pk,
             role=roles[1],
             status=statuses[2],
         )
         ContactAssociation.objects.create(
             team=Team.objects.first(),
             associated_object_type=ContentType.objects.get_for_model(IPAddress),
-            associated_object_id=IPAddress.objects.first().pk,
+            associated_object_id=ip_addresses[2].pk,
             role=roles[0],
             status=statuses[0],
         )
         ContactAssociation.objects.create(
             team=Team.objects.last(),
             associated_object_type=ContentType.objects.get_for_model(IPAddress),
-            associated_object_id=IPAddress.objects.first().pk,
+            associated_object_id=ip_addresses[3].pk,
             role=roles[0],
             status=statuses[1],
         )