Przeglądaj źródła

Closes #22972: Restore tag deserialization compatibility with Django 6.1 (#22979)

Add set_base() support to CustomTaggableManager so Django's
deserializer can restore tag relationships through django-taggit.
Normalize primary-key values before delegating to taggit and add
regression coverage for object- and PK-based inputs.
Arthur Hanson 1 dzień temu
rodzic
commit
d7f79bd501

+ 20 - 2
netbox/extras/managers.py

@@ -11,8 +11,11 @@ __all__ = (
 
 class NetBoxTaggableManager(_TaggableManager):
     """
-    Extends taggit's _TaggableManager to replace the per-tag get_or_create loop in add() with a
-    single bulk_create() call, reducing SQL queries from O(N) to O(1) when assigning tags.
+    Extends taggit's _TaggableManager to:
+
+    * Replace the per-tag get_or_create loop in add() with a single bulk_create() call, reducing
+      SQL queries from O(N) to O(1) when assigning tags.
+    * Implement set_base(), the M2M assignment entry point Django's deserializer calls.
     """
 
     @require_instance_manager
@@ -67,6 +70,21 @@ class NetBoxTaggableManager(_TaggableManager):
             using=db,
         )
 
+    @require_instance_manager
+    def set_base(self, objs, *, clear=False, through_defaults=None, raw=False):
+        # Django's deserializer assigns M2M data through this method, passing primary keys;
+        # taggit's set() takes only Tag instances or names. Keys which match no tag are passed
+        # through for the database to reject, as ManyRelatedManager.set_base() does.
+        tag_model = self.through.tag_model()
+        if pks := [obj for obj in objs if not isinstance(obj, (tag_model, str))]:
+            db = router.db_for_write(self.through, instance=self.instance)
+            tags = tag_model._default_manager.using(db).in_bulk(pks)
+            objs = [
+                obj if isinstance(obj, (tag_model, str)) else tags.get(obj) or tag_model(pk=obj)
+                for obj in objs
+            ]
+        return self.set(objs, clear=clear, through_defaults=through_defaults)
+
 
 class NetBoxTaggableManagerField(TaggableManager):
     """

+ 54 - 0
netbox/utilities/tests/test_serialization.py

@@ -1,3 +1,5 @@
+from django.core import serializers
+from django.db import IntegrityError, connection, transaction
 from django.test import TestCase
 
 from dcim.choices import SiteStatusChoices
@@ -58,3 +60,55 @@ class SerializationTestCase(TestCase):
         self.assertEqual(instance.object.status, SiteStatusChoices.STATUS_ACTIVE)  # Default field value
         self.assertEqual(instance.object.foo, data['foo'])  # Non-field attribute
         self.assertEqual(list(instance.m2m_data['tags']), list(Tag.objects.all()))
+
+    def test_deserialized_object_save(self):
+        """A deserialized object saves and reassigns its tags (Django 6.1 calls set_base())."""
+        data = {
+            'name': 'Site 1',
+            'slug': 'site-1',
+            'tags': ['Tag 1', 'Tag 2'],
+        }
+        deserialize_object(Site, data, pk=123).save()
+
+        site = Site.objects.get(pk=123)
+        self.assertEqual(site.name, data['name'])
+        self.assertEqual(sorted(site.tags.values_list('name', flat=True)), data['tags'])
+
+    def test_deserialized_object_save_with_empty_tag_list(self):
+        """
+        An empty `tags` list still reaches set_base(): deserialize_object() only pops the key
+        when it is non-empty, so Django populates m2m_data['tags'] with an empty list. (Data
+        carrying no `tags` key at all would not populate m2m_data, and so would not exercise
+        set_base() — keep the empty list explicit.)
+        """
+        deserialize_object(Site, {'name': 'Site 2', 'slug': 'site-2', 'tags': []}, pk=124).save()
+
+        site = Site.objects.get(pk=124)
+        self.assertEqual(site.name, 'Site 2')
+        self.assertEqual(site.tags.count(), 0)
+
+    def test_deserialized_object_save_with_tag_pks(self):
+        """Django's deserializer resolves M2M values to primary keys; set_base() must take them."""
+        tags = list(Tag.objects.all()[:2])
+        data = [{
+            'model': 'dcim.site',
+            'pk': 125,
+            'fields': {'name': 'Site 3', 'slug': 'site-3', 'tags': [tag.pk for tag in tags]},
+        }]
+        list(serializers.deserialize('python', data))[0].save()
+
+        site = Site.objects.get(pk=125)
+        self.assertEqual(sorted(site.tags.values_list('pk', flat=True)), sorted(tag.pk for tag in tags))
+
+    def test_deserialized_object_save_with_unknown_tag_pk(self):
+        """A key matching no tag is left for the database to reject."""
+        data = [{
+            'model': 'dcim.site',
+            'pk': 126,
+            'fields': {'name': 'Site 4', 'slug': 'site-4', 'tags': [99999]},
+        }]
+        with transaction.atomic():
+            list(serializers.deserialize('python', data))[0].save()
+            with self.assertRaises(IntegrityError):
+                connection.check_constraints()
+            transaction.set_rollback(True)