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, extra_tables=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 AS %(tagged_item_alias)s
108 ON %(tag)s.id = %(tagged_item_alias)s.tag_id
110 ON %(tagged_item_alias)s.object_id = %(model_pk)s
112 WHERE %(tagged_item_alias)s.content_type_id = %(content_type_id)s
115 GROUP BY %(tag_columns)s, %(tag)s.id, %(tag)s.name%(extra_tables)s
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 'tagged_item_alias': qn('_newtagging_' + self.intermediary_table_model._meta.db_table),
124 'model': model_table,
125 'model_pk': model_pk,
126 'extra_where': extra_where,
127 'extra_tables': ''.join((', %s.id' % qn(table)) for table in extra_tables),
128 'content_type_id': ContentType.objects.get_for_model(model).pk,
132 if min_count is not None:
133 min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk
134 params.append(min_count)
136 cursor = connection.cursor()
137 cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params)
139 for row in cursor.fetchall():
140 t = self.model(*row[:len(self.model._meta.fields)])
142 t.count = row[len(self.model._meta.fields)]
146 def usage_for_model(self, model, counts=False, min_count=None, filters=None, extra=None):
148 Obtain a list of tags associated with instances of the given
151 If ``counts`` is True, a ``count`` attribute will be added to
152 each tag, indicating how many times it has been used against
153 the Model class in question.
155 If ``min_count`` is given, only tags which have a ``count``
156 greater than or equal to ``min_count`` will be returned.
157 Passing a value for ``min_count`` implies ``counts=True``.
159 To limit the tags (and counts, if specified) returned to those
160 used by a subset of the Model's instances, pass a dictionary
161 of field lookups to be applied to the given Model as the
162 ``filters`` argument.
164 if extra is None: extra = {}
165 if filters is None: filters = {}
168 # post-queryset-refactor (hand off to usage_for_queryset)
169 queryset = model._default_manager.filter()
170 for f in filters.items():
171 queryset.query.add_filter(f)
172 usage = self.usage_for_queryset(queryset, counts, min_count, extra)
174 # pre-queryset-refactor
179 joins, where, params = parse_lookup(filters.items(), model._meta)
180 extra_joins = ' '.join(['%s %s AS %s ON %s' % (join_type, table, alias, condition)
181 for (alias, (table, join_type, condition)) in joins.items()])
182 extra_criteria = 'AND %s' % (' AND '.join(where))
183 usage = self._get_usage(model, counts, min_count, extra_joins, extra_criteria, params, extra)
187 def usage_for_queryset(self, queryset, counts=False, min_count=None, extra=None):
189 Obtain a list of tags associated with instances of a model
190 contained in the given queryset.
192 If ``counts`` is True, a ``count`` attribute will be added to
193 each tag, indicating how many times it has been used against
194 the Model class in question.
196 If ``min_count`` is given, only tags which have a ``count``
197 greater than or equal to ``min_count`` will be returned.
198 Passing a value for ``min_count`` implies ``counts=True``.
201 raise AttributeError("'TagManager.usage_for_queryset' is not compatible with pre-queryset-refactor versions of Django.")
203 extra_joins = ' '.join(queryset.query.get_from_clause()[0][1:])
204 where, params = queryset.query.where.as_sql()
205 extra_tables = queryset.query.extra_tables
207 extra_criteria = 'AND %s' % where
210 return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params, extra, extra_tables=extra_tables)
212 def related_for_model(self, tags, model, counts=False, min_count=None, extra=None):
214 Obtain a list of tags related to a given list of tags - that
215 is, other tags used by items which have all the given tags.
217 If ``counts`` is True, a ``count`` attribute will be added to
218 each tag, indicating the number of items which have it in
219 addition to the given list of tags.
221 If ``min_count`` is given, only tags which have a ``count``
222 greater than or equal to ``min_count`` will be returned.
223 Passing a value for ``min_count`` implies ``counts=True``.
225 if min_count is not None: counts = True
226 tags = self.model.get_tag_list(tags)
227 tag_count = len(tags)
228 tagged_item_table = qn(self.intermediary_table_model._meta.db_table)
229 tag_columns = self._get_tag_columns()
231 if extra is None: extra = {}
234 extra_where = 'AND ' + ' AND '.join(extra['where'])
236 # Temporary table in this query is a hack to prevent MySQL from executing
237 # inner query as dependant query (which could result in severe performance loss)
239 SELECT %(tag_columns)s%(count_sql)s
240 FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id
241 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
242 AND %(tagged_item)s.object_id IN
246 SELECT %(tagged_item)s.object_id
247 FROM %(tagged_item)s, %(tag)s
248 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
249 AND %(tag)s.id = %(tagged_item)s.tag_id
250 AND %(tag)s.id IN (%(tag_id_placeholders)s)
251 GROUP BY %(tagged_item)s.object_id
252 HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s
255 AND %(tag)s.id NOT IN (%(tag_id_placeholders)s)
257 GROUP BY %(tag_columns)s
259 ORDER BY %(tag)s.%(ordering)s ASC""" % {
260 'tag': qn(self.model._meta.db_table),
261 'ordering': ', '.join(qn(field) for field in self.model._meta.ordering),
262 'tag_columns': tag_columns,
263 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '',
264 'tagged_item': tagged_item_table,
265 'content_type_id': ContentType.objects.get_for_model(model).pk,
266 'tag_id_placeholders': ','.join(['%s'] * tag_count),
267 'extra_where': extra_where,
268 'tag_count': tag_count,
269 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '',
272 params = [tag.pk for tag in tags] * 2
273 if min_count is not None:
274 params.append(min_count)
276 cursor = connection.cursor()
277 cursor.execute(query, params)
279 for row in cursor.fetchall():
280 tag = self.model(*row[:len(self.model._meta.fields)])
282 tag.count = row[len(self.model._meta.fields)]
286 def _get_tag_columns(self):
287 tag_table = qn(self.model._meta.db_table)
288 return ', '.join('%s.%s' % (tag_table, qn(field.column)) for field in self.model._meta.fields)
291 class TaggedItemManager(models.Manager):
293 FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING``
294 SQL clauses required by many of this manager's methods into
297 For now, we manually execute a query to retrieve the PKs of
298 objects we're interested in, then use the ORM's ``__in``
299 lookup to return a ``QuerySet``.
301 Once the queryset-refactor branch lands in trunk, this can be
302 tidied up significantly.
304 def __init__(self, tag_model):
305 super(TaggedItemManager, self).__init__()
306 self.tag_model = tag_model
308 def get_by_model(self, queryset_or_model, tags):
310 Create a ``QuerySet`` containing instances of the specified
311 model associated with a given tag or list of tags.
313 tags = self.tag_model.get_tag_list(tags)
314 tag_count = len(tags)
316 # No existing tags were given
317 queryset, model = get_queryset_and_model(queryset_or_model)
318 return model._default_manager.none()
320 # Optimisation for single tag - fall through to the simpler
324 return self.get_intersection_by_model(queryset_or_model, tags)
326 queryset, model = get_queryset_and_model(queryset_or_model)
327 content_type = ContentType.objects.get_for_model(model)
328 opts = self.model._meta
329 tagged_item_table = qn(opts.db_table)
330 return queryset.extra(
331 tables=[opts.db_table],
333 '%s.content_type_id = %%s' % tagged_item_table,
334 '%s.tag_id = %%s' % tagged_item_table,
335 '%s.%s = %s.object_id' % (qn(model._meta.db_table),
336 qn(model._meta.pk.column),
339 params=[content_type.pk, tag.pk],
342 def get_intersection_by_model(self, queryset_or_model, tags):
344 Create a ``QuerySet`` containing instances of the specified
345 model associated with *all* of the given list of tags.
347 tags = self.tag_model.get_tag_list(tags)
348 tag_count = len(tags)
349 queryset, model = get_queryset_and_model(queryset_or_model)
352 return model._default_manager.none()
354 model_table = qn(model._meta.db_table)
355 # This query selects the ids of all objects which have all the
359 FROM %(model)s, %(tagged_item)s
360 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
361 AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
362 AND %(model_pk)s = %(tagged_item)s.object_id
363 GROUP BY %(model_pk)s
364 HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % {
365 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
366 'model': model_table,
367 'tagged_item': qn(self.model._meta.db_table),
368 'content_type_id': ContentType.objects.get_for_model(model).pk,
369 'tag_id_placeholders': ','.join(['%s'] * tag_count),
370 'tag_count': tag_count,
373 cursor = connection.cursor()
374 cursor.execute(query, [tag.pk for tag in tags])
375 object_ids = [row[0] for row in cursor.fetchall()]
376 if len(object_ids) > 0:
377 return queryset.filter(pk__in=object_ids)
379 return model._default_manager.none()
381 def get_union_by_model(self, queryset_or_model, tags):
383 Create a ``QuerySet`` containing instances of the specified
384 model associated with *any* of the given list of tags.
386 tags = self.tag_model.get_tag_list(tags)
387 tag_count = len(tags)
388 queryset, model = get_queryset_and_model(queryset_or_model)
391 return model._default_manager.none()
393 model_table = qn(model._meta.db_table)
394 # This query selects the ids of all objects which have any of
398 FROM %(model)s, %(tagged_item)s
399 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
400 AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
401 AND %(model_pk)s = %(tagged_item)s.object_id
402 GROUP BY %(model_pk)s""" % {
403 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
404 'model': model_table,
405 'tagged_item': qn(self.model._meta.db_table),
406 'content_type_id': ContentType.objects.get_for_model(model).pk,
407 'tag_id_placeholders': ','.join(['%s'] * tag_count),
410 cursor = connection.cursor()
411 cursor.execute(query, [tag.pk for tag in tags])
412 object_ids = [row[0] for row in cursor.fetchall()]
413 if len(object_ids) > 0:
414 return queryset.filter(pk__in=object_ids)
416 return model._default_manager.none()
418 def get_related(self, obj, queryset_or_model, num=None):
420 Retrieve a list of instances of the specified model which share
421 tags with the model instance ``obj``, ordered by the number of
422 shared tags in descending order.
424 If ``num`` is given, a maximum of ``num`` instances will be
427 queryset, model = get_queryset_and_model(queryset_or_model)
428 model_table = qn(model._meta.db_table)
429 content_type = ContentType.objects.get_for_model(obj)
430 related_content_type = ContentType.objects.get_for_model(model)
432 SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s
433 FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item
434 WHERE %(tagged_item)s.object_id = %%s
435 AND %(tagged_item)s.content_type_id = %(content_type_id)s
436 AND %(tag)s.id = %(tagged_item)s.tag_id
437 AND related_tagged_item.content_type_id = %(related_content_type_id)s
438 AND related_tagged_item.tag_id = %(tagged_item)s.tag_id
439 AND %(model_pk)s = related_tagged_item.object_id"""
440 if content_type.pk == related_content_type.pk:
441 # Exclude the given instance itself if determining related
442 # instances for the same model.
444 AND related_tagged_item.object_id != %(tagged_item)s.object_id"""
446 GROUP BY %(model_pk)s
447 ORDER BY %(count)s DESC
450 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
451 'count': qn('count'),
452 'model': model_table,
453 'tagged_item': qn(self.model._meta.db_table),
454 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table),
455 'content_type_id': content_type.pk,
456 'related_content_type_id': related_content_type.pk,
457 'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '',
460 cursor = connection.cursor()
461 cursor.execute(query, [obj.pk])
462 object_ids = [row[0] for row in cursor.fetchall()]
463 if len(object_ids) > 0:
464 # Use in_bulk here instead of an id__in lookup, because id__in would
465 # clobber the ordering.
466 object_dict = queryset.in_bulk(object_ids)
467 return [object_dict[object_id] for object_id in object_ids \
468 if object_id in object_dict]
476 def create_intermediary_table_model(model):
477 """Create an intermediary table model for the specific tag model"""
478 name = model.__name__ + 'Relation'
481 db_table = '%s_relation' % model._meta.db_table
482 unique_together = (('tag', 'content_type', 'object_id'),)
484 def obj_unicode(self):
486 return u'%s [%s]' % (self.content_type.get_object_for_this_type(pk=self.object_id), self.tag)
487 except ObjectDoesNotExist:
488 return u'<deleted> [%s]' % self.tag
490 # Set up a dictionary to simulate declarations within a class
492 '__module__': model.__module__,
494 'tag': models.ForeignKey(model, verbose_name=_('tag'), related_name='items'),
495 'content_type': models.ForeignKey(ContentType, verbose_name=_('content type')),
496 'object_id': models.PositiveIntegerField(_('object id'), db_index=True),
497 'content_object': generic.GenericForeignKey('content_type', 'object_id'),
498 '__unicode__': obj_unicode,
501 return type(name, (models.Model,), attrs)
504 class TagMeta(ModelBase):
505 "Metaclass for tag models (models inheriting from TagBase)."
506 def __new__(cls, name, bases, attrs):
507 model = super(TagMeta, cls).__new__(cls, name, bases, attrs)
508 if not model._meta.abstract:
509 # Create an intermediary table and register custom managers for concrete models
510 model.intermediary_table_model = create_intermediary_table_model(model)
511 TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
512 TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
516 class TagBase(models.Model):
517 """Abstract class to be inherited by model classes."""
518 __metaclass__ = TagMeta
524 def get_tag_list(tag_list):
526 Utility function for accepting tag input in a flexible manner.
528 You should probably override this method in your subclass.
530 if isinstance(tag_list, TagBase):