From d7f79bd50162b2b8a8d07b52348d332bd9404f3c Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 20 Aug 2026 05:51:30 -0700 Subject: [PATCH] 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. --- netbox/extras/managers.py | 22 +++++++- netbox/utilities/tests/test_serialization.py | 54 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/netbox/extras/managers.py b/netbox/extras/managers.py index 450af466e..6c6406755 100644 --- a/netbox/extras/managers.py +++ b/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): """ diff --git a/netbox/utilities/tests/test_serialization.py b/netbox/utilities/tests/test_serialization.py index b883dbd06..e65c1882a 100644 --- a/netbox/utilities/tests/test_serialization.py +++ b/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)