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
66 if tag not in current_tags]
67 for tag in tags_to_add:
68 if tag not in current_tags:
69 self.intermediary_table_model.objects.create(tag=tag, content_object=obj)
71 tags_updated.send(sender=type(obj), instance=obj, affected_tags=tags_to_add + tags_for_removal)
73 def remove_tag(self, obj, tag):
75 Remove tag from an object.
77 content_type = ContentType.objects.get_for_model(obj)
78 self.intermediary_table_model.objects.filter(
79 content_type__pk=content_type.pk, object_id=obj.pk, tag=tag).delete()
81 def get_for_object(self, obj):
83 Create a queryset matching all tags associated with the given
86 ctype = ContentType.objects.get_for_model(obj)
87 return self.filter(items__content_type__pk=ctype.pk,
88 items__object_id=obj.pk)
90 def usage_for_model(self, model, counts=False, filters=None):
92 Obtain a list of tags associated with instances of the given
95 If ``counts`` is True, a ``count`` attribute will be added to
96 each tag, indicating how many times it has been used against
97 the Model class in question.
99 To limit the tags (and counts, if specified) returned to those
100 used by a subset of the Model's instances, pass a dictionary
101 of field lookups to be applied to the given Model as the
102 ``filters`` argument.
104 # TODO: Do we really need this filters stuff?
108 queryset = model.objects.filter()
109 for f in filters.items():
110 queryset.query.add_filter(f)
111 usage = self.usage_for_queryset(queryset, counts)
114 def usage_for_queryset(self, queryset, counts=False):
116 Obtain a list of tags associated with instances of a model
117 contained in the given queryset.
119 If ``counts`` is True, a ``count`` attribute will be added to
120 each tag, indicating how many times it has been used against
121 the Model class in question.
123 usage = self.model.objects.filter(
124 items__content_type=ContentType.objects.get_for_model(queryset.model),
125 items__object_id__in=queryset)
127 usage = usage.annotate(count=models.Count('id'))
129 usage = usage.distinct()
132 def related_for_model(self, tags, model, counts=False):
134 Obtain a list of tags related to a given list of tags - that
135 is, other tags used by items which have all the given tags.
137 If ``counts`` is True, a ``count`` attribute will be added to
138 each tag, indicating the number of items which have it in
139 addition to the given list of tags.
141 objs = self.model.intermediary_table_model.objects.get_by_model(model, tags)
142 qs = self.usage_for_queryset(objs, counts)
143 qs = qs.exclude(pk__in=[tag.pk for tag in tags])
147 class TaggedItemManager(models.Manager):
148 def __init__(self, tag_model):
149 super(TaggedItemManager, self).__init__()
150 self.tag_model = tag_model
152 def get_by_model(self, queryset_or_model, tags):
154 Create a ``QuerySet`` containing instances of the specified
155 model associated with a given tag or list of tags.
157 queryset, model = get_queryset_and_model(queryset_or_model)
158 tags = self.tag_model.get_tag_list(tags)
160 # No existing tags were given
161 return queryset.none()
163 # TODO: presumes reverse generic relation
164 # Multiple joins are WAY faster than having-count, at least on Postgres 9.1.
166 queryset = queryset.filter(tag_relations__tag=tag)
169 def get_union_by_model(self, queryset_or_model, tags):
171 Create a ``QuerySet`` containing instances of the specified
172 model associated with *any* of the given list of tags.
174 queryset, model = get_queryset_and_model(queryset_or_model)
175 tags = self.tag_model.get_tag_list(tags)
177 return queryset.none()
178 # TODO: presumes reverse generic relation
179 return queryset.filter(tag_relations__tag__in=tags).distinct()
181 def get_related(self, obj, queryset_or_model):
183 Retrieve a list of instances of the specified model which share
184 tags with the model instance ``obj``, ordered by the number of
185 shared tags in descending order.
187 queryset, model = get_queryset_and_model(queryset_or_model)
188 # TODO: presumes reverse generic relation.
189 # Do we know it's 'tags'?
190 return queryset.filter(tag_relations__tag__in=obj.tags).annotate(
191 count=models.Count('pk')).order_by('-count').exclude(pk=obj.pk)
198 class TagMeta(ModelBase):
199 """Metaclass for tag models (models inheriting from TagBase)."""
200 def __new__(mcs, name, bases, attrs):
201 model = super(TagMeta, mcs).__new__(mcs, name, bases, attrs)
202 if not model._meta.abstract:
203 # Register custom managers for concrete models
204 TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
205 TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
209 class TagBase(models.Model):
210 """Abstract class to be inherited by model classes."""
211 __metaclass__ = TagMeta
217 def get_tag_list(tag_list):
219 Utility function for accepting tag input in a flexible manner.
221 You should probably override this method in your subclass.
223 if isinstance(tag_list, TagBase):