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 if getattr(queryset.query, 'get_compiler', None):
220 compiler = queryset.query.get_compiler(using='default')
221 extra_joins = ' '.join(compiler.get_from_clause()[0][1:])
222 where, params = queryset.query.where.as_sql(
223 compiler.quote_name_unless_alias, compiler.connection
227 extra_joins = ' '.join(queryset.query.get_from_clause()[0][1:])
228 where, params = queryset.query.where.as_sql()
231 extra_criteria = 'AND %s' % where
234 return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params, extra)
236 def related_for_model(self, tags, model, counts=False, min_count=None, extra=None):
238 Obtain a list of tags related to a given list of tags - that
239 is, other tags used by items which have all the given tags.
241 If ``counts`` is True, a ``count`` attribute will be added to
242 each tag, indicating the number of items which have it in
243 addition to the given list of tags.
245 If ``min_count`` is given, only tags which have a ``count``
246 greater than or equal to ``min_count`` will be returned.
247 Passing a value for ``min_count`` implies ``counts=True``.
249 if min_count is not None: counts = True
250 tags = self.model.get_tag_list(tags)
251 tag_count = len(tags)
252 tagged_item_table = qn(self.intermediary_table_model._meta.db_table)
253 tag_columns = self._get_tag_columns()
255 if extra is None: extra = {}
258 extra_where = 'AND ' + ' AND '.join(extra['where'])
260 # Temporary table in this query is a hack to prevent MySQL from executing
261 # inner query as dependant query (which could result in severe performance loss)
263 SELECT %(tag_columns)s%(count_sql)s
264 FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id
265 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
266 AND %(tagged_item)s.object_id IN
270 SELECT %(tagged_item)s.object_id
271 FROM %(tagged_item)s, %(tag)s
272 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
273 AND %(tag)s.id = %(tagged_item)s.tag_id
274 AND %(tag)s.id IN (%(tag_id_placeholders)s)
275 GROUP BY %(tagged_item)s.object_id
276 HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s
279 AND %(tag)s.id NOT IN (%(tag_id_placeholders)s)
281 GROUP BY %(tag_columns)s
283 ORDER BY %(tag)s.%(ordering)s ASC""" % {
284 'tag': qn(self.model._meta.db_table),
285 'ordering': ', '.join(qn(field) for field in self.model._meta.ordering),
286 'tag_columns': tag_columns,
287 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '',
288 'tagged_item': tagged_item_table,
289 'content_type_id': ContentType.objects.get_for_model(model).pk,
290 'tag_id_placeholders': ','.join(['%s'] * tag_count),
291 'extra_where': extra_where,
292 'tag_count': tag_count,
293 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '',
296 params = [tag.pk for tag in tags] * 2
297 if min_count is not None:
298 params.append(min_count)
300 cursor = connection.cursor()
301 cursor.execute(query, params)
303 for row in cursor.fetchall():
304 tag = self.model(*row[:len(self.model._meta.fields)])
306 tag.count = row[len(self.model._meta.fields)]
310 def _get_tag_columns(self):
311 tag_table = qn(self.model._meta.db_table)
312 return ', '.join('%s.%s' % (tag_table, qn(field.column)) for field in self.model._meta.fields)
315 class TaggedItemManager(models.Manager):
317 FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING``
318 SQL clauses required by many of this manager's methods into
321 For now, we manually execute a query to retrieve the PKs of
322 objects we're interested in, then use the ORM's ``__in``
323 lookup to return a ``QuerySet``.
325 Once the queryset-refactor branch lands in trunk, this can be
326 tidied up significantly.
328 def __init__(self, tag_model):
329 super(TaggedItemManager, self).__init__()
330 self.tag_model = tag_model
332 def get_by_model(self, queryset_or_model, tags):
334 Create a ``QuerySet`` containing instances of the specified
335 model associated with a given tag or list of tags.
337 tags = self.tag_model.get_tag_list(tags)
338 tag_count = len(tags)
340 # No existing tags were given
341 queryset, model = get_queryset_and_model(queryset_or_model)
342 return model._default_manager.none()
344 # Optimisation for single tag - fall through to the simpler
348 return self.get_intersection_by_model(queryset_or_model, tags)
350 queryset, model = get_queryset_and_model(queryset_or_model)
351 content_type = ContentType.objects.get_for_model(model)
352 opts = self.model._meta
353 tagged_item_table = qn(opts.db_table)
354 return queryset.extra(
355 tables=[opts.db_table],
357 '%s.content_type_id = %%s' % tagged_item_table,
358 '%s.tag_id = %%s' % tagged_item_table,
359 '%s.%s = %s.object_id' % (qn(model._meta.db_table),
360 qn(model._meta.pk.column),
363 params=[content_type.pk, tag.pk],
366 def get_intersection_by_model(self, queryset_or_model, tags):
368 Create a ``QuerySet`` containing instances of the specified
369 model associated with *all* of the given list of tags.
371 tags = self.tag_model.get_tag_list(tags)
372 tag_count = len(tags)
373 queryset, model = get_queryset_and_model(queryset_or_model)
376 return model._default_manager.none()
378 model_table = qn(model._meta.db_table)
379 # This query selects the ids of all objects which have all the
383 FROM %(model)s, %(tagged_item)s
384 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
385 AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
386 AND %(model_pk)s = %(tagged_item)s.object_id
387 GROUP BY %(model_pk)s
388 HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % {
389 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
390 'model': model_table,
391 'tagged_item': qn(self.model._meta.db_table),
392 'content_type_id': ContentType.objects.get_for_model(model).pk,
393 'tag_id_placeholders': ','.join(['%s'] * tag_count),
394 'tag_count': tag_count,
397 cursor = connection.cursor()
398 cursor.execute(query, [tag.pk for tag in tags])
399 object_ids = [row[0] for row in cursor.fetchall()]
400 if len(object_ids) > 0:
401 return queryset.filter(pk__in=object_ids)
403 return model._default_manager.none()
405 def get_union_by_model(self, queryset_or_model, tags):
407 Create a ``QuerySet`` containing instances of the specified
408 model associated with *any* of the given list of tags.
410 tags = self.tag_model.get_tag_list(tags)
411 tag_count = len(tags)
412 queryset, model = get_queryset_and_model(queryset_or_model)
415 return model._default_manager.none()
417 model_table = qn(model._meta.db_table)
418 # This query selects the ids of all objects which have any of
422 FROM %(model)s, %(tagged_item)s
423 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
424 AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
425 AND %(model_pk)s = %(tagged_item)s.object_id
426 GROUP BY %(model_pk)s""" % {
427 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
428 'model': model_table,
429 'tagged_item': qn(self.model._meta.db_table),
430 'content_type_id': ContentType.objects.get_for_model(model).pk,
431 'tag_id_placeholders': ','.join(['%s'] * tag_count),
434 cursor = connection.cursor()
435 cursor.execute(query, [tag.pk for tag in tags])
436 object_ids = [row[0] for row in cursor.fetchall()]
437 if len(object_ids) > 0:
438 return queryset.filter(pk__in=object_ids)
440 return model._default_manager.none()
442 def get_related(self, obj, queryset_or_model, num=None):
444 Retrieve a list of instances of the specified model which share
445 tags with the model instance ``obj``, ordered by the number of
446 shared tags in descending order.
448 If ``num`` is given, a maximum of ``num`` instances will be
451 queryset, model = get_queryset_and_model(queryset_or_model)
452 model_table = qn(model._meta.db_table)
453 content_type = ContentType.objects.get_for_model(obj)
454 related_content_type = ContentType.objects.get_for_model(model)
456 SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s
457 FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item
458 WHERE %(tagged_item)s.object_id = %%s
459 AND %(tagged_item)s.content_type_id = %(content_type_id)s
460 AND %(tag)s.id = %(tagged_item)s.tag_id
461 AND related_tagged_item.content_type_id = %(related_content_type_id)s
462 AND related_tagged_item.tag_id = %(tagged_item)s.tag_id
463 AND %(model_pk)s = related_tagged_item.object_id"""
464 if content_type.pk == related_content_type.pk:
465 # Exclude the given instance itself if determining related
466 # instances for the same model.
468 AND related_tagged_item.object_id != %(tagged_item)s.object_id"""
470 GROUP BY %(model_pk)s
471 ORDER BY %(count)s DESC
474 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
475 'count': qn('count'),
476 'model': model_table,
477 'tagged_item': qn(self.model._meta.db_table),
478 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table),
479 'content_type_id': content_type.pk,
480 'related_content_type_id': related_content_type.pk,
481 'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '',
484 cursor = connection.cursor()
485 cursor.execute(query, [obj.pk])
486 object_ids = [row[0] for row in cursor.fetchall()]
487 if len(object_ids) > 0:
488 # Use in_bulk here instead of an id__in lookup, because id__in would
489 # clobber the ordering.
490 object_dict = queryset.in_bulk(object_ids)
491 return [object_dict[object_id] for object_id in object_ids \
492 if object_id in object_dict]
500 def create_intermediary_table_model(model):
501 """Create an intermediary table model for the specific tag model"""
502 name = model.__name__ + 'Relation'
505 db_table = '%s_relation' % model._meta.db_table
506 unique_together = (('tag', 'content_type', 'object_id'),)
508 def obj_unicode(self):
510 return u'%s [%s]' % (self.content_type.get_object_for_this_type(pk=self.object_id), self.tag)
511 except ObjectDoesNotExist:
512 return u'<deleted> [%s]' % self.tag
514 # Set up a dictionary to simulate declarations within a class
516 '__module__': model.__module__,
518 'tag': models.ForeignKey(model, verbose_name=_('tag'), related_name='items'),
519 'content_type': models.ForeignKey(ContentType, verbose_name=_('content type')),
520 'object_id': models.PositiveIntegerField(_('object id'), db_index=True),
521 'content_object': generic.GenericForeignKey('content_type', 'object_id'),
522 '__unicode__': obj_unicode,
525 return type(name, (models.Model,), attrs)
528 class TagMeta(ModelBase):
529 "Metaclass for tag models (models inheriting from TagBase)."
530 def __new__(cls, name, bases, attrs):
531 model = super(TagMeta, cls).__new__(cls, name, bases, attrs)
532 if not model._meta.abstract:
533 # Create an intermediary table and register custom managers for concrete models
534 model.intermediary_table_model = create_intermediary_table_model(model)
535 TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
536 TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
540 class TagBase(models.Model):
541 """Abstract class to be inherited by model classes."""
542 __metaclass__ = TagMeta
548 def get_tag_list(tag_list):
550 Utility function for accepting tag input in a flexible manner.
552 You should probably override this method in your subclass.
554 if isinstance(tag_list, TagBase):