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.
This commit is contained in:
Arthur Hanson 2026-08-20 05:51:30 -07:00 committed by GitHub
parent 875e7d0885
commit d7f79bd501
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 74 additions and 2 deletions

View File

@ -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):
"""

View File

@ -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)