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
18 from django.dispatch import Signal
20 qn = connection.ops.quote_name
23 from django.db.models.query import parse_lookup
28 tags_updated = Signal(providing_args=["affected_tags"])
30 def get_queryset_and_model(queryset_or_model):
32 Given a ``QuerySet`` or a ``Model``, returns a two-tuple of
35 If a ``Model`` is given, the ``QuerySet`` returned will be created
36 using its default manager.
39 return queryset_or_model, queryset_or_model.model
40 except AttributeError:
41 return queryset_or_model._default_manager.all(), queryset_or_model
47 class TagManager(models.Manager):
48 def __init__(self, intermediary_table_model):
49 super(TagManager, self).__init__()
50 self.intermediary_table_model = intermediary_table_model
51 models.signals.pre_delete.connect(self.target_deleted)
53 def target_deleted(self, instance, **kwargs):
54 """ clear tag relations before deleting an object """
60 self.update_tags(instance, [])
62 def update_tags(self, obj, tags):
64 Update tags associated with an object.
66 content_type = ContentType.objects.get_for_model(obj)
67 current_tags = list(self.filter(items__content_type__pk=content_type.pk,
68 items__object_id=obj.pk))
69 updated_tags = self.model.get_tag_list(tags)
71 # Remove tags which no longer apply
72 tags_for_removal = [tag for tag in current_tags \
73 if tag not in updated_tags]
74 if len(tags_for_removal):
75 self.intermediary_table_model._default_manager.filter(content_type__pk=content_type.pk,
77 tag__in=tags_for_removal).delete()
79 tags_to_add = [tag for tag in updated_tags
80 if tag not in current_tags]
81 for tag in tags_to_add:
82 if tag not in current_tags:
83 self.intermediary_table_model._default_manager.create(tag=tag, content_object=obj)
85 tags_updated.send(sender=obj, affected_tags=tags_to_add + tags_for_removal)
87 def remove_tag(self, obj, tag):
89 Remove tag from an object.
91 content_type = ContentType.objects.get_for_model(obj)
92 self.intermediary_table_model._default_manager.filter(content_type__pk=content_type.pk,
93 object_id=obj.pk, tag=tag).delete()
95 def get_for_object(self, obj):
97 Create a queryset matching all tags associated with the given
100 ctype = ContentType.objects.get_for_model(obj)
101 return self.filter(items__content_type__pk=ctype.pk,
102 items__object_id=obj.pk)
104 def _get_usage(self, model, counts=False, min_count=None, extra_joins=None, extra_criteria=None, params=None, extra=None):
106 Perform the custom SQL query for ``usage_for_model`` and
107 ``usage_for_queryset``.
109 if min_count is not None: counts = True
111 model_table = qn(model._meta.db_table)
112 model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column))
113 tag_columns = self._get_tag_columns()
115 if extra is None: extra = {}
118 extra_where = 'AND ' + ' AND '.join(extra['where'])
121 SELECT DISTINCT %(tag_columns)s%(count_sql)s
124 INNER JOIN %(tagged_item)s
125 ON %(tag)s.id = %(tagged_item)s.tag_id
127 ON %(tagged_item)s.object_id = %(model_pk)s
129 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
132 GROUP BY %(tag_columns)s, %(tag)s.id, %(tag)s.name
134 ORDER BY %(tag)s.%(ordering)s ASC""" % {
135 'tag': qn(self.model._meta.db_table),
136 'ordering': ', '.join(qn(field) for field in self.model._meta.ordering),
137 'tag_columns': tag_columns,
138 'count_sql': counts and (', COUNT(%s)' % model_pk) or '',
139 'tagged_item': qn(self.intermediary_table_model._meta.db_table),
140 'model': model_table,
141 'model_pk': model_pk,
142 'extra_where': extra_where,
143 'content_type_id': ContentType.objects.get_for_model(model).pk,
147 if min_count is not None:
148 min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk
149 params.append(min_count)
151 cursor = connection.cursor()
152 cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params)
154 for row in cursor.fetchall():
155 t = self.model(*row[:len(self.model._meta.fields)])
157 t.count = row[len(self.model._meta.fields)]
161 def usage_for_model(self, model, counts=False, min_count=None, filters=None, extra=None):
163 Obtain a list of tags associated with instances of the given
166 If ``counts`` is True, a ``count`` attribute will be added to
167 each tag, indicating how many times it has been used against
168 the Model class in question.
170 If ``min_count`` is given, only tags which have a ``count``
171 greater than or equal to ``min_count`` will be returned.
172 Passing a value for ``min_count`` implies ``counts=True``.
174 To limit the tags (and counts, if specified) returned to those
175 used by a subset of the Model's instances, pass a dictionary
176 of field lookups to be applied to the given Model as the
177 ``filters`` argument.
179 if extra is None: extra = {}
180 if filters is None: filters = {}
183 # post-queryset-refactor (hand off to usage_for_queryset)
184 queryset = model._default_manager.filter()
185 for f in filters.items():
186 queryset.query.add_filter(f)
187 usage = self.usage_for_queryset(queryset, counts, min_count, extra)
189 # pre-queryset-refactor
194 joins, where, params = parse_lookup(filters.items(), model._meta)
195 extra_joins = ' '.join(['%s %s AS %s ON %s' % (join_type, table, alias, condition)
196 for (alias, (table, join_type, condition)) in joins.items()])
197 extra_criteria = 'AND %s' % (' AND '.join(where))
198 usage = self._get_usage(model, counts, min_count, extra_joins, extra_criteria, params, extra)
202 def usage_for_queryset(self, queryset, counts=False, min_count=None, extra=None):
204 Obtain a list of tags associated with instances of a model
205 contained in the given queryset.
207 If ``counts`` is True, a ``count`` attribute will be added to
208 each tag, indicating how many times it has been used against
209 the Model class in question.
211 If ``min_count`` is given, only tags which have a ``count``
212 greater than or equal to ``min_count`` will be returned.
213 Passing a value for ``min_count`` implies ``counts=True``.
216 raise AttributeError("'TagManager.usage_for_queryset' is not compatible with pre-queryset-refactor versions of Django.")
218 extra_joins = ' '.join(queryset.query.get_from_clause()[0][1:])
219 where, params = queryset.query.where.as_sql()
221 extra_criteria = 'AND %s' % where
224 return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params, extra)
226 def related_for_model(self, tags, model, counts=False, min_count=None, extra=None):
228 Obtain a list of tags related to a given list of tags - that
229 is, other tags used by items which have all the given tags.
231 If ``counts`` is True, a ``count`` attribute will be added to
232 each tag, indicating the number of items which have it in
233 addition to the given list of tags.
235 If ``min_count`` is given, only tags which have a ``count``
236 greater than or equal to ``min_count`` will be returned.
237 Passing a value for ``min_count`` implies ``counts=True``.
239 if min_count is not None: counts = True
240 tags = self.model.get_tag_list(tags)
241 tag_count = len(tags)
242 tagged_item_table = qn(self.intermediary_table_model._meta.db_table)
243 tag_columns = self._get_tag_columns()
245 if extra is None: extra = {}
248 extra_where = 'AND ' + ' AND '.join(extra['where'])
250 # Temporary table in this query is a hack to prevent MySQL from executing
251 # inner query as dependant query (which could result in severe performance loss)
253 SELECT %(tag_columns)s%(count_sql)s
254 FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id
255 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
256 AND %(tagged_item)s.object_id IN
260 SELECT %(tagged_item)s.object_id
261 FROM %(tagged_item)s, %(tag)s
262 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
263 AND %(tag)s.id = %(tagged_item)s.tag_id
264 AND %(tag)s.id IN (%(tag_id_placeholders)s)
265 GROUP BY %(tagged_item)s.object_id
266 HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s
269 AND %(tag)s.id NOT IN (%(tag_id_placeholders)s)
271 GROUP BY %(tag_columns)s
273 ORDER BY %(tag)s.%(ordering)s ASC""" % {
274 'tag': qn(self.model._meta.db_table),
275 'ordering': ', '.join(qn(field) for field in self.model._meta.ordering),
276 'tag_columns': tag_columns,
277 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '',
278 'tagged_item': tagged_item_table,
279 'content_type_id': ContentType.objects.get_for_model(model).pk,
280 'tag_id_placeholders': ','.join(['%s'] * tag_count),
281 'extra_where': extra_where,
282 'tag_count': tag_count,
283 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '',
286 params = [tag.pk for tag in tags] * 2
287 if min_count is not None:
288 params.append(min_count)
290 cursor = connection.cursor()
291 cursor.execute(query, params)
293 for row in cursor.fetchall():
294 tag = self.model(*row[:len(self.model._meta.fields)])
296 tag.count = row[len(self.model._meta.fields)]
300 def _get_tag_columns(self):
301 tag_table = qn(self.model._meta.db_table)
302 return ', '.join('%s.%s' % (tag_table, qn(field.column)) for field in self.model._meta.fields)
305 class TaggedItemManager(models.Manager):
307 FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING``
308 SQL clauses required by many of this manager's methods into
311 For now, we manually execute a query to retrieve the PKs of
312 objects we're interested in, then use the ORM's ``__in``
313 lookup to return a ``QuerySet``.
315 Once the queryset-refactor branch lands in trunk, this can be
316 tidied up significantly.
318 def __init__(self, tag_model):
319 super(TaggedItemManager, self).__init__()
320 self.tag_model = tag_model
322 def get_by_model(self, queryset_or_model, tags):
324 Create a ``QuerySet`` containing instances of the specified
325 model associated with a given tag or list of tags.
327 tags = self.tag_model.get_tag_list(tags)
328 tag_count = len(tags)
330 # No existing tags were given
331 queryset, model = get_queryset_and_model(queryset_or_model)
332 return model._default_manager.none()
334 # Optimisation for single tag - fall through to the simpler
338 return self.get_intersection_by_model(queryset_or_model, tags)
340 queryset, model = get_queryset_and_model(queryset_or_model)
341 content_type = ContentType.objects.get_for_model(model)
342 opts = self.model._meta
343 tagged_item_table = qn(opts.db_table)
344 return queryset.extra(
345 tables=[opts.db_table],
347 '%s.content_type_id = %%s' % tagged_item_table,
348 '%s.tag_id = %%s' % tagged_item_table,
349 '%s.%s = %s.object_id' % (qn(model._meta.db_table),
350 qn(model._meta.pk.column),
353 params=[content_type.pk, tag.pk],
356 def get_intersection_by_model(self, queryset_or_model, tags):
358 Create a ``QuerySet`` containing instances of the specified
359 model associated with *all* of the given list of tags.
361 tags = self.tag_model.get_tag_list(tags)
362 tag_count = len(tags)
363 queryset, model = get_queryset_and_model(queryset_or_model)
366 return model._default_manager.none()
368 model_table = qn(model._meta.db_table)
369 # This query selects the ids of all objects which have all the
373 FROM %(model)s, %(tagged_item)s
374 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
375 AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
376 AND %(model_pk)s = %(tagged_item)s.object_id
377 GROUP BY %(model_pk)s
378 HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % {
379 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
380 'model': model_table,
381 'tagged_item': qn(self.model._meta.db_table),
382 'content_type_id': ContentType.objects.get_for_model(model).pk,
383 'tag_id_placeholders': ','.join(['%s'] * tag_count),
384 'tag_count': tag_count,
387 cursor = connection.cursor()
388 cursor.execute(query, [tag.pk for tag in tags])
389 object_ids = [row[0] for row in cursor.fetchall()]
390 if len(object_ids) > 0:
391 return queryset.filter(pk__in=object_ids)
393 return model._default_manager.none()
395 def get_union_by_model(self, queryset_or_model, tags):
397 Create a ``QuerySet`` containing instances of the specified
398 model associated with *any* of the given list of tags.
400 tags = self.tag_model.get_tag_list(tags)
401 tag_count = len(tags)
402 queryset, model = get_queryset_and_model(queryset_or_model)
405 return model._default_manager.none()
407 model_table = qn(model._meta.db_table)
408 # This query selects the ids of all objects which have any of
412 FROM %(model)s, %(tagged_item)s
413 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
414 AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
415 AND %(model_pk)s = %(tagged_item)s.object_id
416 GROUP BY %(model_pk)s""" % {
417 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
418 'model': model_table,
419 'tagged_item': qn(self.model._meta.db_table),
420 'content_type_id': ContentType.objects.get_for_model(model).pk,
421 'tag_id_placeholders': ','.join(['%s'] * tag_count),
424 cursor = connection.cursor()
425 cursor.execute(query, [tag.pk for tag in tags])
426 object_ids = [row[0] for row in cursor.fetchall()]
427 if len(object_ids) > 0:
428 return queryset.filter(pk__in=object_ids)
430 return model._default_manager.none()
432 def get_related(self, obj, queryset_or_model, num=None):
434 Retrieve a list of instances of the specified model which share
435 tags with the model instance ``obj``, ordered by the number of
436 shared tags in descending order.
438 If ``num`` is given, a maximum of ``num`` instances will be
441 queryset, model = get_queryset_and_model(queryset_or_model)
442 model_table = qn(model._meta.db_table)
443 content_type = ContentType.objects.get_for_model(obj)
444 related_content_type = ContentType.objects.get_for_model(model)
446 SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s
447 FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item
448 WHERE %(tagged_item)s.object_id = %%s
449 AND %(tagged_item)s.content_type_id = %(content_type_id)s
450 AND %(tag)s.id = %(tagged_item)s.tag_id
451 AND related_tagged_item.content_type_id = %(related_content_type_id)s
452 AND related_tagged_item.tag_id = %(tagged_item)s.tag_id
453 AND %(model_pk)s = related_tagged_item.object_id"""
454 if content_type.pk == related_content_type.pk:
455 # Exclude the given instance itself if determining related
456 # instances for the same model.
458 AND related_tagged_item.object_id != %(tagged_item)s.object_id"""
460 GROUP BY %(model_pk)s
461 ORDER BY %(count)s DESC
464 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
465 'count': qn('count'),
466 'model': model_table,
467 'tagged_item': qn(self.model._meta.db_table),
468 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table),
469 'content_type_id': content_type.pk,
470 'related_content_type_id': related_content_type.pk,
471 'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '',
474 cursor = connection.cursor()
475 cursor.execute(query, [obj.pk])
476 object_ids = [row[0] for row in cursor.fetchall()]
477 if len(object_ids) > 0:
478 # Use in_bulk here instead of an id__in lookup, because id__in would
479 # clobber the ordering.
480 object_dict = queryset.in_bulk(object_ids)
481 return [object_dict[object_id] for object_id in object_ids \
482 if object_id in object_dict]
490 def create_intermediary_table_model(model):
491 """Create an intermediary table model for the specific tag model"""
492 name = model.__name__ + 'Relation'
495 db_table = '%s_relation' % model._meta.db_table
496 unique_together = (('tag', 'content_type', 'object_id'),)
498 def obj_unicode(self):
500 return u'%s [%s]' % (self.content_type.get_object_for_this_type(pk=self.object_id), self.tag)
501 except ObjectDoesNotExist:
502 return u'<deleted> [%s]' % self.tag
504 # Set up a dictionary to simulate declarations within a class
506 '__module__': model.__module__,
508 'tag': models.ForeignKey(model, verbose_name=_('tag'), related_name='items'),
509 'content_type': models.ForeignKey(ContentType, verbose_name=_('content type')),
510 'object_id': models.PositiveIntegerField(_('object id'), db_index=True),
511 'content_object': generic.GenericForeignKey('content_type', 'object_id'),
512 '__unicode__': obj_unicode,
515 return type(name, (models.Model,), attrs)
518 class TagMeta(ModelBase):
519 "Metaclass for tag models (models inheriting from TagBase)."
520 def __new__(cls, name, bases, attrs):
521 model = super(TagMeta, cls).__new__(cls, name, bases, attrs)
522 if not model._meta.abstract:
523 # Create an intermediary table and register custom managers for concrete models
524 model.intermediary_table_model = create_intermediary_table_model(model)
525 TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
526 TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
530 class TagBase(models.Model):
531 """Abstract class to be inherited by model classes."""
532 __metaclass__ = TagMeta
538 def get_tag_list(tag_list):
540 Utility function for accepting tag input in a flexible manner.
542 You should probably override this method in your subclass.
544 if isinstance(tag_list, TagBase):