Repository: nautobot/nautobot

Issue:
Missing uniqueness constraint on TaggedItem
### Environment

While [django-taggit's base TaggedItem class has a unique_together constraint](https://github.com/jazzband/django-taggit/blob/master/taggit/models.py#L191), Nautobot's [implementation of this class does not](https://github.com/nautobot/nautobot/blob/develop/nautobot/extras/models/tags.py#L89). We should add this constraint in 2.0 and handle any deduplication of data needed in migrations.

### Steps to Reproduce

```python
>>> TaggedItem.objects.create(content_type=ContentType.objects.get_for_model(Location), object_id=Location.objects.first().pk, tag=Tag.objects.first())
<TaggedItem: Campus-00 tagged with Amber>
>>> TaggedItem.objects.create(content_type=ContentType.objects.get_for_model(Location), object_id=Location.objects.first().pk, tag=Tag.objects.first())
<TaggedItem: Campus-00 tagged with Amber>
>>> TaggedItem.objects.create(content_type=ContentType.objects.get_for_model(Location), object_id=Location.objects.first().pk, tag=Tag.objects.first())
<TaggedItem: Campus-00 tagged with Amber>

>>> TaggedItem.objects.filter(object_id=Location.objects.first().pk)
<RestrictedQuerySet [<TaggedItem: Campus-00 tagged with Amber>, <TaggedItem: Campus-00 tagged with Amber>, <TaggedItem: Campus-00 tagged with Amber>]>
```

<!-- What did you expect to happen? -->
### Expected Behavior

Database should prevent duplicate TaggedItem objects from being created.

<!-- What happened instead? -->
### Observed Behavior

Duplicate TaggedItem objects can be created.

### TODO

- Delete duplicates as there is no inherent data loss
- UniqueTogether: `(content_type, object_id, tag)`


Discussion:

Resolving patch:
diff --git a/nautobot/extras/migrations/0084_taggeditem_cleanup.py b/nautobot/extras/migrations/0084_taggeditem_cleanup.py
new file mode 100644
--- /dev/null
+++ b/nautobot/extras/migrations/0084_taggeditem_cleanup.py
@@ -0,0 +1,43 @@
+from django.db import migrations, models
+
+
+def cleanup_taggeditem_duplicates(apps, schema_editor):
+    TaggedItem = apps.get_model("extras", "TaggedItem")
+
+    # Get the set of distinct values for TaggedItems
+    taggeditem_values = TaggedItem.objects.values("content_type", "object_id", "tag")
+    # Discard any existing ordering, just to be safe
+    taggeditem_values = taggeditem_values.order_by()
+    # Count the number of duplicates for each value
+    taggeditem_values = taggeditem_values.annotate(count_id=models.Count("id"))
+
+    # Filter to only those that actually have duplicates (i.e. more than one record)
+    duplicate_taggeditem_values = taggeditem_values.filter(count_id__gt=1)
+
+    deleted_count = 0
+    for duplicate in duplicate_taggeditem_values:
+        # Delete all but the "first" in each duplicate set.
+        # We can't use models.Min("id") to deterministically pick which one to keep because id is a UUID and psycopg2
+        # doesn't implement a min(uuid) function.
+        # We also can't just slice the queryset before deleting it (Django doesn't allow it).
+        to_delete = TaggedItem.objects.filter(
+            content_type=duplicate["content_type"], object_id=duplicate["object_id"], tag=duplicate["tag"]
+        )
+        to_keep = to_delete.first()
+        to_delete = to_delete.exclude(id=to_keep.id)
+
+        count, _ = to_delete.delete()
+        deleted_count += count
+
+    if deleted_count:
+        print(f"    Deleted {deleted_count} redundant TaggedItem records")
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("extras", "0083_rename_computed_field_slug_to_key"),
+    ]
+
+    operations = [
+        migrations.RunPython(cleanup_taggeditem_duplicates, migrations.RunPython.noop),
+    ]
diff --git a/nautobot/extras/migrations/0085_taggeditem_uniqueness.py b/nautobot/extras/migrations/0085_taggeditem_uniqueness.py
new file mode 100644
--- /dev/null
+++ b/nautobot/extras/migrations/0085_taggeditem_uniqueness.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.18 on 2023-05-26 14:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("extras", "0084_taggeditem_cleanup"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="taggeditem",
+            name="object_id",
+            field=models.UUIDField(db_index=True),
+        ),
+        migrations.AlterUniqueTogether(
+            name="taggeditem",
+            unique_together={("content_type", "object_id", "tag")},
+        ),
+    ]
diff --git a/nautobot/extras/models/tags.py b/nautobot/extras/models/tags.py
--- a/nautobot/extras/models/tags.py
+++ b/nautobot/extras/models/tags.py
@@ -66,12 +66,7 @@ def validate_content_types_removal(self, content_types_id):
 
 class TaggedItem(BaseModel, GenericUUIDTaggedItemBase):
     tag = models.ForeignKey(to=Tag, related_name="%(app_label)s_%(class)s_items", on_delete=models.CASCADE)
-    object_id = models.UUIDField()
 
     class Meta:
         index_together = ("content_type", "object_id")
-        # TODO: unique_together = [["content_type", "object_id", "tag"]]
-
-    # TODO: This isn't a guaranteed natural key for this model (see lack of a `unique_together` above), but in practice
-    # it is "nearly" unique. Once a proper unique_together is added and accounted for, this can be removed as redundant
-    natural_key_field_names = ["content_type", "object_id", "tag"]
+        unique_together = [["content_type", "object_id", "tag"]]


Test patch: