diff options
author | SVN-Git Migration <python-modules-team@lists.alioth.debian.org> | 2015-10-08 11:51:45 -0700 |
---|---|---|
committer | SVN-Git Migration <python-modules-team@lists.alioth.debian.org> | 2015-10-08 11:51:45 -0700 |
commit | 2228968f3d51a3d686adb2839bf43e018432f941 (patch) | |
tree | 9e95992c4b09bbea50336e91a709a775a20fd1e5 /tagging/managers.py | |
download | python-django-tagging-upstream/0.1+svn102.tar python-django-tagging-upstream/0.1+svn102.tar.gz |
Imported Upstream version 0.1+svn102upstream/0.1+svn102
Diffstat (limited to 'tagging/managers.py')
-rw-r--r-- | tagging/managers.py | 366 |
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() |