|
1 """ |
|
2 Models and managers for generic tagging. |
|
3 """ |
|
4 # Python 2.3 compatibility |
|
5 try: |
|
6 set |
|
7 except NameError: |
|
8 from sets import Set as set |
|
9 |
|
10 from django.contrib.contenttypes import generic |
|
11 from django.contrib.contenttypes.models import ContentType |
|
12 from django.db import connection, models |
|
13 from django.db.models.query import QuerySet |
|
14 from django.utils.translation import ugettext_lazy as _ |
|
15 |
|
16 from tagging import settings |
|
17 from tagging.utils import calculate_cloud, get_tag_list, get_queryset_and_model, parse_tag_input |
|
18 from tagging.utils import LOGARITHMIC |
|
19 |
|
20 qn = connection.ops.quote_name |
|
21 |
|
22 ############ |
|
23 # Managers # |
|
24 ############ |
|
25 |
|
26 class TagManager(models.Manager): |
|
27 def update_tags(self, obj, tag_names): |
|
28 """ |
|
29 Update tags associated with an object. |
|
30 """ |
|
31 ctype = ContentType.objects.get_for_model(obj) |
|
32 current_tags = list(self.filter(items__content_type__pk=ctype.pk, |
|
33 items__object_id=obj.pk)) |
|
34 updated_tag_names = parse_tag_input(tag_names) |
|
35 if settings.FORCE_LOWERCASE_TAGS: |
|
36 updated_tag_names = [t.lower() for t in updated_tag_names] |
|
37 |
|
38 # Remove tags which no longer apply |
|
39 tags_for_removal = [tag for tag in current_tags \ |
|
40 if tag.name not in updated_tag_names] |
|
41 if len(tags_for_removal): |
|
42 TaggedItem._default_manager.filter(content_type__pk=ctype.pk, |
|
43 object_id=obj.pk, |
|
44 tag__in=tags_for_removal).delete() |
|
45 # Add new tags |
|
46 current_tag_names = [tag.name for tag in current_tags] |
|
47 for tag_name in updated_tag_names: |
|
48 if tag_name not in current_tag_names: |
|
49 tag, created = self.get_or_create(name=tag_name) |
|
50 TaggedItem._default_manager.create(tag=tag, object=obj) |
|
51 |
|
52 def add_tag(self, obj, tag_name): |
|
53 """ |
|
54 Associates the given object with a tag. |
|
55 """ |
|
56 tag_names = parse_tag_input(tag_name) |
|
57 if not len(tag_names): |
|
58 raise AttributeError(_('No tags were given: "%s".') % tag_name) |
|
59 if len(tag_names) > 1: |
|
60 raise AttributeError(_('Multiple tags were given: "%s".') % tag_name) |
|
61 tag_name = tag_names[0] |
|
62 if settings.FORCE_LOWERCASE_TAGS: |
|
63 tag_name = tag_name.lower() |
|
64 tag, created = self.get_or_create(name=tag_name) |
|
65 ctype = ContentType.objects.get_for_model(obj) |
|
66 TaggedItem._default_manager.get_or_create( |
|
67 tag=tag, content_type=ctype, object_id=obj.pk) |
|
68 |
|
69 def get_for_object(self, obj): |
|
70 """ |
|
71 Create a queryset matching all tags associated with the given |
|
72 object. |
|
73 """ |
|
74 ctype = ContentType.objects.get_for_model(obj) |
|
75 return self.filter(items__content_type__pk=ctype.pk, |
|
76 items__object_id=obj.pk) |
|
77 |
|
78 def _get_usage(self, model, counts=False, min_count=None, extra_joins=None, extra_criteria=None, params=None): |
|
79 """ |
|
80 Perform the custom SQL query for ``usage_for_model`` and |
|
81 ``usage_for_queryset``. |
|
82 """ |
|
83 if min_count is not None: counts = True |
|
84 |
|
85 model_table = qn(model._meta.db_table) |
|
86 model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column)) |
|
87 query = """ |
|
88 SELECT DISTINCT %(tag)s.id, %(tag)s.name%(count_sql)s |
|
89 FROM |
|
90 %(tag)s |
|
91 INNER JOIN %(tagged_item)s |
|
92 ON %(tag)s.id = %(tagged_item)s.tag_id |
|
93 INNER JOIN %(model)s |
|
94 ON %(tagged_item)s.object_id = %(model_pk)s |
|
95 %%s |
|
96 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s |
|
97 %%s |
|
98 GROUP BY %(tag)s.id, %(tag)s.name |
|
99 %%s |
|
100 ORDER BY %(tag)s.name ASC""" % { |
|
101 'tag': qn(self.model._meta.db_table), |
|
102 'count_sql': counts and (', COUNT(%s)' % model_pk) or '', |
|
103 'tagged_item': qn(TaggedItem._meta.db_table), |
|
104 'model': model_table, |
|
105 'model_pk': model_pk, |
|
106 'content_type_id': ContentType.objects.get_for_model(model).pk, |
|
107 } |
|
108 |
|
109 min_count_sql = '' |
|
110 if min_count is not None: |
|
111 min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk |
|
112 params.append(min_count) |
|
113 |
|
114 cursor = connection.cursor() |
|
115 cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params) |
|
116 tags = [] |
|
117 for row in cursor.fetchall(): |
|
118 t = self.model(*row[:2]) |
|
119 if counts: |
|
120 t.count = row[2] |
|
121 tags.append(t) |
|
122 return tags |
|
123 |
|
124 def usage_for_model(self, model, counts=False, min_count=None, filters=None): |
|
125 """ |
|
126 Obtain a list of tags associated with instances of the given |
|
127 Model class. |
|
128 |
|
129 If ``counts`` is True, a ``count`` attribute will be added to |
|
130 each tag, indicating how many times it has been used against |
|
131 the Model class in question. |
|
132 |
|
133 If ``min_count`` is given, only tags which have a ``count`` |
|
134 greater than or equal to ``min_count`` will be returned. |
|
135 Passing a value for ``min_count`` implies ``counts=True``. |
|
136 |
|
137 To limit the tags (and counts, if specified) returned to those |
|
138 used by a subset of the Model's instances, pass a dictionary |
|
139 of field lookups to be applied to the given Model as the |
|
140 ``filters`` argument. |
|
141 """ |
|
142 if filters is None: filters = {} |
|
143 |
|
144 queryset = model._default_manager.filter() |
|
145 for f in filters.items(): |
|
146 queryset.query.add_filter(f) |
|
147 usage = self.usage_for_queryset(queryset, counts, min_count) |
|
148 |
|
149 return usage |
|
150 |
|
151 def usage_for_queryset(self, queryset, counts=False, min_count=None): |
|
152 """ |
|
153 Obtain a list of tags associated with instances of a model |
|
154 contained in the given queryset. |
|
155 |
|
156 If ``counts`` is True, a ``count`` attribute will be added to |
|
157 each tag, indicating how many times it has been used against |
|
158 the Model class in question. |
|
159 |
|
160 If ``min_count`` is given, only tags which have a ``count`` |
|
161 greater than or equal to ``min_count`` will be returned. |
|
162 Passing a value for ``min_count`` implies ``counts=True``. |
|
163 """ |
|
164 |
|
165 if getattr(queryset.query, 'get_compiler', None): |
|
166 # Django 1.2+ |
|
167 compiler = queryset.query.get_compiler(using='default') |
|
168 extra_joins = ' '.join(compiler.get_from_clause()[0][1:]) |
|
169 where, params = queryset.query.where.as_sql( |
|
170 compiler.quote_name_unless_alias, compiler.connection |
|
171 ) |
|
172 else: |
|
173 # Django pre-1.2 |
|
174 extra_joins = ' '.join(queryset.query.get_from_clause()[0][1:]) |
|
175 where, params = queryset.query.where.as_sql() |
|
176 |
|
177 if where: |
|
178 extra_criteria = 'AND %s' % where |
|
179 else: |
|
180 extra_criteria = '' |
|
181 return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params) |
|
182 |
|
183 def related_for_model(self, tags, model, counts=False, min_count=None): |
|
184 """ |
|
185 Obtain a list of tags related to a given list of tags - that |
|
186 is, other tags used by items which have all the given tags. |
|
187 |
|
188 If ``counts`` is True, a ``count`` attribute will be added to |
|
189 each tag, indicating the number of items which have it in |
|
190 addition to the given list of tags. |
|
191 |
|
192 If ``min_count`` is given, only tags which have a ``count`` |
|
193 greater than or equal to ``min_count`` will be returned. |
|
194 Passing a value for ``min_count`` implies ``counts=True``. |
|
195 """ |
|
196 if min_count is not None: counts = True |
|
197 tags = get_tag_list(tags) |
|
198 tag_count = len(tags) |
|
199 tagged_item_table = qn(TaggedItem._meta.db_table) |
|
200 query = """ |
|
201 SELECT %(tag)s.id, %(tag)s.name%(count_sql)s |
|
202 FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id |
|
203 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s |
|
204 AND %(tagged_item)s.object_id IN |
|
205 ( |
|
206 SELECT %(tagged_item)s.object_id |
|
207 FROM %(tagged_item)s, %(tag)s |
|
208 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s |
|
209 AND %(tag)s.id = %(tagged_item)s.tag_id |
|
210 AND %(tag)s.id IN (%(tag_id_placeholders)s) |
|
211 GROUP BY %(tagged_item)s.object_id |
|
212 HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s |
|
213 ) |
|
214 AND %(tag)s.id NOT IN (%(tag_id_placeholders)s) |
|
215 GROUP BY %(tag)s.id, %(tag)s.name |
|
216 %(min_count_sql)s |
|
217 ORDER BY %(tag)s.name ASC""" % { |
|
218 'tag': qn(self.model._meta.db_table), |
|
219 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '', |
|
220 'tagged_item': tagged_item_table, |
|
221 'content_type_id': ContentType.objects.get_for_model(model).pk, |
|
222 'tag_id_placeholders': ','.join(['%s'] * tag_count), |
|
223 'tag_count': tag_count, |
|
224 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '', |
|
225 } |
|
226 |
|
227 params = [tag.pk for tag in tags] * 2 |
|
228 if min_count is not None: |
|
229 params.append(min_count) |
|
230 |
|
231 cursor = connection.cursor() |
|
232 cursor.execute(query, params) |
|
233 related = [] |
|
234 for row in cursor.fetchall(): |
|
235 tag = self.model(*row[:2]) |
|
236 if counts is True: |
|
237 tag.count = row[2] |
|
238 related.append(tag) |
|
239 return related |
|
240 |
|
241 def cloud_for_model(self, model, steps=4, distribution=LOGARITHMIC, |
|
242 filters=None, min_count=None): |
|
243 """ |
|
244 Obtain a list of tags associated with instances of the given |
|
245 Model, giving each tag a ``count`` attribute indicating how |
|
246 many times it has been used and a ``font_size`` attribute for |
|
247 use in displaying a tag cloud. |
|
248 |
|
249 ``steps`` defines the range of font sizes - ``font_size`` will |
|
250 be an integer between 1 and ``steps`` (inclusive). |
|
251 |
|
252 ``distribution`` defines the type of font size distribution |
|
253 algorithm which will be used - logarithmic or linear. It must |
|
254 be either ``tagging.utils.LOGARITHMIC`` or |
|
255 ``tagging.utils.LINEAR``. |
|
256 |
|
257 To limit the tags displayed in the cloud to those associated |
|
258 with a subset of the Model's instances, pass a dictionary of |
|
259 field lookups to be applied to the given Model as the |
|
260 ``filters`` argument. |
|
261 |
|
262 To limit the tags displayed in the cloud to those with a |
|
263 ``count`` greater than or equal to ``min_count``, pass a value |
|
264 for the ``min_count`` argument. |
|
265 """ |
|
266 tags = list(self.usage_for_model(model, counts=True, filters=filters, |
|
267 min_count=min_count)) |
|
268 return calculate_cloud(tags, steps, distribution) |
|
269 |
|
270 class TaggedItemManager(models.Manager): |
|
271 """ |
|
272 FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING`` |
|
273 SQL clauses required by many of this manager's methods into |
|
274 Django's ORM. |
|
275 |
|
276 For now, we manually execute a query to retrieve the PKs of |
|
277 objects we're interested in, then use the ORM's ``__in`` |
|
278 lookup to return a ``QuerySet``. |
|
279 |
|
280 Now that the queryset-refactor branch is in the trunk, this can be |
|
281 tidied up significantly. |
|
282 """ |
|
283 def get_by_model(self, queryset_or_model, tags): |
|
284 """ |
|
285 Create a ``QuerySet`` containing instances of the specified |
|
286 model associated with a given tag or list of tags. |
|
287 """ |
|
288 tags = get_tag_list(tags) |
|
289 tag_count = len(tags) |
|
290 if tag_count == 0: |
|
291 # No existing tags were given |
|
292 queryset, model = get_queryset_and_model(queryset_or_model) |
|
293 return model._default_manager.none() |
|
294 elif tag_count == 1: |
|
295 # Optimisation for single tag - fall through to the simpler |
|
296 # query below. |
|
297 tag = tags[0] |
|
298 else: |
|
299 return self.get_intersection_by_model(queryset_or_model, tags) |
|
300 |
|
301 queryset, model = get_queryset_and_model(queryset_or_model) |
|
302 content_type = ContentType.objects.get_for_model(model) |
|
303 opts = self.model._meta |
|
304 tagged_item_table = qn(opts.db_table) |
|
305 return queryset.extra( |
|
306 tables=[opts.db_table], |
|
307 where=[ |
|
308 '%s.content_type_id = %%s' % tagged_item_table, |
|
309 '%s.tag_id = %%s' % tagged_item_table, |
|
310 '%s.%s = %s.object_id' % (qn(model._meta.db_table), |
|
311 qn(model._meta.pk.column), |
|
312 tagged_item_table) |
|
313 ], |
|
314 params=[content_type.pk, tag.pk], |
|
315 ) |
|
316 |
|
317 def get_intersection_by_model(self, queryset_or_model, tags): |
|
318 """ |
|
319 Create a ``QuerySet`` containing instances of the specified |
|
320 model associated with *all* of the given list of tags. |
|
321 """ |
|
322 tags = get_tag_list(tags) |
|
323 tag_count = len(tags) |
|
324 queryset, model = get_queryset_and_model(queryset_or_model) |
|
325 |
|
326 if not tag_count: |
|
327 return model._default_manager.none() |
|
328 |
|
329 model_table = qn(model._meta.db_table) |
|
330 # This query selects the ids of all objects which have all the |
|
331 # given tags. |
|
332 query = """ |
|
333 SELECT %(model_pk)s |
|
334 FROM %(model)s, %(tagged_item)s |
|
335 WHERE %(tagged_item)s.content_type_id = %(content_type_id)s |
|
336 AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s) |
|
337 AND %(model_pk)s = %(tagged_item)s.object_id |
|
338 GROUP BY %(model_pk)s |
|
339 HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % { |
|
340 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), |
|
341 'model': model_table, |
|
342 'tagged_item': qn(self.model._meta.db_table), |
|
343 'content_type_id': ContentType.objects.get_for_model(model).pk, |
|
344 'tag_id_placeholders': ','.join(['%s'] * tag_count), |
|
345 'tag_count': tag_count, |
|
346 } |
|
347 |
|
348 cursor = connection.cursor() |
|
349 cursor.execute(query, [tag.pk for tag in tags]) |
|
350 object_ids = [row[0] for row in cursor.fetchall()] |
|
351 if len(object_ids) > 0: |
|
352 return queryset.filter(pk__in=object_ids) |
|
353 else: |
|
354 return model._default_manager.none() |
|
355 |
|
356 def get_union_by_model(self, queryset_or_model, tags): |
|
357 """ |
|
358 Create a ``QuerySet`` containing instances of the specified |
|
359 model associated with *any* of the given list of tags. |
|
360 """ |
|
361 tags = get_tag_list(tags) |
|
362 tag_count = len(tags) |
|
363 queryset, model = get_queryset_and_model(queryset_or_model) |
|
364 |
|
365 if not tag_count: |
|
366 return model._default_manager.none() |
|
367 |
|
368 model_table = qn(model._meta.db_table) |
|
369 # This query selects the ids of all objects which have any of |
|
370 # the given tags. |
|
371 query = """ |
|
372 SELECT %(model_pk)s |
|
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 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), |
|
379 'model': model_table, |
|
380 'tagged_item': qn(self.model._meta.db_table), |
|
381 'content_type_id': ContentType.objects.get_for_model(model).pk, |
|
382 'tag_id_placeholders': ','.join(['%s'] * tag_count), |
|
383 } |
|
384 |
|
385 cursor = connection.cursor() |
|
386 cursor.execute(query, [tag.pk for tag in tags]) |
|
387 object_ids = [row[0] for row in cursor.fetchall()] |
|
388 if len(object_ids) > 0: |
|
389 return queryset.filter(pk__in=object_ids) |
|
390 else: |
|
391 return model._default_manager.none() |
|
392 |
|
393 def get_related(self, obj, queryset_or_model, num=None): |
|
394 """ |
|
395 Retrieve a list of instances of the specified model which share |
|
396 tags with the model instance ``obj``, ordered by the number of |
|
397 shared tags in descending order. |
|
398 |
|
399 If ``num`` is given, a maximum of ``num`` instances will be |
|
400 returned. |
|
401 """ |
|
402 queryset, model = get_queryset_and_model(queryset_or_model) |
|
403 model_table = qn(model._meta.db_table) |
|
404 content_type = ContentType.objects.get_for_model(obj) |
|
405 related_content_type = ContentType.objects.get_for_model(model) |
|
406 query = """ |
|
407 SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s |
|
408 FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item |
|
409 WHERE %(tagged_item)s.object_id = %%s |
|
410 AND %(tagged_item)s.content_type_id = %(content_type_id)s |
|
411 AND %(tag)s.id = %(tagged_item)s.tag_id |
|
412 AND related_tagged_item.content_type_id = %(related_content_type_id)s |
|
413 AND related_tagged_item.tag_id = %(tagged_item)s.tag_id |
|
414 AND %(model_pk)s = related_tagged_item.object_id""" |
|
415 if content_type.pk == related_content_type.pk: |
|
416 # Exclude the given instance itself if determining related |
|
417 # instances for the same model. |
|
418 query += """ |
|
419 AND related_tagged_item.object_id != %(tagged_item)s.object_id""" |
|
420 query += """ |
|
421 GROUP BY %(model_pk)s |
|
422 ORDER BY %(count)s DESC |
|
423 %(limit_offset)s""" |
|
424 query = query % { |
|
425 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), |
|
426 'count': qn('count'), |
|
427 'model': model_table, |
|
428 'tagged_item': qn(self.model._meta.db_table), |
|
429 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table), |
|
430 'content_type_id': content_type.pk, |
|
431 'related_content_type_id': related_content_type.pk, |
|
432 # Hardcoding this for now just to get tests working again - this |
|
433 # should now be handled by the query object. |
|
434 'limit_offset': num is not None and 'LIMIT %s' or '', |
|
435 } |
|
436 |
|
437 cursor = connection.cursor() |
|
438 params = [obj.pk] |
|
439 if num is not None: |
|
440 params.append(num) |
|
441 cursor.execute(query, params) |
|
442 object_ids = [row[0] for row in cursor.fetchall()] |
|
443 if len(object_ids) > 0: |
|
444 # Use in_bulk here instead of an id__in lookup, because id__in would |
|
445 # clobber the ordering. |
|
446 object_dict = queryset.in_bulk(object_ids) |
|
447 return [object_dict[object_id] for object_id in object_ids \ |
|
448 if object_id in object_dict] |
|
449 else: |
|
450 return [] |
|
451 |
|
452 ########## |
|
453 # Models # |
|
454 ########## |
|
455 |
|
456 class Tag(models.Model): |
|
457 """ |
|
458 A tag. |
|
459 """ |
|
460 name = models.CharField(_('name'), max_length=50, unique=True, db_index=True) |
|
461 |
|
462 objects = TagManager() |
|
463 |
|
464 class Meta: |
|
465 ordering = ('name',) |
|
466 verbose_name = _('tag') |
|
467 verbose_name_plural = _('tags') |
|
468 |
|
469 def __unicode__(self): |
|
470 return self.name |
|
471 |
|
472 class TaggedItem(models.Model): |
|
473 """ |
|
474 Holds the relationship between a tag and the item being tagged. |
|
475 """ |
|
476 tag = models.ForeignKey(Tag, verbose_name=_('tag'), related_name='items') |
|
477 content_type = models.ForeignKey(ContentType, verbose_name=_('content type')) |
|
478 object_id = models.PositiveIntegerField(_('object id'), db_index=True) |
|
479 object = generic.GenericForeignKey('content_type', 'object_id') |
|
480 |
|
481 objects = TaggedItemManager() |
|
482 |
|
483 class Meta: |
|
484 # Enforce unique tag association per object |
|
485 unique_together = (('tag', 'content_type', 'object_id'),) |
|
486 verbose_name = _('tagged item') |
|
487 verbose_name_plural = _('tagged items') |
|
488 |
|
489 def __unicode__(self): |
|
490 return u'%s [%s]' % (self.object, self.tag) |