aboutsummaryrefslogtreecommitdiff
path: root/tagging/managers.py
diff options
context:
space:
mode:
Diffstat (limited to 'tagging/managers.py')
-rw-r--r--tagging/managers.py366
1 files changed, 366 insertions, 0 deletions
diff --git a/tagging/managers.py b/tagging/managers.py
new file mode 100644
index 0000000..5e47605
--- /dev/null
+++ b/tagging/managers.py
@@ -0,0 +1,366 @@
+"""
+Custom Managers for generic tagging Models.
+"""
+import types
+
+from django.db import connection
+from django.db.models import Manager
+from django.db.models.query import QuerySet, parse_lookup
+from django.contrib.contenttypes.models import ContentType
+
+from tagging import settings
+from tagging.utils import calculate_cloud, get_tag_name_list, get_tag_list, LOGARITHMIC
+from tagging.validators import tag_re
+
+# Python 2.3 compatibility
+if not hasattr(__builtins__, 'set'):
+ from sets import Set as set
+
+qn = connection.ops.quote_name
+
+class TagManager(Manager):
+ def update_tags(self, obj, tag_names):
+ """
+ Update tags associated with an object.
+ """
+ ctype = ContentType.objects.get_for_model(obj)
+ current_tags = list(self.filter(items__content_type__pk=ctype.id,
+ items__object_id=obj._get_pk_val()))
+ updated_tag_names = set(get_tag_name_list(tag_names))
+ if settings.FORCE_LOWERCASE_TAGS:
+ updated_tag_names = [t.lower() for t in updated_tag_names]
+
+ TaggedItemModel = self._get_related_model_by_accessor('items')
+
+ # Remove tags which no longer apply
+ tags_for_removal = [tag for tag in current_tags \
+ if tag.name not in updated_tag_names]
+ if len(tags_for_removal) > 0:
+ TaggedItemModel._default_manager.filter(content_type__pk=ctype.id,
+ object_id=obj._get_pk_val(),
+ tag__in=tags_for_removal).delete()
+
+ # Add new tags
+ current_tag_names = [tag.name for tag in current_tags]
+ for tag_name in updated_tag_names:
+ if tag_name not in current_tag_names:
+ tag, created = self.get_or_create(name=tag_name)
+ TaggedItemModel._default_manager.create(tag=tag, object=obj)
+
+ def add_tag(self, obj, tag_name):
+ """
+ Associates the given object with a tag.
+ """
+ if not tag_re.match(tag_name):
+ raise AttributeError(u'An invalid tag name was given: %s. Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens.' % tag_name)
+ if settings.FORCE_LOWERCASE_TAGS:
+ tag_name = tag_name.lower()
+ tag, created = self.get_or_create(name=tag_name)
+ ctype = ContentType.objects.get_for_model(obj)
+ TaggedItemModel = self._get_related_model_by_accessor('items')
+ TaggedItemModel._default_manager.get_or_create(
+ tag=tag, content_type=ctype, object_id=obj._get_pk_val())
+
+ def get_for_object(self, obj):
+ """
+ Create a queryset matching all tags associated with the given
+ object.
+ """
+ ctype = ContentType.objects.get_for_model(obj)
+ return self.filter(items__content_type__pk=ctype.id,
+ items__object_id=obj._get_pk_val())
+
+ def usage_for_model(self, Model, counts=False, min_count=None, filters=None):
+ """
+ Obtain a list of tags associated with instances of the given
+ Model.
+
+ If ``counts`` is True, a ``count`` attribute will be added to
+ each tag, indicating how many times it has been used against
+ the Model in question.
+
+ If ``min_count`` is given, only tags which have a ``count``
+ greater than or equal to ``min_count`` will be returned.
+ Passing a value for ``min_count`` implies ``counts=True``.
+
+ To limit the tags (and counts, if specified) returned to those
+ used by a subset of the Model's instances, pass a dictionary
+ of field lookups to be applied to the given Model as the
+ ``filters`` argument.
+ """
+ if filters is None: filters = {}
+ if min_count is not None: counts = True
+
+ model_table = qn(Model._meta.db_table)
+ model_pk = '%s.%s' % (model_table, qn(Model._meta.pk.column))
+ query = """
+ SELECT DISTINCT %(tag)s.id, %(tag)s.name%(count_sql)s
+ FROM
+ %(tag)s
+ INNER JOIN %(tagged_item)s
+ ON %(tag)s.id = %(tagged_item)s.tag_id
+ INNER JOIN %(model)s
+ ON %(tagged_item)s.object_id = %(model_pk)s
+ %%s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ %%s
+ GROUP BY %(tag)s.id, %(tag)s.name
+ %%s
+ ORDER BY %(tag)s.name ASC""" % {
+ 'tag': qn(self.model._meta.db_table),
+ 'count_sql': counts and (', COUNT(%s)' % model_pk) or '',
+ 'tagged_item': qn(self._get_related_model_by_accessor('items')._meta.db_table),
+ 'model': model_table,
+ 'model_pk': model_pk,
+ 'content_type_id': ContentType.objects.get_for_model(Model).id,
+ }
+
+ extra_joins = ''
+ extra_criteria = ''
+ min_count_sql = ''
+ params = []
+ if len(filters) > 0:
+ joins, where, params = parse_lookup(filters.items(), Model._meta)
+ extra_joins = ' '.join(['%s %s AS %s ON %s' % (join_type, table, alias, condition)
+ for (alias, (table, join_type, condition)) in joins.items()])
+ extra_criteria = 'AND %s' % (' AND '.join(where))
+ if min_count is not None:
+ min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk
+ params.append(min_count)
+
+ cursor = connection.cursor()
+ cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params)
+ tags = []
+ for row in cursor.fetchall():
+ t = self.model(*row[:2])
+ if counts:
+ t.count = row[2]
+ tags.append(t)
+ return tags
+
+ def related_for_model(self, tags, Model, counts=False, min_count=None):
+ """
+ Obtain a list of tags related to a given list of tags - that
+ is, other tags used by items which have all the given tags.
+
+ If ``counts`` is True, a ``count`` attribute will be added to
+ each tag, indicating the number of items which have it in
+ addition to the given list of tags.
+
+ If ``min_count`` is given, only tags which have a ``count``
+ greater than or equal to ``min_count`` will be returned.
+ Passing a value for ``min_count`` implies ``counts=True``.
+ """
+ if min_count is not None: counts = True
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ tagged_item_table = qn(self._get_related_model_by_accessor('items')._meta.db_table)
+ query = """
+ SELECT %(tag)s.id, %(tag)s.name%(count_sql)s
+ FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tagged_item)s.object_id IN
+ (
+ SELECT %(tagged_item)s.object_id
+ FROM %(tagged_item)s, %(tag)s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tag)s.id = %(tagged_item)s.tag_id
+ AND %(tag)s.id IN (%(tag_id_placeholders)s)
+ GROUP BY %(tagged_item)s.object_id
+ HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s
+ )
+ AND %(tag)s.id NOT IN (%(tag_id_placeholders)s)
+ GROUP BY %(tag)s.id, %(tag)s.name
+ %(min_count_sql)s
+ ORDER BY %(tag)s.name ASC""" % {
+ 'tag': qn(self.model._meta.db_table),
+ 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '',
+ 'tagged_item': tagged_item_table,
+ 'content_type_id': ContentType.objects.get_for_model(Model).id,
+ 'tag_id_placeholders': ','.join(['%s'] * tag_count),
+ 'tag_count': tag_count,
+ 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '',
+ }
+
+ params = [tag.id for tag in tags] * 2
+ if min_count is not None:
+ params.append(min_count)
+
+ cursor = connection.cursor()
+ cursor.execute(query, params)
+ related = []
+ for row in cursor.fetchall():
+ tag = self.model(*row[:2])
+ if counts is True:
+ tag.count = row[2]
+ related.append(tag)
+ return related
+
+ def cloud_for_model(self, Model, steps=4, distribution=LOGARITHMIC, filters=None, min_count=None):
+ """
+ Obtain a list of tags associated with instances of the given
+ Model, giving each tag a ``count`` attribute indicating how
+ many times it has been used and a ``font_size`` attribute for
+ use in displaying a tag cloud.
+
+ ``steps`` defines the range of font sizes - ``font_size`` will
+ be an integer between 1 and ``steps`` (inclusive).
+
+ ``distribution`` defines the type of font size distribution
+ algorithm which will be used - logarithmic or linear. It must
+ be either ``tagging.utils.LOGARITHMIC`` or
+ ``tagging.utils.LINEAR``.
+
+ To limit the tags displayed in the cloud to those associated
+ with a subset of the Model's instances, pass a dictionary of
+ field lookups to be applied to the given Model as the
+ ``filters`` argument.
+
+ To limit the tags displayed in the cloud to those with a
+ ``count`` greater than or equal to ``min_count``, pass a value
+ for the ``min_count`` argument.
+ """
+ tags = list(self.usage_for_model(Model, counts=True, filters=filters, min_count=min_count))
+ return calculate_cloud(tags, steps)
+
+ def _get_related_model_by_accessor(self, accessor):
+ """
+ Returns the model for the related object accessed by the
+ given attribute name.
+
+ Since we sometimes need to access the ``TaggedItem`` model
+ when managing tagging and the``Tag`` model does not have a
+ field representing this relationship, this method is used to
+ retrieve the ``TaggedItem`` model, avoiding circular imports
+ betweeen the `models` and `managers` modules.
+ """
+ for rel_obj in self.model._meta.get_all_related_objects():
+ if rel_obj.get_accessor_name() == accessor:
+ return rel_obj.model
+ raise ValueError('Could not find a related object with the accessor "%s".' % accessor)
+
+class TaggedItemManager(Manager):
+ def get_by_model(self, Model, tags):
+ """
+ Create a queryset matching instances of the given Model
+ associated with a given Tag or list of Tags.
+ """
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ if tag_count == 0:
+ return [] # No existing tags were given
+ elif tag_count == 1:
+ tag = tags[0] # Optimisation for single tag
+ else:
+ return self.get_intersection_by_model(Model, tags)
+ ctype = ContentType.objects.get_for_model(Model)
+ rel_table = qn(self.model._meta.db_table)
+ return Model._default_manager.extra(
+ tables=[self.model._meta.db_table], # Use a non-explicit join
+ where=[
+ '%s.content_type_id = %%s' % rel_table,
+ '%s.tag_id = %%s' % rel_table,
+ '%s.%s = %s.object_id' % (qn(Model._meta.db_table),
+ qn(Model._meta.pk.column),
+ rel_table)
+ ],
+ params=[ctype.id, tag.id],
+ )
+
+ def get_intersection_by_model(self, Model, tags):
+ """
+ Create a queryset matching instances of the given Model
+ associated with all the given list of Tags.
+
+ FIXME The query currently used to grab the ids of objects
+ which have all the tags should be all that we need run,
+ using a non-explicit join for the QuerySet returned, as
+ in get_by_model, but there's currently no way to get the
+ required GROUP BY and HAVING clauses into Django's ORM.
+
+ Once the ORM is capable of this, we should have a
+ solution which requires only a single query and won't
+ have the problem where the number of ids in the IN
+ clause in the QuerySet could exceed the length allowed,
+ as could currently happen.
+ """
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ model_table = qn(Model._meta.db_table)
+ # This query selects the ids of all objects which have all the
+ # given tags.
+ query = """
+ SELECT %(model_pk)s
+ FROM %(model)s, %(tagged_item)s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
+ AND %(model_pk)s = %(tagged_item)s.object_id
+ GROUP BY %(model_pk)s
+ HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % {
+ 'model_pk': '%s.%s' % (model_table, qn(Model._meta.pk.column)),
+ 'model': model_table,
+ 'tagged_item': qn(self.model._meta.db_table),
+ 'content_type_id': ContentType.objects.get_for_model(Model).id,
+ 'tag_id_placeholders': ','.join(['%s'] * tag_count),
+ 'tag_count': tag_count,
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [tag.id for tag in tags])
+ object_ids = [row[0] for row in cursor.fetchall()]
+ if len(object_ids) > 0:
+ return Model._default_manager.filter(pk__in=object_ids)
+ else:
+ return Model._default_manager.none()
+
+ def get_related(self, obj, Model, num=None):
+ """
+ Retrieve instances of ``Model`` which share tags with the
+ model instance ``obj``, ordered by the number of shared tags
+ in descending order.
+
+ If ``num`` is given, a maximum of ``num`` instances will be
+ returned.
+ """
+ model_table = qn(Model._meta.db_table)
+ content_type = ContentType.objects.get_for_model(obj)
+ related_content_type = ContentType.objects.get_for_model(Model)
+ query = """
+ SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s
+ FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item
+ WHERE %(tagged_item)s.object_id = %%s
+ AND %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tag)s.id = %(tagged_item)s.tag_id
+ AND related_tagged_item.content_type_id = %(related_content_type_id)s
+ AND related_tagged_item.tag_id = %(tagged_item)s.tag_id
+ AND %(model_pk)s = related_tagged_item.object_id"""
+ if content_type.id == related_content_type.id:
+ # Exclude the given instance itself if determining related
+ # instances for the same model.
+ query += """
+ AND related_tagged_item.object_id != %(tagged_item)s.object_id"""
+ query += """
+ GROUP BY %(model_pk)s
+ ORDER BY %(count)s DESC
+ %(limit_offset)s"""
+ query = query % {
+ 'model_pk': '%s.%s' % (model_table, qn(Model._meta.pk.column)),
+ 'count': qn('count'),
+ 'model': model_table,
+ 'tagged_item': qn(self.model._meta.db_table),
+ 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table),
+ 'content_type_id': content_type.id,
+ 'related_content_type_id': related_content_type.id,
+ 'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '',
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [obj._get_pk_val()])
+ object_ids = [row[0] for row in cursor.fetchall()]
+ if len(object_ids) > 0:
+ # Use in_bulk here instead of an id__in lookup, because id__in would
+ # clobber the ordering.
+ object_dict = Model._default_manager.in_bulk(object_ids)
+ return [object_dict[object_id] for object_id in object_ids]
+ else:
+ return Model._default_manager.none()