1 # -*- coding: utf-8 -*-
3 Models and managers for generic tagging.
6 from django.contrib.contenttypes.models import ContentType
7 from django.db import connection, models
8 from django.db.models.base import ModelBase
9 from django.dispatch import Signal
11 qn = connection.ops.quote_name
13 tags_updated = Signal(providing_args=["affected_tags"])
16 def get_queryset_and_model(queryset_or_model):
18 Given a ``QuerySet`` or a ``Model``, returns a two-tuple of
21 If a ``Model`` is given, the ``QuerySet`` returned will be created
22 using its default manager.
25 return queryset_or_model, queryset_or_model.model
26 except AttributeError:
27 return queryset_or_model.objects.all(), queryset_or_model
33 class TagManager(models.Manager):
34 def __init__(self, intermediary_table_model):
35 super(TagManager, self).__init__()
36 self.intermediary_table_model = intermediary_table_model
37 models.signals.pre_delete.connect(self.target_deleted)
39 def target_deleted(self, instance, **kwargs):
40 """ clear tag relations before deleting an object """
46 self.update_tags(instance, [])
48 def update_tags(self, obj, tags):
50 Update tags associated with an object.
52 content_type = ContentType.objects.get_for_model(obj)
53 current_tags = list(self.filter(items__content_type__pk=content_type.pk,
54 items__object_id=obj.pk))
55 updated_tags = self.model.get_tag_list(tags)
57 # Remove tags which no longer apply
58 tags_for_removal = [tag for tag in current_tags if tag not in updated_tags]
59 if len(tags_for_removal):
60 self.intermediary_table_model.objects.filter(
61 content_type__pk=content_type.pk,
63 tag__in=tags_for_removal).delete()
65 tags_to_add = [tag for tag in updated_tags if tag not in current_tags]
66 for tag in tags_to_add:
67 existing = self.intermediary_table_model.objects.filter(
68 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag)
70 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
72 tags_updated.send(sender=type(obj), instance=obj, affected_tags=tags_to_add + tags_for_removal)
74 def remove_tag(self, obj, tag):
76 Remove tag from an object.
78 content_type = ContentType.objects.get_for_model(obj)
79 self.intermediary_table_model.objects.filter(
80 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag).delete()
82 def add_tag(self, obj, tag):
86 content_type = ContentType.objects.get_for_model(obj)
87 relations = self.intermediary_table_model.objects.filter(
88 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag)
90 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
92 def get_for_object(self, obj):
94 Create a queryset matching all tags associated with the given
97 ctype = ContentType.objects.get_for_model(obj)
98 return self.filter(items__content_type__pk=ctype.pk,
99 items__object_id=obj.pk)
101 def usage_for_model(self, model, counts=False, filters=None):
103 Obtain a list of tags associated with instances of the given
106 If ``counts`` is True, a ``count`` attribute will be added to
107 each tag, indicating how many times it has been used against
108 the Model class in question.
110 To limit the tags (and counts, if specified) returned to those
111 used by a subset of the Model's instances, pass a dictionary
112 of field lookups to be applied to the given Model as the
113 ``filters`` argument.
115 # TODO: Do we really need this filters stuff?
119 queryset = model.objects.filter()
120 for f in filters.items():
121 queryset.query.add_filter(f)
122 usage = self.usage_for_queryset(queryset, counts)
125 def usage_for_queryset(self, queryset, counts=False):
127 Obtain a list of tags associated with instances of a model
128 contained in the given queryset.
130 If ``counts`` is True, a ``count`` attribute will be added to
131 each tag, indicating how many times it has been used against
132 the Model class in question.
134 usage = self.model.objects.filter(
135 items__content_type=ContentType.objects.get_for_model(queryset.model),
136 items__object_id__in=queryset)
138 usage = usage.annotate(count=models.Count('id'))
140 usage = usage.distinct()
143 def related_for_model(self, tags, model, counts=False):
145 Obtain a list of tags related to a given list of tags - that
146 is, other tags used by items which have all the given tags.
148 If ``counts`` is True, a ``count`` attribute will be added to
149 each tag, indicating the number of items which have it in
150 addition to the given list of tags.
152 objs = self.model.intermediary_table_model.objects.get_by_model(model, tags)
153 qs = self.usage_for_queryset(objs, counts)
154 qs = qs.exclude(pk__in=[tag.pk for tag in tags])
158 class TaggedItemManager(models.Manager):
159 def __init__(self, tag_model):
160 super(TaggedItemManager, self).__init__()
161 self.tag_model = tag_model
163 def get_by_model(self, queryset_or_model, tags):
165 Create a ``QuerySet`` containing instances of the specified
166 model associated with a given tag or list of tags.
168 queryset, model = get_queryset_and_model(queryset_or_model)
169 tags = self.tag_model.get_tag_list(tags)
171 # No existing tags were given
172 return queryset.none()
174 # TODO: presumes reverse generic relation
175 # Multiple joins are WAY faster than having-count, at least on Postgres 9.1.
177 queryset = queryset.filter(tag_relations__tag=tag)
180 def get_union_by_model(self, queryset_or_model, tags):
182 Create a ``QuerySet`` containing instances of the specified
183 model associated with *any* of the given list of tags.
185 queryset, model = get_queryset_and_model(queryset_or_model)
186 tags = self.tag_model.get_tag_list(tags)
188 return queryset.none()
189 # TODO: presumes reverse generic relation
190 return queryset.filter(tag_relations__tag__in=tags).distinct()
192 def get_related(self, obj, queryset_or_model):
194 Retrieve a list of instances of the specified model which share
195 tags with the model instance ``obj``, ordered by the number of
196 shared tags in descending order.
198 queryset, model = get_queryset_and_model(queryset_or_model)
199 # TODO: presumes reverse generic relation.
200 # Do we know it's 'tags'?
201 return queryset.filter(tag_relations__tag__in=obj.tags).annotate(
202 count=models.Count('pk')).order_by('-count').exclude(pk=obj.pk)
209 class TagMeta(ModelBase):
210 """Metaclass for tag models (models inheriting from TagBase)."""
211 def __new__(mcs, name, bases, attrs):
212 model = super(TagMeta, mcs).__new__(mcs, name, bases, attrs)
213 if not model._meta.abstract:
214 # Register custom managers for concrete models
215 TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
216 TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
220 class TagBase(models.Model):
221 """Abstract class to be inherited by model classes."""
222 __metaclass__ = TagMeta
228 def get_tag_list(tag_list):
230 Utility function for accepting tag input in a flexible manner.
232 You should probably override this method in your subclass.
234 if isinstance(tag_list, TagBase):