1 # -*- coding: utf-8 -*-
3 Models and managers for generic tagging.
6 # Python 2.3 compatibility
10 from sets import Set as set
12 from django.contrib.contenttypes import generic
13 from django.contrib.contenttypes.models import ContentType
14 from django.db import connection, models
15 from django.utils.translation import ugettext_lazy as _
16 from django.db.models.base import ModelBase
17 from django.core.exceptions import ObjectDoesNotExist
19 qn = connection.ops.quote_name
22 from django.db.models.query import parse_lookup
27 def get_queryset_and_model(queryset_or_model):
29 Given a ``QuerySet`` or a ``Model``, returns a two-tuple of
32 If a ``Model`` is given, the ``QuerySet`` returned will be created
33 using its default manager.
36 return queryset_or_model, queryset_or_model.model
37 except AttributeError:
38 return queryset_or_model._default_manager.all(), queryset_or_model
44 class TagManager(models.Manager):
45 def __init__(self, intermediary_table_model):
46 super(TagManager, self).__init__()
47 self.intermediary_table_model = intermediary_table_model
49 def update_tags(self, obj, tags):
51 Update tags associated with an object.
53 content_type = ContentType.objects.get_for_model(obj)
54 current_tags = list(self.filter(items__content_type__pk=content_type.pk,
55 items__object_id=obj.pk))
56 updated_tags = self.model.get_tag_list(tags)
58 # Remove tags which no longer apply
59 tags_for_removal = [tag for tag in current_tags \
60 if tag not in updated_tags]
61 if len(tags_for_removal):
62 self.intermediary_table_model._default_manager.filter(content_type__pk=content_type.pk,
64 tag__in=tags_for_removal).delete()
66 for tag in updated_tags:
67 if tag not in current_tags:
68 self.intermediary_table_model._default_manager.create(tag=tag, content_object=obj)
70 def remove_tag(self, obj, tag):
72 Remove tag from an object.
74 content_type = ContentType.objects.get_for_model(obj)
75 self.intermediary_table_model._default_manager.filter(content_type__pk=content_type.pk,
76 object_id=obj.pk, tag=tag).delete()
78 def get_for_object(self, obj):
80 Create a queryset matching all tags associated with the given
83 ctype = ContentType.objects.get_for_model(obj)
84 return self.filter(items__content_type__pk=ctype.pk,
85 items__object_id=obj.pk)
87 def _get_usage(self, model, counts=False, min_count=None, extra_joins=None, extra_criteria=None, params=None, extra=None):
89 Perform the custom SQL query for ``usage_for_model`` and
90 ``usage_for_queryset``.
92 if min_count is not None: counts = True
94 model_table = qn(model._meta.db_table)
95 model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column))
96 tag_columns = self._get_tag_columns()
98 if extra is None: extra = {}
101 extra_where = 'AND ' + ' AND '.join(extra['where'])
104 SELECT DISTINCT %(tag_columns)s%(count_sql)s
107 INNER JOIN %(tagged_item)s
108 ON %(tag)s.id = %(tagged_item)s.tag_id
110 ON %(tagged_item)s.object_id = %(model_pk)s
112 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
115 GROUP BY %(tag)s.id, %(tag)s.name
117 ORDER BY %(tag)s.%(ordering)s ASC""" % {
118 'tag': qn(self.model._meta.db_table),
119 'ordering': ', '.join(qn(field) for field in self.model._meta.ordering),
120 'tag_columns': tag_columns,
121 'count_sql': counts and (', COUNT(%s)' % model_pk) or '',
122 'tagged_item': qn(self.intermediary_table_model._meta.db_table),
123 'model': model_table,
124 'model_pk': model_pk,
125 'extra_where': extra_where,
126 'content_type_id': ContentType.objects.get_for_model(model).pk,
130 if min_count is not None:
131 min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk
132 params.append(min_count)
134 cursor = connection.cursor()
135 cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params)
137 for row in cursor.fetchall():
138 t = self.model(*row[:len(self.model._meta.fields)])
140 t.count = row[len(self.model._meta.fields)]
144 def usage_for_model(self, model, counts=False, min_count=None, filters=None, extra=None):
146 Obtain a list of tags associated with instances of the given
149 If ``counts`` is True, a ``count`` attribute will be added to
150 each tag, indicating how many times it has been used against
151 the Model class in question.
153 If ``min_count`` is given, only tags which have a ``count``
154 greater than or equal to ``min_count`` will be returned.
155 Passing a value for ``min_count`` implies ``counts=True``.
157 To limit the tags (and counts, if specified) returned to those
158 used by a subset of the Model's instances, pass a dictionary
159 of field lookups to be applied to the given Model as the
160 ``filters`` argument.
162 if extra is None: extra = {}
163 if filters is None: filters = {}
166 # post-queryset-refactor (hand off to usage_for_queryset)
167 queryset = model._default_manager.filter()
168 for f in filters.items():
169 queryset.query.add_filter(f)
170 usage = self.usage_for_queryset(queryset, counts, min_count, extra)
172 # pre-queryset-refactor
177 joins, where, params = parse_lookup(filters.items(), model._meta)
178 extra_joins = ' '.join(['%s %s AS %s ON %s' % (join_type, table, alias, condition)
179 for (alias, (table, join_type, condition)) in joins.items()])
180 extra_criteria = 'AND %s' % (' AND '.join(where))
181 usage = self._get_usage(model, counts, min_count, extra_joins, extra_criteria, params, extra)
185 def usage_for_queryset(self, queryset, counts=False, min_count=None, extra=None):
187 Obtain a list of tags associated with instances of a model
188 contained in the given queryset.
190 If ``counts`` is True, a ``count`` attribute will be added to
191 each tag, indicating how many times it has been used against
192 the Model class in question.
194 If ``min_count`` is given, only tags which have a ``count``
195 greater than or equal to ``min_count`` will be returned.
196 Passing a value for ``min_count`` implies ``counts=True``.
199 raise AttributeError("'TagManager.usage_for_queryset' is not compatible with pre-queryset-refactor versions of Django.")
201 extra_joins = ' '.join(queryset.query.get_from_clause()[0][1:])
202 where, params = queryset.query.where.as_sql()
204 extra_criteria = 'AND %s' % where
207 return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params, extra)
209 def related_for_model(self, tags, model, counts=False, min_count=None, extra=None):
211 Obtain a list of tags related to a given list of tags - that
212 is, other tags used by items which have all the given tags.
214 If ``counts`` is True, a ``count`` attribute will be added to
215 each tag, indicating the number of items which have it in
216 addition to the given list of tags.
218 If ``min_count`` is given, only tags which have a ``count``
219 greater than or equal to ``min_count`` will be returned.
220 Passing a value for ``min_count`` implies ``counts=True``.
222 if min_count is not None: counts = True
223 tags = self.model.get_tag_list(tags)
224 tag_count = len(tags)
225 tagged_item_table = qn(self.intermediary_table_model._meta.db_table)
226 tag_columns = self._get_tag_columns()
228 if extra is None: extra = {}
231 extra_where = 'AND ' + ' AND '.join(extra['where'])
233 # Temporary table in this query is a hack to prevent MySQL from executing
234 # inner query as dependant query (which could result in severe performance loss)
236 SELECT %(tag_columns)s%(count_sql)s
237 FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id
238 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
239 AND %(tagged_item)s.object_id IN
243 SELECT %(tagged_item)s.object_id
244 FROM %(tagged_item)s, %(tag)s
245 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
246 AND %(tag)s.id = %(tagged_item)s.tag_id
247 AND %(tag)s.id IN (%(tag_id_placeholders)s)
248 GROUP BY %(tagged_item)s.object_id
249 HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s
252 AND %(tag)s.id NOT IN (%(tag_id_placeholders)s)
254 GROUP BY %(tag_columns)s
256 ORDER BY %(tag)s.%(ordering)s ASC""" % {
257 'tag': qn(self.model._meta.db_table),
258 'ordering': ', '.join(qn(field) for field in self.model._meta.ordering),
259 'tag_columns': tag_columns,
260 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '',
261 'tagged_item': tagged_item_table,
262 'content_type_id': ContentType.objects.get_for_model(model).pk,
263 'tag_id_placeholders': ','.join(['%s'] * tag_count),
264 'extra_where': extra_where,
265 'tag_count': tag_count,
266 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '',
269 params = [tag.pk for tag in tags] * 2
270 if min_count is not None:
271 params.append(min_count)
273 cursor = connection.cursor()
274 cursor.execute(query, params)
276 for row in cursor.fetchall():
277 tag = self.model(*row[:len(self.model._meta.fields)])
279 tag.count = row[len(self.model._meta.fields)]
283 def _get_tag_columns(self):
284 tag_table = qn(self.model._meta.db_table)
285 return ', '.join('%s.%s' % (tag_table, qn(field.column)) for field in self.model._meta.fields)
288 class TaggedItemManager(models.Manager):
290 FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING``
291 SQL clauses required by many of this manager's methods into
294 For now, we manually execute a query to retrieve the PKs of
295 objects we're interested in, then use the ORM's ``__in``
296 lookup to return a ``QuerySet``.
298 Once the queryset-refactor branch lands in trunk, this can be
299 tidied up significantly.
301 def __init__(self, tag_model):
302 super(TaggedItemManager, self).__init__()
303 self.tag_model = tag_model
305 def get_by_model(self, queryset_or_model, tags):
307 Create a ``QuerySet`` containing instances of the specified
308 model associated with a given tag or list of tags.
310 tags = self.tag_model.get_tag_list(tags)
311 tag_count = len(tags)
313 # No existing tags were given
314 queryset, model = get_queryset_and_model(queryset_or_model)
315 return model._default_manager.none()
317 # Optimisation for single tag - fall through to the simpler
321 return self.get_intersection_by_model(queryset_or_model, tags)
323 queryset, model = get_queryset_and_model(queryset_or_model)
324 content_type = ContentType.objects.get_for_model(model)
325 opts = self.model._meta
326 tagged_item_table = qn(opts.db_table)
327 return queryset.extra(
328 tables=[opts.db_table],
330 '%s.content_type_id = %%s' % tagged_item_table,
331 '%s.tag_id = %%s' % tagged_item_table,
332 '%s.%s = %s.object_id' % (qn(model._meta.db_table),
333 qn(model._meta.pk.column),
336 params=[content_type.pk, tag.pk],
339 def get_intersection_by_model(self, queryset_or_model, tags):
341 Create a ``QuerySet`` containing instances of the specified
342 model associated with *all* of the given list of tags.
344 tags = self.tag_model.get_tag_list(tags)
345 tag_count = len(tags)
346 queryset, model = get_queryset_and_model(queryset_or_model)
349 return model._default_manager.none()
351 model_table = qn(model._meta.db_table)
352 # This query selects the ids of all objects which have all the
356 FROM %(model)s, %(tagged_item)s
357 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
358 AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
359 AND %(model_pk)s = %(tagged_item)s.object_id
360 GROUP BY %(model_pk)s
361 HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % {
362 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
363 'model': model_table,
364 'tagged_item': qn(self.model._meta.db_table),
365 'content_type_id': ContentType.objects.get_for_model(model).pk,
366 'tag_id_placeholders': ','.join(['%s'] * tag_count),
367 'tag_count': tag_count,
370 cursor = connection.cursor()
371 cursor.execute(query, [tag.pk for tag in tags])
372 object_ids = [row[0] for row in cursor.fetchall()]
373 if len(object_ids) > 0:
374 return queryset.filter(pk__in=object_ids)
376 return model._default_manager.none()
378 def get_union_by_model(self, queryset_or_model, tags):
380 Create a ``QuerySet`` containing instances of the specified
381 model associated with *any* of the given list of tags.
383 tags = self.tag_model.get_tag_list(tags)
384 tag_count = len(tags)
385 queryset, model = get_queryset_and_model(queryset_or_model)
388 return model._default_manager.none()
390 model_table = qn(model._meta.db_table)
391 # This query selects the ids of all objects which have any of
395 FROM %(model)s, %(tagged_item)s
396 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
397 AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
398 AND %(model_pk)s = %(tagged_item)s.object_id
399 GROUP BY %(model_pk)s""" % {
400 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
401 'model': model_table,
402 'tagged_item': qn(self.model._meta.db_table),
403 'content_type_id': ContentType.objects.get_for_model(model).pk,
404 'tag_id_placeholders': ','.join(['%s'] * tag_count),
407 cursor = connection.cursor()
408 cursor.execute(query, [tag.pk for tag in tags])
409 object_ids = [row[0] for row in cursor.fetchall()]
410 if len(object_ids) > 0:
411 return queryset.filter(pk__in=object_ids)
413 return model._default_manager.none()
415 def get_related(self, obj, queryset_or_model, num=None):
417 Retrieve a list of instances of the specified model which share
418 tags with the model instance ``obj``, ordered by the number of
419 shared tags in descending order.
421 If ``num`` is given, a maximum of ``num`` instances will be
424 queryset, model = get_queryset_and_model(queryset_or_model)
425 model_table = qn(model._meta.db_table)
426 content_type = ContentType.objects.get_for_model(obj)
427 related_content_type = ContentType.objects.get_for_model(model)
429 SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s
430 FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item
431 WHERE %(tagged_item)s.object_id = %%s
432 AND %(tagged_item)s.content_type_id = %(content_type_id)s
433 AND %(tag)s.id = %(tagged_item)s.tag_id
434 AND related_tagged_item.content_type_id = %(related_content_type_id)s
435 AND related_tagged_item.tag_id = %(tagged_item)s.tag_id
436 AND %(model_pk)s = related_tagged_item.object_id"""
437 if content_type.pk == related_content_type.pk:
438 # Exclude the given instance itself if determining related
439 # instances for the same model.
441 AND related_tagged_item.object_id != %(tagged_item)s.object_id"""
443 GROUP BY %(model_pk)s
444 ORDER BY %(count)s DESC
447 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
448 'count': qn('count'),
449 'model': model_table,
450 'tagged_item': qn(self.model._meta.db_table),
451 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table),
452 'content_type_id': content_type.pk,
453 'related_content_type_id': related_content_type.pk,
454 'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '',
457 cursor = connection.cursor()
458 cursor.execute(query, [obj.pk])
459 object_ids = [row[0] for row in cursor.fetchall()]
460 if len(object_ids) > 0:
461 # Use in_bulk here instead of an id__in lookup, because id__in would
462 # clobber the ordering.
463 object_dict = queryset.in_bulk(object_ids)
464 return [object_dict[object_id] for object_id in object_ids \
465 if object_id in object_dict]
473 def create_intermediary_table_model(model):
474 """Create an intermediary table model for the specific tag model"""
475 name = model.__name__ + 'Relation'
478 db_table = '%s_relation' % model._meta.db_table
479 unique_together = (('tag', 'content_type', 'object_id'),)
481 def obj_unicode(self):
483 return u'%s [%s]' % (self.content_type.get_object_for_this_type(pk=self.object_id), self.tag)
484 except ObjectDoesNotExist:
485 return u'<deleted> [%s]' % self.tag
487 # Set up a dictionary to simulate declarations within a class
489 '__module__': model.__module__,
491 'tag': models.ForeignKey(model, verbose_name=_('tag'), related_name='items'),
492 'content_type': models.ForeignKey(ContentType, verbose_name=_('content type')),
493 'object_id': models.PositiveIntegerField(_('object id'), db_index=True),
494 'content_object': generic.GenericForeignKey('content_type', 'object_id'),
495 '__unicode__': obj_unicode,
498 return type(name, (models.Model,), attrs)
501 class TagMeta(ModelBase):
502 "Metaclass for tag models (models inheriting from TagBase)."
503 def __new__(cls, name, bases, attrs):
504 model = super(TagMeta, cls).__new__(cls, name, bases, attrs)
505 if not model._meta.abstract:
506 # Create an intermediary table and register custom managers for concrete models
507 model.intermediary_table_model = create_intermediary_table_model(model)
508 TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
509 TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
513 class TagBase(models.Model):
514 """Abstract class to be inherited by model classes."""
515 __metaclass__ = TagMeta
521 def get_tag_list(tag_list):
523 Utility function for accepting tag input in a flexible manner.
525 You should probably override this method in your subclass.
527 if isinstance(tag_list, TagBase):