Coverage for extras/managers.py: 38%
39 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from django.db import router
2from django.db.models import signals
3from taggit.managers import TaggableManager, _TaggableManager
4from taggit.utils import require_instance_manager
6__all__ = (
7 'NetBoxTaggableManager',
8 'NetBoxTaggableManagerField',
9)
12class NetBoxTaggableManager(_TaggableManager):
13 """
14 Extends taggit's _TaggableManager to:
16 * Replace the per-tag get_or_create loop in add() with a single bulk_create() call, reducing
17 SQL queries from O(N) to O(1) when assigning tags.
18 * Implement set_base(), the M2M assignment entry point Django's deserializer calls.
19 """
21 @require_instance_manager
22 def add(self, *tags, through_defaults=None, tag_kwargs=None, **kwargs):
23 self._remove_prefetched_objects()
24 if tag_kwargs is None:
25 tag_kwargs = {}
26 db = router.db_for_write(self.through, instance=self.instance)
28 tag_objs = self._to_tag_model_instances(tags, tag_kwargs)
29 new_ids = {t.pk for t in tag_objs}
31 # Determine which tags are not already assigned to this object
32 lookup = self._lookup_kwargs()
33 vals = set(
34 self.through._default_manager.using(db)
35 .values_list("tag_id", flat=True)
36 .filter(**lookup, tag_id__in=new_ids)
37 )
38 new_ids -= vals
40 if not new_ids:
41 return
43 signals.m2m_changed.send(
44 sender=self.through,
45 action="pre_add",
46 instance=self.instance,
47 reverse=False,
48 model=self.through.tag_model(),
49 pk_set=new_ids,
50 using=db,
51 )
53 # Use a single bulk INSERT instead of one get_or_create per tag.
54 self.through._default_manager.using(db).bulk_create(
55 [
56 self.through(tag=tag, **lookup, **(through_defaults or {}))
57 for tag in tag_objs
58 if tag.pk in new_ids
59 ],
60 ignore_conflicts=True,
61 )
63 signals.m2m_changed.send(
64 sender=self.through,
65 action="post_add",
66 instance=self.instance,
67 reverse=False,
68 model=self.through.tag_model(),
69 pk_set=new_ids,
70 using=db,
71 )
73 @require_instance_manager
74 def set_base(self, objs, *, clear=False, through_defaults=None, raw=False):
75 # Django's deserializer assigns M2M data through this method, passing primary keys;
76 # taggit's set() takes only Tag instances or names. Keys which match no tag are passed
77 # through for the database to reject, as ManyRelatedManager.set_base() does.
78 tag_model = self.through.tag_model()
79 if pks := [obj for obj in objs if not isinstance(obj, (tag_model, str))]:
80 db = router.db_for_write(self.through, instance=self.instance)
81 tags = tag_model._default_manager.using(db).in_bulk(pks)
82 objs = [
83 obj if isinstance(obj, (tag_model, str)) else tags.get(obj) or tag_model(pk=obj)
84 for obj in objs
85 ]
86 return self.set(objs, clear=clear, through_defaults=through_defaults)
89class NetBoxTaggableManagerField(TaggableManager):
90 """
91 Subclass of taggit's TaggableManager that interpolates `%(app_label)s` and `%(class)s` in
92 `related_name`. taggit's contribute_to_class() bypasses Django's RelatedField, which is what
93 normally performs this substitution, so without this two taggable models that share a class
94 name (e.g. from different plugins) collide on Tag's reverse accessor.
95 """
96 def contribute_to_class(self, cls, name):
97 super().contribute_to_class(cls, name)
98 if not cls._meta.abstract and self.remote_field.related_name:
99 self.remote_field.related_name = self.remote_field.related_name % {
100 'class': cls.__name__.lower(),
101 'app_label': cls._meta.app_label.lower(),
102 }
104 def deconstruct(self):
105 # Emit the upstream taggit path and omit related_name so existing migrations remain
106 # equivalent and no AlterField is produced for every TagsMixin consumer. related_name
107 # has no effect on the database schema; it is reapplied on model load. Only safe while
108 # this subclass adds no field attributes that affect schema — if that changes, restore
109 # the real path so migrations capture the diff.
110 name, _path, args, kwargs = super().deconstruct()
111 kwargs.pop('related_name', None)
112 return name, 'taggit.managers.TaggableManager', args, kwargs