~carljm/django-tagging/tagfield-signal-updates

« back to all changes in this revision

Viewing changes to tagging/managers.py

  • Committer: jonathan.buchanan
  • Date: 2008-01-14 20:12:54 UTC
  • Revision ID: vcs-imports@canonical.com-20080114201254-7vj1cbnicw31f6fc
Moved tagging managers to models.py; added ability to register models with the tagging module

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
1
"""
2
 
Custom managers for generic tagging models.
 
2
Custom managers for Django models registered with the tagging
 
3
application.
3
4
"""
4
 
from django.db import connection
5
 
from django.db.models import Manager
6
 
from django.db.models.query import QuerySet, parse_lookup
7
 
from django.contrib.contenttypes.models import ContentType
8
 
from django.utils.translation import ugettext as _
9
 
 
10
 
from tagging import settings
11
 
from tagging.utils import calculate_cloud, get_tag_list, parse_tag_input
12
 
from tagging.utils import LOGARITHMIC
13
 
 
14
 
# Python 2.3 compatibility
15
 
if not hasattr(__builtins__, 'set'):
16
 
    from sets import Set as set
17
 
 
18
 
qn = connection.ops.quote_name
19
 
 
20
 
class TagManager(Manager):
21
 
    def update_tags(self, obj, tag_names):
22
 
        """
23
 
        Update tags associated with an object.
24
 
        """
25
 
        ctype = ContentType.objects.get_for_model(obj)
26
 
        current_tags = list(self.filter(items__content_type__pk=ctype.pk,
27
 
                                        items__object_id=obj.pk))
28
 
        updated_tag_names = parse_tag_input(tag_names)
29
 
        if settings.FORCE_LOWERCASE_TAGS:
30
 
            updated_tag_names = [t.lower() for t in updated_tag_names]
31
 
 
32
 
        TaggedItemModel = self._get_related_model_by_accessor('items')
33
 
 
34
 
        # Remove tags which no longer apply
35
 
        tags_for_removal = [tag for tag in current_tags \
36
 
                            if tag.name not in updated_tag_names]
37
 
        if len(tags_for_removal):
38
 
            TaggedItemModel._default_manager.filter(content_type__pk=ctype.pk,
39
 
                                                    object_id=obj.pk,
40
 
                                                    tag__in=tags_for_removal).delete()
41
 
 
42
 
        # Add new tags
43
 
        current_tag_names = [tag.name for tag in current_tags]
44
 
        for tag_name in updated_tag_names:
45
 
            if tag_name not in current_tag_names:
46
 
                tag, created = self.get_or_create(name=tag_name)
47
 
                TaggedItemModel._default_manager.create(tag=tag, object=obj)
48
 
 
49
 
    def add_tag(self, obj, tag_name):
50
 
        """
51
 
        Associates the given object with a tag.
52
 
        """
53
 
        tag_names = parse_tag_input(tag_name)
54
 
        if not len(tag_names):
55
 
            raise AttributeError(_('No tags were given: "%s".') % tag_name)
56
 
        if len(tag_names) > 1:
57
 
            raise AttributeError(_('Multiple tags were given: "%s".') % tag_name)
58
 
        tag_name = tag_names[0]
59
 
        if settings.FORCE_LOWERCASE_TAGS:
60
 
            tag_name = tag_name.lower()
61
 
        tag, created = self.get_or_create(name=tag_name)
62
 
        ctype = ContentType.objects.get_for_model(obj)
63
 
        TaggedItemModel = self._get_related_model_by_accessor('items')
64
 
        TaggedItemModel._default_manager.get_or_create(
65
 
            tag=tag, content_type=ctype, object_id=obj.pk)
66
 
 
67
 
    def get_for_object(self, obj):
68
 
        """
69
 
        Create a queryset matching all tags associated with the given
70
 
        object.
71
 
        """
72
 
        ctype = ContentType.objects.get_for_model(obj)
73
 
        return self.filter(items__content_type__pk=ctype.pk,
74
 
                           items__object_id=obj.pk)
75
 
 
76
 
    def usage_for_model(self, model, counts=False, min_count=None, filters=None):
77
 
        """
78
 
        Obtain a list of tags associated with instances of the given
79
 
        Model class.
80
 
 
81
 
        If ``counts`` is True, a ``count`` attribute will be added to
82
 
        each tag, indicating how many times it has been used against
83
 
        the Model class in question.
84
 
 
85
 
        If ``min_count`` is given, only tags which have a ``count``
86
 
        greater than or equal to ``min_count`` will be returned.
87
 
        Passing a value for ``min_count`` implies ``counts=True``.
88
 
 
89
 
        To limit the tags (and counts, if specified) returned to those
90
 
        used by a subset of the Model's instances, pass a dictionary
91
 
        of field lookups to be applied to the given Model as the
92
 
        ``filters`` argument.
93
 
        """
94
 
        if filters is None: filters = {}
95
 
        if min_count is not None: counts = True
96
 
 
97
 
        model_table = qn(model._meta.db_table)
98
 
        model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column))
99
 
        query = """
100
 
        SELECT DISTINCT %(tag)s.id, %(tag)s.name%(count_sql)s
101
 
        FROM
102
 
            %(tag)s
103
 
            INNER JOIN %(tagged_item)s
104
 
                ON %(tag)s.id = %(tagged_item)s.tag_id
105
 
            INNER JOIN %(model)s
106
 
                ON %(tagged_item)s.object_id = %(model_pk)s
107
 
            %%s
108
 
        WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
109
 
            %%s
110
 
        GROUP BY %(tag)s.id, %(tag)s.name
111
 
        %%s
112
 
        ORDER BY %(tag)s.name ASC""" % {
113
 
            'tag': qn(self.model._meta.db_table),
114
 
            'count_sql': counts and (', COUNT(%s)' % model_pk) or '',
115
 
            'tagged_item': qn(self._get_related_model_by_accessor('items')._meta.db_table),
116
 
            'model': model_table,
117
 
            'model_pk': model_pk,
118
 
            'content_type_id': ContentType.objects.get_for_model(model).pk,
119
 
        }
120
 
 
121
 
        extra_joins = ''
122
 
        extra_criteria = ''
123
 
        min_count_sql = ''
124
 
        params = []
125
 
        if len(filters) > 0:
126
 
            joins, where, params = parse_lookup(filters.items(), model._meta)
127
 
            extra_joins = ' '.join(['%s %s AS %s ON %s' % (join_type, table, alias, condition)
128
 
                                    for (alias, (table, join_type, condition)) in joins.items()])
129
 
            extra_criteria = 'AND %s' % (' AND '.join(where))
130
 
        if min_count is not None:
131
 
            min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk
132
 
            params.append(min_count)
133
 
 
134
 
        cursor = connection.cursor()
135
 
        cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params)
136
 
        tags = []
137
 
        for row in cursor.fetchall():
138
 
            t = self.model(*row[:2])
139
 
            if counts:
140
 
                t.count = row[2]
141
 
            tags.append(t)
142
 
        return tags
143
 
 
144
 
    def related_for_model(self, tags, model, counts=False, min_count=None):
145
 
        """
146
 
        Obtain a list of tags related to a given list of tags - that
147
 
        is, other tags used by items which have all the given tags.
148
 
 
149
 
        If ``counts`` is True, a ``count`` attribute will be added to
150
 
        each tag, indicating the number of items which have it in
151
 
        addition to the given list of tags.
152
 
 
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``.
156
 
        """
157
 
        if min_count is not None: counts = True
158
 
        tags = get_tag_list(tags)
159
 
        tag_count = len(tags)
160
 
        tagged_item_table = qn(self._get_related_model_by_accessor('items')._meta.db_table)
161
 
        query = """
162
 
        SELECT %(tag)s.id, %(tag)s.name%(count_sql)s
163
 
        FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id
164
 
        WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
165
 
          AND %(tagged_item)s.object_id IN
166
 
          (
167
 
              SELECT %(tagged_item)s.object_id
168
 
              FROM %(tagged_item)s, %(tag)s
169
 
              WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
170
 
                AND %(tag)s.id = %(tagged_item)s.tag_id
171
 
                AND %(tag)s.id IN (%(tag_id_placeholders)s)
172
 
              GROUP BY %(tagged_item)s.object_id
173
 
              HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s
174
 
          )
175
 
          AND %(tag)s.id NOT IN (%(tag_id_placeholders)s)
176
 
        GROUP BY %(tag)s.id, %(tag)s.name
177
 
        %(min_count_sql)s
178
 
        ORDER BY %(tag)s.name ASC""" % {
179
 
            'tag': qn(self.model._meta.db_table),
180
 
            'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '',
181
 
            'tagged_item': tagged_item_table,
182
 
            'content_type_id': ContentType.objects.get_for_model(model).pk,
183
 
            'tag_id_placeholders': ','.join(['%s'] * tag_count),
184
 
            'tag_count': tag_count,
185
 
            'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '',
186
 
        }
187
 
 
188
 
        params = [tag.pk for tag in tags] * 2
189
 
        if min_count is not None:
190
 
            params.append(min_count)
191
 
 
192
 
        cursor = connection.cursor()
193
 
        cursor.execute(query, params)
194
 
        related = []
195
 
        for row in cursor.fetchall():
196
 
            tag = self.model(*row[:2])
197
 
            if counts is True:
198
 
                tag.count = row[2]
199
 
            related.append(tag)
200
 
        return related
201
 
 
202
 
    def cloud_for_model(self, model, steps=4, distribution=LOGARITHMIC,
203
 
                        filters=None, min_count=None):
204
 
        """
205
 
        Obtain a list of tags associated with instances of the given
206
 
        Model, giving each tag a ``count`` attribute indicating how
207
 
        many times it has been used and a ``font_size`` attribute for
208
 
        use in displaying a tag cloud.
209
 
 
210
 
        ``steps`` defines the range of font sizes - ``font_size`` will
211
 
        be an integer between 1 and ``steps`` (inclusive).
212
 
 
213
 
        ``distribution`` defines the type of font size distribution
214
 
        algorithm which will be used - logarithmic or linear. It must
215
 
        be either ``tagging.utils.LOGARITHMIC`` or
216
 
        ``tagging.utils.LINEAR``.
217
 
 
218
 
        To limit the tags displayed in the cloud to those associated
219
 
        with a subset of the Model's instances, pass a dictionary of
220
 
        field lookups to be applied to the given Model as the
221
 
        ``filters`` argument.
222
 
 
223
 
        To limit the tags displayed in the cloud to those with a
224
 
        ``count`` greater than or equal to ``min_count``, pass a value
225
 
        for the ``min_count`` argument.
226
 
        """
227
 
        tags = list(self.usage_for_model(model, counts=True, filters=filters,
228
 
                                         min_count=min_count))
229
 
        return calculate_cloud(tags, steps, distribution)
230
 
 
231
 
    def _get_related_model_by_accessor(self, accessor):
232
 
        """
233
 
        Returns the model for the related object accessed by the
234
 
        given attribute name.
235
 
 
236
 
        Since we sometimes need to access the ``TaggedItem`` model
237
 
        when managing tagging and the``Tag`` model does not have a
238
 
        field representing this relationship, this method is used to
239
 
        retrieve the ``TaggedItem`` model, avoiding circular imports
240
 
        betweeen the ``models`` and ``managers`` modules.
241
 
        """
242
 
        for rel_obj in self.model._meta.get_all_related_objects():
243
 
            if rel_obj.get_accessor_name() == accessor:
244
 
                return rel_obj.model
245
 
        raise ValueError(_('Could not find a related object with the accessor "%s".') % accessor)
246
 
 
247
 
class TaggedItemManager(Manager):
248
 
    def get_by_model(self, model, tags):
249
 
        """
250
 
        Create a queryset matching instances of the given Model
251
 
        associated with a given Tag or list of Tags.
252
 
        """
253
 
        tags = get_tag_list(tags)
254
 
        tag_count = len(tags)
255
 
        if tag_count == 0:
256
 
            # No existing tags were given
257
 
            return model._default_manager.none()
258
 
        elif tag_count == 1:
259
 
            # Optimisation for single tag - fall through to the simpler
260
 
            # query below.
261
 
            tag = tags[0]
262
 
        else:
263
 
            return self.get_intersection_by_model(model, tags)
264
 
 
265
 
        ctype = ContentType.objects.get_for_model(model)
266
 
        opts = self.model._meta
267
 
        tagged_item_table = qn(opts.db_table)
268
 
        return model._default_manager.extra(
269
 
            tables=[opts.db_table],
270
 
            where=[
271
 
                '%s.content_type_id = %%s' % tagged_item_table,
272
 
                '%s.tag_id = %%s' % tagged_item_table,
273
 
                '%s.%s = %s.object_id' % (qn(model._meta.db_table),
274
 
                                          qn(model._meta.pk.column),
275
 
                                          tagged_item_table)
276
 
            ],
277
 
            params=[ctype.pk, tag.pk],
278
 
        )
279
 
 
280
 
    def get_intersection_by_model(self, model, tags):
281
 
        """
282
 
        Create a queryset matching instances of the given Model
283
 
        associated with all the given list of Tags.
284
 
 
285
 
        FIXME The query currently used to grab the ids of objects
286
 
              which have all the tags should be all that we need run,
287
 
              using a non-explicit join for the QuerySet returned, as
288
 
              in get_by_model, but there's currently no way to get the
289
 
              required GROUP BY and HAVING clauses into Django's ORM.
290
 
 
291
 
              Once the ORM is capable of this, we should have a
292
 
              solution which requires only a single query and won't
293
 
              have the problem where the number of ids in the IN
294
 
              clause in the QuerySet could exceed the length allowed,
295
 
              as could currently happen.
296
 
        """
297
 
        tags = get_tag_list(tags)
298
 
        tag_count = len(tags)
299
 
        model_table = qn(model._meta.db_table)
300
 
        # This query selects the ids of all objects which have all the
301
 
        # given tags.
302
 
        query = """
303
 
        SELECT %(model_pk)s
304
 
        FROM %(model)s, %(tagged_item)s
305
 
        WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
306
 
          AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
307
 
          AND %(model_pk)s = %(tagged_item)s.object_id
308
 
        GROUP BY %(model_pk)s
309
 
        HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % {
310
 
            'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
311
 
            'model': model_table,
312
 
            'tagged_item': qn(self.model._meta.db_table),
313
 
            'content_type_id': ContentType.objects.get_for_model(model).pk,
314
 
            'tag_id_placeholders': ','.join(['%s'] * tag_count),
315
 
            'tag_count': tag_count,
316
 
        }
317
 
 
318
 
        cursor = connection.cursor()
319
 
        cursor.execute(query, [tag.pk for tag in tags])
320
 
        object_ids = [row[0] for row in cursor.fetchall()]
321
 
        if len(object_ids) > 0:
322
 
            return model._default_manager.filter(pk__in=object_ids)
323
 
        else:
324
 
            return model._default_manager.none()
325
 
 
326
 
    def get_union_by_model(self, model, tags):
327
 
        """
328
 
        Create a queryset matching instances of the given Model
329
 
        associated with any of the given list of Tags.
330
 
        """
331
 
        tags = get_tag_list(tags)
332
 
        tag_count = len(tags)
333
 
        model_table = qn(model._meta.db_table)
334
 
        # This query selects the ids of all objects which have any of
335
 
        # the given tags.
336
 
        query = """
337
 
        SELECT %(model_pk)s
338
 
        FROM %(model)s, %(tagged_item)s
339
 
        WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
340
 
          AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
341
 
          AND %(model_pk)s = %(tagged_item)s.object_id
342
 
        GROUP BY %(model_pk)s""" % {
343
 
            'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
344
 
            'model': model_table,
345
 
            'tagged_item': qn(self.model._meta.db_table),
346
 
            'content_type_id': ContentType.objects.get_for_model(model).pk,
347
 
            'tag_id_placeholders': ','.join(['%s'] * tag_count),
348
 
        }
349
 
 
350
 
        cursor = connection.cursor()
351
 
        cursor.execute(query, [tag.pk for tag in tags])
352
 
        object_ids = [row[0] for row in cursor.fetchall()]
353
 
        if len(object_ids) > 0:
354
 
            return model._default_manager.filter(pk__in=object_ids)
355
 
        else:
356
 
            return model._default_manager.none()
357
 
 
358
 
    def get_related(self, obj, model, num=None):
359
 
        """
360
 
        Retrieve instances of ``model`` which share tags with the
361
 
        model instance ``obj``, ordered by the number of shared tags
362
 
        in descending order.
363
 
 
364
 
        If ``num`` is given, a maximum of ``num`` instances will be
365
 
        returned.
366
 
        """
367
 
        model_table = qn(model._meta.db_table)
368
 
        content_type = ContentType.objects.get_for_model(obj)
369
 
        related_content_type = ContentType.objects.get_for_model(model)
370
 
        query = """
371
 
        SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s
372
 
        FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item
373
 
        WHERE %(tagged_item)s.object_id = %%s
374
 
          AND %(tagged_item)s.content_type_id = %(content_type_id)s
375
 
          AND %(tag)s.id = %(tagged_item)s.tag_id
376
 
          AND related_tagged_item.content_type_id = %(related_content_type_id)s
377
 
          AND related_tagged_item.tag_id = %(tagged_item)s.tag_id
378
 
          AND %(model_pk)s = related_tagged_item.object_id"""
379
 
        if content_type.pk == related_content_type.pk:
380
 
            # Exclude the given instance itself if determining related
381
 
            # instances for the same model.
382
 
            query += """
383
 
          AND related_tagged_item.object_id != %(tagged_item)s.object_id"""
384
 
        query += """
385
 
        GROUP BY %(model_pk)s
386
 
        ORDER BY %(count)s DESC
387
 
        %(limit_offset)s"""
388
 
        query = query % {
389
 
            'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)),
390
 
            'count': qn('count'),
391
 
            'model': model_table,
392
 
            'tagged_item': qn(self.model._meta.db_table),
393
 
            'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table),
394
 
            'content_type_id': content_type.pk,
395
 
            'related_content_type_id': related_content_type.pk,
396
 
            'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '',
397
 
        }
398
 
 
399
 
        cursor = connection.cursor()
400
 
        cursor.execute(query, [obj.pk])
401
 
        object_ids = [row[0] for row in cursor.fetchall()]
402
 
        if len(object_ids) > 0:
403
 
            # Use in_bulk here instead of an id__in lookup, because id__in would
404
 
            # clobber the ordering.
405
 
            object_dict = model._default_manager.in_bulk(object_ids)
406
 
            return [object_dict[object_id] for object_id in object_ids]
407
 
        else:
408
 
            return model._default_manager.none()
 
5
from django.db import models
 
6
 
 
7
from tagging.models import TaggedItem
 
8
 
 
9
class ModelTaggedItemManager(models.Manager):
 
10
    """
 
11
    A manager for retrieving model instances based on their tags.
 
12
    """
 
13
    def related_to(self, obj):
 
14
        return TaggedItem.objects.get_related(obj, self.model)
 
15
 
 
16
    def with_all(self, tags):
 
17
        return TaggedItem.objects.get_by_model(self.model, tags)
 
18
 
 
19
    def with_any(self, tags):
 
20
        return TaggedItem.objects.get_union_by_model(self.model, tags)