e1e92b67e8db807ee7a776cdb329f0790bbb7074
[wolnelektury.git] / apps / newtagging / models.py
1 """
2 Models and managers for generic tagging.
3 """
4
5 # Python 2.3 compatibility
6 try:
7     set
8 except NameError: 
9     from sets import Set as set
10
11 from django.contrib.contenttypes import generic
12 from django.contrib.contenttypes.models import ContentType
13 from django.db import connection, models
14 from django.utils.translation import ugettext_lazy as _
15 from django.db.models.base import ModelBase
16 from django.core.exceptions import ObjectDoesNotExist
17
18 qn = connection.ops.quote_name
19
20 try:
21     from django.db.models.query import parse_lookup
22 except ImportError:
23     parse_lookup = None
24
25
26 def get_queryset_and_model(queryset_or_model):
27     """
28     Given a ``QuerySet`` or a ``Model``, returns a two-tuple of
29     (queryset, model).
30
31     If a ``Model`` is given, the ``QuerySet`` returned will be created
32     using its default manager.
33     """
34     try:
35         return queryset_or_model, queryset_or_model.model
36     except AttributeError:
37         return queryset_or_model._default_manager.all(), queryset_or_model
38
39
40 ############
41 # Managers #
42 ############
43 class TagManager(models.Manager):
44     def __init__(self, intermediary_table_model):
45         super(TagManager, self).__init__()
46         self.intermediary_table_model = intermediary_table_model
47     
48     def update_tags(self, obj, tags):
49         """
50         Update tags associated with an object.
51         """
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)
56     
57         # Remove tags which no longer apply
58         tags_for_removal = [tag for tag in current_tags \
59                             if tag not in updated_tags]
60         if len(tags_for_removal):
61             self.intermediary_table_model._default_manager.filter(content_type__pk=content_type.pk,
62                                                object_id=obj.pk,
63                                                tag__in=tags_for_removal).delete()
64         # Add new tags
65         for tag in updated_tags:
66             if tag not in current_tags:
67                 self.intermediary_table_model._default_manager.create(tag=tag, content_object=obj)
68     
69     def remove_tag(self, obj, tag):
70         """
71         Remove tag from an object.
72         """
73         content_type = ContentType.objects.get_for_model(obj)
74         self.intermediary_table_model._default_manager.filter(content_type__pk=content_type.pk,
75             object_id=obj.pk, tag=tag).delete()
76
77     def get_for_object(self, obj):
78         """
79         Create a queryset matching all tags associated with the given
80         object.
81         """
82         ctype = ContentType.objects.get_for_model(obj)
83         return self.filter(items__content_type__pk=ctype.pk,
84                            items__object_id=obj.pk)
85     
86     def _get_usage(self, model, counts=False, min_count=None, extra_joins=None, extra_criteria=None, params=None, extra=None):
87         """
88         Perform the custom SQL query for ``usage_for_model`` and
89         ``usage_for_queryset``.
90         """
91         if min_count is not None: counts = True
92
93         model_table = qn(model._meta.db_table)
94         model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column))
95         tag_columns = self._get_tag_columns()
96         
97         if extra is None: extra = {}
98         extra_where = ''
99         if 'where' in extra:
100             extra_where = 'AND ' + ' AND '.join(extra['where'])
101         
102         query = """
103         SELECT DISTINCT %(tag_columns)s%(count_sql)s
104         FROM
105             %(tag)s
106             INNER JOIN %(tagged_item)s
107                 ON %(tag)s.id = %(tagged_item)s.tag_id
108             INNER JOIN %(model)s
109                 ON %(tagged_item)s.object_id = %(model_pk)s
110             %%s
111         WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
112             %%s
113             %(extra_where)s
114         GROUP BY %(tag)s.id, %(tag)s.name
115         %%s
116         ORDER BY %(tag)s.%(ordering)s ASC""" % {
117             'tag': qn(self.model._meta.db_table),
118             'ordering': ', '.join(qn(field) for field in self.model._meta.ordering),
119             'tag_columns': tag_columns,
120             'count_sql': counts and (', COUNT(%s)' % model_pk) or '',
121             'tagged_item': qn(self.intermediary_table_model._meta.db_table),
122             'model': model_table,
123             'model_pk': model_pk,
124             'extra_where': extra_where,
125             'content_type_id': ContentType.objects.get_for_model(model).pk,
126         }
127
128         min_count_sql = ''
129         if min_count is not None:
130             min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk
131             params.append(min_count)
132
133         cursor = connection.cursor()
134         cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params)
135         tags = []
136         for row in cursor.fetchall():
137             t = self.model(*row[:len(self.model._meta.fields)])
138             if counts:
139                 t.count = row[len(self.model._meta.fields)]
140             tags.append(t)
141         return tags
142
143     def usage_for_model(self, model, counts=False, min_count=None, filters=None, extra=None):
144         """
145         Obtain a list of tags associated with instances of the given
146         Model class.
147
148         If ``counts`` is True, a ``count`` attribute will be added to
149         each tag, indicating how many times it has been used against
150         the Model class in question.
151
152         If ``min_count`` is given, only tags which have a ``count``
153         greater than or equal to ``min_count`` will be returned.
154         Passing a value for ``min_count`` implies ``counts=True``.
155
156         To limit the tags (and counts, if specified) returned to those
157         used by a subset of the Model's instances, pass a dictionary
158         of field lookups to be applied to the given Model as the
159         ``filters`` argument.
160         """
161         if extra is None: extra = {}
162         if filters is None: filters = {}
163
164         if not parse_lookup:
165             # post-queryset-refactor (hand off to usage_for_queryset)
166             queryset = model._default_manager.filter()
167             for f in filters.items():
168                 queryset.query.add_filter(f)
169             usage = self.usage_for_queryset(queryset, counts, min_count, extra)
170         else:
171             # pre-queryset-refactor
172             extra_joins = ''
173             extra_criteria = ''
174             params = []
175             if len(filters) > 0:
176                 joins, where, params = parse_lookup(filters.items(), model._meta)
177                 extra_joins = ' '.join(['%s %s AS %s ON %s' % (join_type, table, alias, condition)
178                                         for (alias, (table, join_type, condition)) in joins.items()])
179                 extra_criteria = 'AND %s' % (' AND '.join(where))
180             usage = self._get_usage(model, counts, min_count, extra_joins, extra_criteria, params, extra)
181
182         return usage
183
184     def usage_for_queryset(self, queryset, counts=False, min_count=None, extra=None):
185         """
186         Obtain a list of tags associated with instances of a model
187         contained in the given queryset.
188
189         If ``counts`` is True, a ``count`` attribute will be added to
190         each tag, indicating how many times it has been used against
191         the Model class in question.
192
193         If ``min_count`` is given, only tags which have a ``count``
194         greater than or equal to ``min_count`` will be returned.
195         Passing a value for ``min_count`` implies ``counts=True``.
196         """
197         if parse_lookup:
198             raise AttributeError("'TagManager.usage_for_queryset' is not compatible with pre-queryset-refactor versions of Django.")
199
200         extra_joins = ' '.join(queryset.query.get_from_clause()[0][1:])
201         where, params = queryset.query.where.as_sql()
202         if where:
203             extra_criteria = 'AND %s' % where
204         else:
205             extra_criteria = ''
206         return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params, extra)
207
208     def related_for_model(self, tags, model, counts=False, min_count=None, extra=None):
209         """
210         Obtain a list of tags related to a given list of tags - that
211         is, other tags used by items which have all the given tags.
212
213         If ``counts`` is True, a ``count`` attribute will be added to
214         each tag, indicating the number of items which have it in
215         addition to the given list of tags.
216
217         If ``min_count`` is given, only tags which have a ``count``
218         greater than or equal to ``min_count`` will be returned.
219         Passing a value for ``min_count`` implies ``counts=True``.
220         """
221         if min_count is not None: counts = True
222         tags = self.model.get_tag_list(tags)
223         tag_count = len(tags)
224         tagged_item_table = qn(self.intermediary_table_model._meta.db_table)
225         tag_columns = self._get_tag_columns()
226         
227         if extra is None: extra = {}
228         extra_where = ''
229         if 'where' in extra:
230             extra_where = 'AND ' + ' AND '.join(extra['where'])
231         
232         # Temporary table in this query is a hack to prevent MySQL from executing
233         # inner query as dependant query (which could result in severe performance loss)
234         query = """
235         SELECT %(tag_columns)s%(count_sql)s
236         FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id
237         WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
238         AND %(tagged_item)s.object_id IN
239         (
240             SELECT *
241             FROM (
242                 SELECT %(tagged_item)s.object_id
243                 FROM %(tagged_item)s, %(tag)s
244                 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
245                   AND %(tag)s.id = %(tagged_item)s.tag_id
246                   AND %(tag)s.id IN (%(tag_id_placeholders)s)
247                 GROUP BY %(tagged_item)s.object_id
248                 HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s
249             ) AS temporary
250         )
251         AND %(tag)s.id NOT IN (%(tag_id_placeholders)s)
252         %(extra_where)s
253         GROUP BY %(tag_columns)s
254         %(min_count_sql)s
255         ORDER BY %(tag)s.%(ordering)s ASC""" % {
256             'tag': qn(self.model._meta.db_table),
257             'ordering': ', '.join(qn(field) for field in self.model._meta.ordering),
258             'tag_columns': tag_columns,
259             'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '',
260             'tagged_item': tagged_item_table,
261             'content_type_id': ContentType.objects.get_for_model(model).pk,
262             'tag_id_placeholders': ','.join(['%s'] * tag_count),
263             'extra_where': extra_where,
264             'tag_count': tag_count,
265             'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '',
266         }
267
268         params = [tag.pk for tag in tags] * 2
269         if min_count is not None:
270             params.append(min_count)
271
272         cursor = connection.cursor()
273         cursor.execute(query, params)
274         related = []
275         for row in cursor.fetchall():
276             tag = self.model(*row[:len(self.model._meta.fields)])
277             if counts is True:
278                 tag.count = row[len(self.model._meta.fields)]
279             related.append(tag)
280         return related
281
282     def _get_tag_columns(self):
283         tag_table = qn(self.model._meta.db_table)
284         return ', '.join('%s.%s' % (tag_table, qn(field.column)) for field in self.model._meta.fields)
285
286
287 class TaggedItemManager(models.Manager):
288     """
289     FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING``
290           SQL clauses required by many of this manager's methods into
291           Django's ORM.
292
293           For now, we manually execute a query to retrieve the PKs of
294           objects we're interested in, then use the ORM's ``__in``
295           lookup to return a ``QuerySet``.
296
297           Once the queryset-refactor branch lands in trunk, this can be
298           tidied up significantly.
299     """
300     def __init__(self, tag_model):
301         super(TaggedItemManager, self).__init__()
302         self.tag_model = tag_model
303     
304     def get_by_model(self, queryset_or_model, tags):
305         """
306         Create a ``QuerySet`` containing instances of the specified
307         model associated with a given tag or list of tags.
308         """
309         tags = self.tag_model.get_tag_list(tags)
310         tag_count = len(tags)
311         if tag_count == 0:
312             # No existing tags were given
313             queryset, model = get_queryset_and_model(queryset_or_model)
314             return model._default_manager.none()
315         elif tag_count == 1:
316             # Optimisation for single tag - fall through to the simpler
317             # query below.
318             tag = tags[0]
319         else:
320             return self.get_intersection_by_model(queryset_or_model, tags)
321
322         queryset, model = get_queryset_and_model(queryset_or_model)
323         content_type = ContentType.objects.get_for_model(model)
324         opts = self.model._meta
325         tagged_item_table = qn(opts.db_table)
326         return queryset.extra(
327             tables=[opts.db_table],
328             where=[
329                 '%s.content_type_id = %%s' % tagged_item_table,
330                 '%s.tag_id = %%s' % tagged_item_table,
331                 '%s.%s = %s.object_id' % (qn(model._meta.db_table),
332                                           qn(model._meta.pk.column),
333                                           tagged_item_table)
334             ],
335             params=[content_type.pk, tag.pk],
336         )
337
338     def get_intersection_by_model(self, queryset_or_model, tags):
339         """
340         Create a ``QuerySet`` containing instances of the specified
341         model associated with *all* of the given list of tags.
342         """
343         tags = self.tag_model.get_tag_list(tags)
344         tag_count = len(tags)
345         queryset, model = get_queryset_and_model(queryset_or_model)
346
347         if not tag_count:
348             return model._default_manager.none()
349
350         model_table = qn(model._meta.db_table)
351         # This query selects the ids of all objects which have all the
352         # given tags.
353         query = """
354         SELECT %(model_pk)s
355         FROM %(model)s, %(tagged_item)s
356         WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
357           AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
358           AND %(model_pk)s = %(tagged_item)s.object_id
359         GROUP BY %(model_pk)s
360         HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % {
361             'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
362             'model': model_table,
363             'tagged_item': qn(self.model._meta.db_table),
364             'content_type_id': ContentType.objects.get_for_model(model).pk,
365             'tag_id_placeholders': ','.join(['%s'] * tag_count),
366             'tag_count': tag_count,
367         }
368
369         cursor = connection.cursor()
370         cursor.execute(query, [tag.pk for tag in tags])
371         object_ids = [row[0] for row in cursor.fetchall()]
372         if len(object_ids) > 0:
373             return queryset.filter(pk__in=object_ids)
374         else:
375             return model._default_manager.none()
376
377     def get_union_by_model(self, queryset_or_model, tags):
378         """
379         Create a ``QuerySet`` containing instances of the specified
380         model associated with *any* of the given list of tags.
381         """
382         tags = self.tag_model.get_tag_list(tags)
383         tag_count = len(tags)
384         queryset, model = get_queryset_and_model(queryset_or_model)
385
386         if not tag_count:
387             return model._default_manager.none()
388
389         model_table = qn(model._meta.db_table)
390         # This query selects the ids of all objects which have any of
391         # the given tags.
392         query = """
393         SELECT %(model_pk)s
394         FROM %(model)s, %(tagged_item)s
395         WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
396           AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
397           AND %(model_pk)s = %(tagged_item)s.object_id
398         GROUP BY %(model_pk)s""" % {
399             'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
400             'model': model_table,
401             'tagged_item': qn(self.model._meta.db_table),
402             'content_type_id': ContentType.objects.get_for_model(model).pk,
403             'tag_id_placeholders': ','.join(['%s'] * tag_count),
404         }
405
406         cursor = connection.cursor()
407         cursor.execute(query, [tag.pk for tag in tags])
408         object_ids = [row[0] for row in cursor.fetchall()]
409         if len(object_ids) > 0:
410             return queryset.filter(pk__in=object_ids)
411         else:
412             return model._default_manager.none()
413
414     def get_related(self, obj, queryset_or_model, num=None):
415         """
416         Retrieve a list of instances of the specified model which share
417         tags with the model instance ``obj``, ordered by the number of
418         shared tags in descending order.
419
420         If ``num`` is given, a maximum of ``num`` instances will be
421         returned.
422         """
423         queryset, model = get_queryset_and_model(queryset_or_model)
424         model_table = qn(model._meta.db_table)
425         content_type = ContentType.objects.get_for_model(obj)
426         related_content_type = ContentType.objects.get_for_model(model)
427         query = """
428         SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s
429         FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item
430         WHERE %(tagged_item)s.object_id = %%s
431           AND %(tagged_item)s.content_type_id = %(content_type_id)s
432           AND %(tag)s.id = %(tagged_item)s.tag_id
433           AND related_tagged_item.content_type_id = %(related_content_type_id)s
434           AND related_tagged_item.tag_id = %(tagged_item)s.tag_id
435           AND %(model_pk)s = related_tagged_item.object_id"""
436         if content_type.pk == related_content_type.pk:
437             # Exclude the given instance itself if determining related
438             # instances for the same model.
439             query += """
440           AND related_tagged_item.object_id != %(tagged_item)s.object_id"""
441         query += """
442         GROUP BY %(model_pk)s
443         ORDER BY %(count)s DESC
444         %(limit_offset)s"""
445         query = query % {
446             'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
447             'count': qn('count'),
448             'model': model_table,
449             'tagged_item': qn(self.model._meta.db_table),
450             'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table),
451             'content_type_id': content_type.pk,
452             'related_content_type_id': related_content_type.pk,
453             'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '',
454         }
455
456         cursor = connection.cursor()
457         cursor.execute(query, [obj.pk])
458         object_ids = [row[0] for row in cursor.fetchall()]
459         if len(object_ids) > 0:
460             # Use in_bulk here instead of an id__in lookup, because id__in would
461             # clobber the ordering.
462             object_dict = queryset.in_bulk(object_ids)
463             return [object_dict[object_id] for object_id in object_ids \
464                     if object_id in object_dict]
465         else:
466             return []
467
468
469 ##########
470 # Models #
471 ##########
472 def create_intermediary_table_model(model):
473     """Create an intermediary table model for the specific tag model"""
474     name = model.__name__ + 'Relation'
475      
476     class Meta:
477         db_table = '%s_relation' % model._meta.db_table
478         unique_together = (('tag', 'content_type', 'object_id'),)
479
480     def obj_unicode(self):
481         try:
482             return u'%s [%s]' % (self.content_type.get_object_for_this_type(pk=self.object_id), self.tag)
483         except ObjectDoesNotExist:
484             return u'<deleted> [%s]' % self.tag
485             
486     # Set up a dictionary to simulate declarations within a class    
487     attrs = {
488         '__module__': model.__module__,
489         'Meta': Meta,
490         'tag': models.ForeignKey(model, verbose_name=_('tag'), related_name='items'),
491         'content_type': models.ForeignKey(ContentType, verbose_name=_('content type')),
492         'object_id': models.PositiveIntegerField(_('object id'), db_index=True),
493         'content_object': generic.GenericForeignKey('content_type', 'object_id'),
494         '__unicode__': obj_unicode,
495     }
496
497     return type(name, (models.Model,), attrs)
498
499
500 class TagMeta(ModelBase):
501     "Metaclass for tag models (models inheriting from TagBase)."
502     def __new__(cls, name, bases, attrs):
503         model = super(TagMeta, cls).__new__(cls, name, bases, attrs)
504         if not model._meta.abstract:
505             # Create an intermediary table and register custom managers for concrete models
506             model.intermediary_table_model = create_intermediary_table_model(model)
507             TagManager(model.intermediary_table_model).contribute_to_class(model, 'objects')
508             TaggedItemManager(model).contribute_to_class(model.intermediary_table_model, 'objects')
509         return model
510
511
512 class TagBase(models.Model):
513     """Abstract class to be inherited by model classes."""
514     __metaclass__ = TagMeta
515     
516     class Meta:
517         abstract = True
518     
519     @staticmethod
520     def get_tag_list(tag_list):
521         """
522         Utility function for accepting tag input in a flexible manner.
523         
524         You should probably override this method in your subclass.
525         """
526         if isinstance(tag_list, TagBase):
527             return [tag_list]
528         else:
529             return tag_list
530