Repository: nautobot/nautobot

Issue:
Multiple ConfigContext can be created with the same name
<!--
    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
* Python version: any
* Nautobot version: 1.0.1

<!--
    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 ConfigContext.
2. Create a second config context with the same `name` string.

<!-- What did you expect to happen? -->
### Expected Behavior

Creation of the second same-named ConfigContext should fail or be blocked by the UI due to the duplicate name. This is the behavior shown in NetBox 2.10.x.

<!-- What happened instead? -->
### Observed Behavior

Multiple same-named config contexts can coexist. This is problematic because, in the case of multiple contexts having the same weight, ties are broken based on the context name.

This appears to be another case where a `unique_together` constraint is not working as one might expect due to `NULL!=NULL` behavior of the database, as ConfigContext defines `unique_together = [["name", "owner_content_type", "owner_object_id"]]` and in common usage `owner_content_type` and `owner_object_id` will be NULL values.



Discussion:

Resolving patch:
diff --git a/nautobot/extras/models/models.py b/nautobot/extras/models/models.py
--- a/nautobot/extras/models/models.py
+++ b/nautobot/extras/models/models.py
@@ -292,6 +292,21 @@ def clean(self):
         if self.file_extension.startswith("."):
             self.file_extension = self.file_extension[1:]
 
+        # Don't allow two ExportTemplates with the same name, content_type, and owner.
+        # This is necessary because Django doesn't consider NULL=NULL, and so if owner is NULL the unique_together
+        # condition will never be matched even if name and content_type are the same.
+        if (
+            ExportTemplate.objects.exclude(pk=self.pk)
+            .filter(
+                name=self.name,
+                content_type=self.content_type,
+                owner_content_type=self.owner_content_type,
+                owner_object_id=self.owner_object_id,
+            )
+            .exists()
+        ):
+            raise ValidationError({"name": "An ExportTemplate with this name and content type already exists."})
+
 
 #
 # Image attachments
@@ -423,6 +438,15 @@ def clean(self):
         if type(self.data) is not dict:
             raise ValidationError({"data": 'JSON data must be in object form. Example: {"foo": 123}'})
 
+        # Check for a duplicated `name`. This is necessary because Django does not consider two NULL fields to be equal,
+        # and thus if the `owner` is NULL, a duplicate `name` will not otherwise automatically raise an exception.
+        if (
+            ConfigContext.objects.exclude(pk=self.pk)
+            .filter(name=self.name, owner_content_type=self.owner_content_type, owner_object_id=self.owner_object_id)
+            .exists()
+        ):
+            raise ValidationError({"name": "A ConfigContext with this name already exists."})
+
 
 class ConfigContextModel(models.Model):
     """


Test patch:
diff --git a/nautobot/extras/tests/test_models.py b/nautobot/extras/tests/test_models.py
--- a/nautobot/extras/tests/test_models.py
+++ b/nautobot/extras/tests/test_models.py
@@ -19,7 +19,7 @@
     Region,
 )
 from nautobot.extras.jobs import get_job, Job
-from nautobot.extras.models import ConfigContext, GitRepository, JobResult, Status, Tag
+from nautobot.extras.models import ConfigContext, ExportTemplate, GitRepository, JobResult, Status, Tag
 from nautobot.ipam.models import IPAddress
 from nautobot.tenancy.models import Tenant, TenantGroup
 from nautobot.utilities.choices import ColorChoices
@@ -84,6 +84,27 @@ def test_name_ordering_after_weight(self):
         expected_data = {"a": 123, "b": 456, "c": 789}
         self.assertEqual(self.device.get_config_context(), expected_data)
 
+    def test_name_uniqueness(self):
+        """
+        Verify that two unowned ConfigContexts cannot share the same name (GitHub issue #431).
+        """
+        ConfigContext.objects.create(name="context 1", weight=100, data={"a": 123, "b": 456, "c": 777})
+        with self.assertRaises(ValidationError):
+            duplicate_context = ConfigContext(name="context 1", weight=200, data={"c": 666})
+            duplicate_context.validated_save()
+
+        # If a different context is owned by a GitRepository, that's not considered a duplicate
+        repo = GitRepository(
+            name="Test Git Repository",
+            slug="test-git-repo",
+            remote_url="http://localhost/git.git",
+            username="oauth2",
+        )
+        repo.save(trigger_resync=False)
+
+        nonduplicate_context = ConfigContext(name="context 1", weight=300, data={"a": "22"}, owner=repo)
+        nonduplicate_context.validated_save()
+
     def test_annotation_same_as_get_for_object(self):
         """
         This test incorperates features from all of the above tests cases to ensure
@@ -224,6 +245,38 @@ def test_multiple_tags_return_distinct_objects_with_seperate_config_contexts(sel
         self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
 
 
+class ExportTemplateTest(TestCase):
+    """
+    Tests for the ExportTemplate model class.
+    """
+
+    def test_name_contenttype_uniqueness(self):
+        """
+        The pair of (name, content_type) must be unique for an un-owned ExportTemplate.
+
+        See GitHub issue #431.
+        """
+        device_ct = ContentType.objects.get_for_model(Device)
+        ExportTemplate.objects.create(content_type=device_ct, name="Export Template 1", template_code="hello world")
+
+        with self.assertRaises(ValidationError):
+            duplicate_template = ExportTemplate(content_type=device_ct, name="Export Template 1", template_code="foo")
+            duplicate_template.validated_save()
+
+        # A differently owned ExportTemplate may have the same name
+        repo = GitRepository(
+            name="Test Git Repository",
+            slug="test-git-repo",
+            remote_url="http://localhost/git.git",
+            username="oauth2",
+        )
+        repo.save(trigger_resync=False)
+        nonduplicate_template = ExportTemplate(
+            content_type=device_ct, name="Export Template 1", owner=repo, template_code="bar"
+        )
+        nonduplicate_template.validated_save()
+
+
 class GitRepositoryTest(TransactionTestCase):
     """
     Tests for the GitRepository model class.