Repository: nautobot/nautobot

Issue:
Inconsistency in ContactAssociation uniqueness constraints
<!--
    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.2
* Python version: 3.10.12
* Database platform, version: postgres, 16.2
* 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.
-->
Performing a Import Objects job on the content type of `extras | contact association` does not honor the same uniqueness constraints as using the form to manually enter a new association. Specifically, associated the same contact, and role to multiple objects will result in a failure using the Job, but can be done manually by going to each object and assigning the contact.

### Test Data
Location Types
```CSV
name
Root Location
```

Locations
```CSV
location_type__name,name,status__name
Root Location,Location 1,Active
Root Location,Location 2,Active
```

Contacts
```CSV
name,email
Bob,bob@example.com
```

### Steps to Reproduce
1. Create a CSV that associates the same contact, and role to two different objects of the same type (this requires knowing the ids of the generated locations when loading our test data, so this will change for everybody going through these steps
```CSV
associated_object_id,associated_object_type,status__name,role__name,contact__name
7939407e-0dba-430b-bf54-b8d3e88258dc,dcim.location,Active,On Site,Bob
969beaa9-f75a-4b11-b6ae-15d6357cf5fe,dcim.location,Active,On Site,Bob
```
2. Utilize the import objects job to import the contact associations
![NautobotContactAssociationImport-0001](https://github.com/nautobot/nautobot/assets/635290/1ca3f3b2-a884-48a4-8071-bf3edc5b3731)



<!-- What did you expect to happen? -->
### Expected Behavior
The expected behavior is the form indicating that it successfully imported the contact associations.
![NautobotContactAssociationImport-0003](https://github.com/nautobot/nautobot/assets/635290/920c150a-fc55-48ee-9f8d-f07c2f8496ea)

<!-- What happened instead? -->
### Observed Behavior
![NautobotContactAssociationImport-0002](https://github.com/nautobot/nautobot/assets/635290/50d32ba9-2679-4791-8c31-b626105e42cf)




Discussion:
I will be submitting a PR shortly that fixes this issue.

Resolving patch:
diff --git a/nautobot/extras/api/serializers.py b/nautobot/extras/api/serializers.py
--- a/nautobot/extras/api/serializers.py
+++ b/nautobot/extras/api/serializers.py
@@ -223,17 +223,28 @@ class Meta:
         }
 
     def validate(self, data):
-        # Validate uniqueness of (contact/team, role)
+        # Validate uniqueness of (associated object, associated object type, contact/team, role)
+        unique_together_fields = None
+
         if data.get("contact") and data.get("role"):
-            validator = UniqueTogetherValidator(
-                queryset=ContactAssociation.objects.all(),
-                fields=("contact", "role"),
+            unique_together_fields = (
+                "associated_object_type",
+                "associated_object_id",
+                "contact",
+                "role",
             )
-            validator(data, self)
         elif data.get("team") and data.get("role"):
+            unique_together_fields = (
+                "associated_object_type",
+                "associated_object_id",
+                "team",
+                "role",
+            )
+
+        if unique_together_fields is not None:
             validator = UniqueTogetherValidator(
                 queryset=ContactAssociation.objects.all(),
-                fields=("team", "role"),
+                fields=unique_together_fields,
             )
             validator(data, self)
 


Test patch:
diff --git a/nautobot/core/tests/test_jobs.py b/nautobot/core/tests/test_jobs.py
--- a/nautobot/core/tests/test_jobs.py
+++ b/nautobot/core/tests/test_jobs.py
@@ -4,9 +4,9 @@
 import yaml
 
 from nautobot.core.testing import create_job_result_and_run_job, TransactionTestCase
-from nautobot.dcim.models import DeviceType, Manufacturer
+from nautobot.dcim.models import DeviceType, Location, LocationType, Manufacturer
 from nautobot.extras.choices import JobResultStatusChoices, LogLevelChoices
-from nautobot.extras.models import ExportTemplate, JobLogEntry, Status
+from nautobot.extras.models import Contact, ContactAssociation, ExportTemplate, JobLogEntry, Role, Status
 from nautobot.users.models import ObjectPermission
 
 
@@ -256,3 +256,80 @@ def test_csv_import_bad_row(self):
             self.assertEqual(log_successes[3].message, 'Row 5: Created record "test_status4"')
             self.assertTrue(Status.objects.filter(name="test_status4").exists())
             self.assertEqual(log_successes[4].message, "Created 4 status object(s) from 5 row(s) of data")
+
+    def test_csv_import_contact_assignment(self):
+        location_types_csv = "\n".join(["name", "ContactAssignmentImportTestLocationType"])
+        locations_csv = "\n".join(
+            [
+                "location_type__name,name,status__name",
+                "ContactAssignmentImportTestLocationType,ContactAssignmentImportTestLocation1,Active",
+                "ContactAssignmentImportTestLocationType,ContactAssignmentImportTestLocation2,Active",
+            ]
+        )
+        roles_csv = "\n".join(
+            [
+                "name,content_types",
+                "ContactAssignmentImportTestLocation-On Site,extras.contactassociation",
+            ]
+        )
+        contacts_csv = "\n".join(["name,email", "Bob-ContactAssignmentImportTestLocation,bob@example.com"])
+
+        location_types_job_result = create_job_result_and_run_job(
+            "nautobot.core.jobs",
+            "ImportObjects",
+            content_type=ContentType.objects.get_for_model(LocationType).pk,
+            csv_data=location_types_csv,
+        )
+        self.assertEqual(location_types_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
+
+        location_type_count = LocationType.objects.filter(name="ContactAssignmentImportTestLocationType").count()
+        self.assertEqual(location_type_count, 1, f"Unexpected count of LocationTypes {location_type_count}")
+
+        locations_job_result = create_job_result_and_run_job(
+            "nautobot.core.jobs",
+            "ImportObjects",
+            content_type=ContentType.objects.get_for_model(Location).pk,
+            csv_data=locations_csv,
+        )
+        self.assertEqual(locations_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
+
+        location_count = Location.objects.filter(location_type__name="ContactAssignmentImportTestLocationType").count()
+        self.assertEqual(location_count, 2, f"Unexpected count of Locations {location_count}")
+
+        contacts_job_result = create_job_result_and_run_job(
+            "nautobot.core.jobs",
+            "ImportObjects",
+            content_type=ContentType.objects.get_for_model(Contact).pk,
+            csv_data=contacts_csv,
+        )
+        self.assertEqual(contacts_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
+
+        contact_count = Contact.objects.filter(name="Bob-ContactAssignmentImportTestLocation").count()
+        self.assertEqual(contact_count, 1, f"Unexpected number of contacts {contact_count}")
+
+        roles_job_result = create_job_result_and_run_job(
+            "nautobot.core.jobs",
+            "ImportObjects",
+            content_type=ContentType.objects.get_for_model(Role).pk,
+            csv_data=roles_csv,
+        )
+        self.assertEqual(roles_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)
+
+        role_count = Role.objects.filter(name="ContactAssignmentImportTestLocation-On Site").count()
+        self.assertEqual(role_count, 1, f"Unexpected number of role values {role_count}")
+
+        associations = ["associated_object_id,associated_object_type,status__name,role__name,contact__name"]
+        for location in Location.objects.filter(location_type__name="ContactAssignmentImportTestLocationType"):
+            associations.append(
+                f"{location.pk},dcim.location,Active,ContactAssignmentImportTestLocation-On Site,Bob-ContactAssignmentImportTestLocation"
+            )
+        associations_csv = "\n".join(associations)
+
+        associations_job_result = create_job_result_and_run_job(
+            "nautobot.core.jobs",
+            "ImportObjects",
+            content_type=ContentType.objects.get_for_model(ContactAssociation).pk,
+            csv_data=associations_csv,
+        )
+
+        self.assertEqual(associations_job_result.status, JobResultStatusChoices.STATUS_SUCCESS)