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 | |
download | python-django-tagging-2228968f3d51a3d686adb2839bf43e018432f941.tar python-django-tagging-2228968f3d51a3d686adb2839bf43e018432f941.tar.gz |
Imported Upstream version 0.1+svn102upstream/0.1+svn102
Diffstat (limited to 'tagging')
-rw-r--r-- | tagging/__init__.py | 1 | ||||
-rw-r--r-- | tagging/fields.py | 109 | ||||
-rw-r--r-- | tagging/forms.py | 22 | ||||
-rw-r--r-- | tagging/managers.py | 366 | ||||
-rw-r--r-- | tagging/models.py | 53 | ||||
-rw-r--r-- | tagging/settings.py | 13 | ||||
-rw-r--r-- | tagging/templatetags/__init__.py | 0 | ||||
-rw-r--r-- | tagging/templatetags/tagging_tags.py | 114 | ||||
-rw-r--r-- | tagging/tests/__init__.py | 0 | ||||
-rw-r--r-- | tagging/tests/models.py | 38 | ||||
-rw-r--r-- | tagging/tests/runtests.py | 8 | ||||
-rw-r--r-- | tagging/tests/settings.py | 27 | ||||
-rw-r--r-- | tagging/tests/tags.txt | 122 | ||||
-rw-r--r-- | tagging/tests/tests.py | 384 | ||||
-rw-r--r-- | tagging/utils.py | 140 | ||||
-rw-r--r-- | tagging/validators.py | 33 | ||||
-rw-r--r-- | tagging/views.py | 48 |
17 files changed, 1478 insertions, 0 deletions
diff --git a/tagging/__init__.py b/tagging/__init__.py new file mode 100644 index 0000000..171b9e0 --- /dev/null +++ b/tagging/__init__.py @@ -0,0 +1 @@ +VERSION = (0, 2, 'pre') diff --git a/tagging/fields.py b/tagging/fields.py new file mode 100644 index 0000000..1e66458 --- /dev/null +++ b/tagging/fields.py @@ -0,0 +1,109 @@ +from django.db.models import signals +from django.db.models.fields import CharField +from django.dispatch import dispatcher + +from tagging import settings +from tagging.models import Tag +from tagging.validators import isTagList + +class TagField(CharField): + """ + A "special" character field that actually works as a relationship to tags + "under the hood". This exposes a space-separated string of tags, but does + the splitting/reordering/etc. under the hood. + """ + def __init__(self, **kwargs): + kwargs['max_length'] = kwargs.get('max_length', 255) + kwargs['blank'] = kwargs.get('blank', True) + kwargs['validator_list'] = [isTagList] + kwargs.get('validator_list', []) + super(TagField, self).__init__(**kwargs) + + def contribute_to_class(self, cls, name): + super(TagField, self).contribute_to_class(cls, name) + + # Make this object the descriptor for field access. + setattr(cls, self.name, self) + + # Save tags back to the database post-save + dispatcher.connect(self._save, signal=signals.post_save, sender=cls) + + def __get__(self, instance, owner=None): + """ + Tag getter. Returns an instance's tags if accessed on an instance, and + all of a model's tags if called on a class. That is, this model:: + + class Link(models.Model): + ... + tags = TagField() + + Lets you do both of these:: + + >>> l = Link.objects.get(...) + >>> l.tags + 'tag1 tag2 tag3' + + >>> Link.tags + 'tag1 tag2 tag3 tag4' + + """ + # Handle access on the model (i.e. Link.tags) + if instance is None: + return tags2str(Tag.objects.usage_for_model(owner)) + + tags = self._get_instance_tag_cache(instance) + if tags is None: + if instance._get_pk_val() is None: + self._set_instance_tag_cache(instance, '') + else: + self._set_instance_tag_cache(instance, tags2str(Tag.objects.get_for_object(instance))) + return self._get_instance_tag_cache(instance) + + def __set__(self, instance, value): + """ + Set an object's tags. + """ + if instance is None: + raise AttributeError('%s can only be set on instances.' % self.name) + if settings.FORCE_LOWERCASE_TAGS and value is not None: + self._set_instance_tag_cache(instance, value.lower()) + else: + self._set_instance_tag_cache(instance, value) + + def _save(self, signal, sender, instance): + """ + Save tags back to the database + """ + tags = self._get_instance_tag_cache(instance) + if tags is not None : + Tag.objects.update_tags(instance, tags) + + def __delete__(self, instance): + """ + Clear all of an object's tags. + """ + self._set_instance_tag_cache(instance, '') + + def _get_instance_tag_cache(self, instance): + """ + Helper: get an instance's tag cache. + """ + return getattr(instance, '_%s_cache' % self.attname, None) + + def _set_instance_tag_cache(self, instance, tags): + """ + Helper: set an instance's tag cache. + """ + setattr(instance, '_%s_cache' % self.attname, tags) + + def get_internal_type(self): + return 'CharField' + + def formfield(self, **kwargs): + from tagging import forms + defaults = {'form_class': forms.TagField} + defaults.update(kwargs) + return super(TagField, self).formfield(**defaults) + +# Helper +def tags2str(tagset): + return u' '.join([t.name for t in tagset]) diff --git a/tagging/forms.py b/tagging/forms.py new file mode 100644 index 0000000..875c598 --- /dev/null +++ b/tagging/forms.py @@ -0,0 +1,22 @@ +from django import newforms as forms + +from tagging.utils import get_tag_name_list +from tagging.validators import tag_list_re + +class TagField(forms.CharField): + def clean(self, value): + """ + Validates that the input is a valid list of tag names, + separated by a single comma, a single space or a comma + followed by a space. + """ + value = super(TagField, self).clean(value) + if value == u'': + return value + if not tag_list_re.search(value): + raise forms.ValidationError(u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.') + tag_names = get_tag_name_list(value) + for tag_name in tag_names: + if len(tag_name) > 50: + raise forms.ValidationError(u'Tag names must be no longer than 50 characters.') + return value 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() diff --git a/tagging/models.py b/tagging/models.py new file mode 100644 index 0000000..199e5eb --- /dev/null +++ b/tagging/models.py @@ -0,0 +1,53 @@ +""" +Models for generic tagging. +""" +from django.db import models +from django.contrib.contenttypes import generic +from django.contrib.contenttypes.models import ContentType + +from tagging.managers import TagManager, TaggedItemManager +from tagging.validators import isTag + +class Tag(models.Model): + """ + A basic tag. + """ + name = models.CharField(max_length=50, unique=True, db_index=True, validator_list=[isTag]) + + objects = TagManager() + + class Meta: + db_table = 'tag' + verbose_name = 'Tag' + verbose_name_plural = 'Tags' + ordering = ('name',) + + class Admin: + pass + + def __unicode__(self): + return self.name + +class TaggedItem(models.Model): + """ + Holds the relationship between a tag and the item being tagged. + """ + tag = models.ForeignKey(Tag, related_name='items') + content_type = models.ForeignKey(ContentType) + object_id = models.PositiveIntegerField() + object = generic.GenericForeignKey('content_type', 'object_id') + + objects = TaggedItemManager() + + class Meta: + db_table = 'tagged_item' + verbose_name = 'Tagged Item' + verbose_name_plural = 'Tagged Items' + # Enforce unique tag association per object + unique_together = (('tag', 'content_type', 'object_id'),) + + class Admin: + pass + + def __unicode__(self): + return u'%s [%s]' % (self.object, self.tag) diff --git a/tagging/settings.py b/tagging/settings.py new file mode 100644 index 0000000..18321bd --- /dev/null +++ b/tagging/settings.py @@ -0,0 +1,13 @@ +""" +Convenience module for access of custom tagging application settings, +which enforces default settings when the main settings module which has +been configured does not contain the appropriate settings. +""" +from django.conf import settings + +# Whether to force all tags to lowercase before they are saved to the +# database. Default is False. +try: + FORCE_LOWERCASE_TAGS = settings.FORCE_LOWERCASE_TAGS +except AttributeError: + FORCE_LOWERCASE_TAGS = False diff --git a/tagging/templatetags/__init__.py b/tagging/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tagging/templatetags/__init__.py diff --git a/tagging/templatetags/tagging_tags.py b/tagging/templatetags/tagging_tags.py new file mode 100644 index 0000000..fb4347f --- /dev/null +++ b/tagging/templatetags/tagging_tags.py @@ -0,0 +1,114 @@ +from django.db.models import get_model +from django.template import Library, Node, TemplateSyntaxError, resolve_variable +from tagging.models import Tag, TaggedItem + +register = Library() + +class TagsForModelNode(Node): + def __init__(self, model, context_var, counts): + self.model = model + self.context_var = context_var + self.counts = counts + + def render(self, context): + model = get_model(*self.model.split('.')) + context[self.context_var] = Tag.objects.usage_for_model(model, counts=self.counts) + return '' + +class TagsForObjectNode(Node): + def __init__(self, obj, context_var): + self.obj = obj + self.context_var = context_var + + def render(self, context): + obj = resolve_variable(self.obj, context) + context[self.context_var] = Tag.objects.get_for_object(obj) + return '' + +class TaggedObjectsNode(Node): + def __init__(self, tag, model, context_var): + self.tag = tag + self.context_var = context_var + self.model = model + + def render(self, context): + tag = resolve_variable(self.tag, context) + model = get_model(*self.model.split('.')) + context[self.context_var] = TaggedItem.objects.get_by_model(model, + tag) + return '' + +def do_tags_for_model(parser, token): + """ + Retrieves a list of ``Tag`` objects associated with a given model + and stores them in a context variable. + + The model is specified in ``[appname].[modelname]`` format. + + If specified - by providing extra ``with counts`` arguments - adds + a ``count`` attribute to each tag containing the number of + instances of the given model which have been tagged with it. + + Example usage:: + + {% tags_for_model products.Widget as widget_tags %} + + {% tags_for_model products.Widget as widget_tags with counts %} + """ + bits = token.contents.split() + len_bits = len(bits) + if len_bits not in (4, 6): + raise TemplateSyntaxError('%s tag requires either three or five arguments' % bits[0]) + if bits[2] != 'as': + raise TemplateSyntaxError("second argument to %s tag must be 'as'" % bits[0]) + if len_bits == 6: + if bits[4] != 'with': + raise TemplateSyntaxError("if given, fourth argument to %s tag must be 'with'" % bits[0]) + if bits[5] != 'counts': + raise TemplateSyntaxError("if given, fifth argument to %s tag must be 'counts'" % bits[0]) + if len_bits == 4: + return TagsForModelNode(bits[1], bits[3], counts=False) + else: + return TagsForModelNode(bits[1], bits[3], counts=True) + +def do_tags_for_object(parser, token): + """ + Retrieves a list of ``Tag`` objects associated with an object and + stores them in a context variable. + + Example usage:: + + {% tags_for_object foo_object as tag_list %} + """ + bits = token.contents.split() + if len(bits) != 4: + raise TemplateSyntaxError('%s tag requires exactly three arguments' % bits[0]) + if bits[2] != 'as': + raise TemplateSyntaxError("second argument to %s tag must be 'as'" % bits[0]) + return TagsForObjectNode(bits[1], bits[3]) + +def do_tagged_objects(parser, token): + """ + Retrieves a list of objects for a given Model which are tagged with + a given Tag and stores them in a context variable. + + The tag must be an instance of a ``Tag``, not the name of a tag. + + The model is specified in ``[appname].[modelname]`` format. + + Example usage:: + + {% tagged_objects foo_tag in tv.Model as object_list %} + """ + bits = token.contents.split() + if len(bits) != 6: + raise TemplateSyntaxError('%s tag requires exactly five arguments' % bits[0]) + if bits[2] != 'in': + raise TemplateSyntaxError("second argument to %s tag must be 'in'" % bits[0]) + if bits[4] != 'as': + raise TemplateSyntaxError("fourth argument to %s tag must be 'as'" % bits[0]) + return TaggedObjectsNode(bits[1], bits[3], bits[5]) + +register.tag('tags_for_model', do_tags_for_model) +register.tag('tags_for_object', do_tags_for_object) +register.tag('tagged_objects', do_tagged_objects)
\ No newline at end of file diff --git a/tagging/tests/__init__.py b/tagging/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tagging/tests/__init__.py diff --git a/tagging/tests/models.py b/tagging/tests/models.py new file mode 100644 index 0000000..86754cd --- /dev/null +++ b/tagging/tests/models.py @@ -0,0 +1,38 @@ +from django.db import models + +from tagging.fields import TagField + +class Perch(models.Model): + size = models.IntegerField() + smelly = models.BooleanField(default=True) + +class Parrot(models.Model): + state = models.CharField(max_length=50) + perch = models.ForeignKey(Perch, null=True) + + def __unicode__(self): + return self.state + + class Meta: + ordering = ['state'] + +class Link(models.Model): + name = models.CharField(max_length=50) + + def __unicode__(self): + return self.name + + class Meta: + ordering = ['name'] + +class Article(models.Model): + name = models.CharField(max_length=50) + + def __unicode__(self): + return self.name + + class Meta: + ordering = ['name'] + +class FormTest(models.Model): + tags = TagField() diff --git a/tagging/tests/runtests.py b/tagging/tests/runtests.py new file mode 100644 index 0000000..ab1d49c --- /dev/null +++ b/tagging/tests/runtests.py @@ -0,0 +1,8 @@ +import os, sys +os.environ['DJANGO_SETTINGS_MODULE'] = 'tagging.tests.settings' + +from django.test.simple import run_tests + +failures = run_tests(None, verbosity=9) +if failures: + sys.exit(failures) diff --git a/tagging/tests/settings.py b/tagging/tests/settings.py new file mode 100644 index 0000000..26e659b --- /dev/null +++ b/tagging/tests/settings.py @@ -0,0 +1,27 @@ +import os +DIRNAME = os.path.dirname(__file__) + +DEFAULT_CHARSET = 'utf-8' + +DATABASE_ENGINE = 'sqlite3' +DATABASE_NAME = os.path.join(DIRNAME, 'tagging_test.db') + +#DATABASE_ENGINE = 'mysql' +#DATABASE_NAME = 'tagging_test' +#DATABASE_USER = 'root' +#DATABASE_PASSWORD = '' +#DATABASE_HOST = 'localhost' +#DATABASE_PORT = '3306' + +#DATABASE_ENGINE = 'postgresql_psycopg2' +#DATABASE_NAME = 'tagging_test' +#DATABASE_USER = 'postgres' +#DATABASE_PASSWORD = '' +#DATABASE_HOST = 'localhost' +#DATABASE_PORT = '5432' + +INSTALLED_APPS = ( + 'django.contrib.contenttypes', + 'tagging', + 'tagging.tests', +) diff --git a/tagging/tests/tags.txt b/tagging/tests/tags.txt new file mode 100644 index 0000000..8543411 --- /dev/null +++ b/tagging/tests/tags.txt @@ -0,0 +1,122 @@ +NewMedia 53 +Website 45 +PR 44 +Status 44 +Collaboration 41 +Drupal 34 +Journalism 31 +Transparency 30 +Theory 29 +Decentralization 25 +EchoChamberProject 24 +OpenSource 23 +Film 22 +Blog 21 +Interview 21 +Political 21 +Worldview 21 +Communications 19 +Conference 19 +Folksonomy 15 +MediaCriticism 15 +Volunteer 15 +Dialogue 13 +InternationalLaw 13 +Rosen 12 +Evolution 11 +KentBye 11 +Objectivity 11 +Plante 11 +ToDo 11 +Advisor 10 +Civics 10 +Roadmap 10 +Wilber 9 +About 8 +CivicSpace 8 +Ecosystem 8 +Choice 7 +Murphy 7 +Sociology 7 +ACH 6 +del.icio.us 6 +IntelligenceAnalysis 6 +Science 6 +Credibility 5 +Distribution 5 +Diversity 5 +Errors 5 +FinalCutPro 5 +Fundraising 5 +Law 5 +PhilosophyofScience 5 +Podcast 5 +PoliticalBias 5 +Activism 4 +Analysis 4 +CBS 4 +DeceptionDetection 4 +Editing 4 +History 4 +RSS 4 +Social 4 +Subjectivity 4 +Vlog 4 +ABC 3 +ALTubes 3 +Economics 3 +FCC 3 +NYT 3 +Sirota 3 +Sundance 3 +Training 3 +Wiki 3 +XML 3 +Borger 2 +Brody 2 +Deliberation 2 +EcoVillage 2 +Identity 2 +LAMP 2 +Lobe 2 +Maine 2 +May 2 +MediaLogic 2 +Metaphor 2 +Mitchell 2 +NBC 2 +OHanlon 2 +Psychology 2 +Queen 2 +Software 2 +SpiralDynamics 2 +Strobel 2 +Sustainability 2 +Transcripts 2 +Brown 1 +Buddhism 1 +Community 1 +DigitalDivide 1 +Donnelly 1 +Education 1 +FairUse 1 +FireANT 1 +Google 1 +HumanRights 1 +KM 1 +Kwiatkowski 1 +Landay 1 +Loiseau 1 +Math 1 +Music 1 +Nature 1 +Schechter 1 +Screencast 1 +Sivaraksa 1 +Skype 1 +SocialCapital 1 +TagCloud 1 +Thielmann 1 +Thomas 1 +Tiger 1 +Wedgwood 1
\ No newline at end of file diff --git a/tagging/tests/tests.py b/tagging/tests/tests.py new file mode 100644 index 0000000..41a7895 --- /dev/null +++ b/tagging/tests/tests.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +r""" +>>> import os +>>> from django import newforms as forms +>>> from tagging import settings +>>> from tagging.models import Tag, TaggedItem +>>> from tagging.tests.models import Article, Link, Perch, Parrot, FormTest +>>> from tagging.utils import calculate_cloud, get_tag_name_list, get_tag_list, get_tag, LINEAR +>>> from tagging.validators import isTagList, isTag +>>> from tagging.forms import TagField + +############# +# Utilities # +############# + +# Tag input ################################################################### + +# Tag names +>>> get_tag_name_list(None) +[] +>>> get_tag_name_list('') +[] +>>> get_tag_name_list('foo') +[u'foo'] +>>> get_tag_name_list('foo bar') +[u'foo', u'bar'] +>>> get_tag_name_list('foo,bar') +[u'foo', u'bar'] +>>> get_tag_name_list(', , foo , bar , ,baz, , ,') +[u'foo', u'bar', u'baz'] +>>> get_tag_name_list('foo,ŠĐĆŽćžšđ') +[u'foo', u'\u0160\u0110\u0106\u017d\u0107\u017e\u0161\u0111'] + +# Normalised Tag list input +>>> cheese = Tag.objects.create(name='cheese') +>>> toast = Tag.objects.create(name='toast') +>>> get_tag_list(cheese) +[<Tag: cheese>] +>>> get_tag_list('cheese toast') +[<Tag: cheese>, <Tag: toast>] +>>> get_tag_list(u'cheese toast') +[<Tag: cheese>, <Tag: toast>] +>>> get_tag_list([]) +[] +>>> get_tag_list(['cheese', 'toast']) +[<Tag: cheese>, <Tag: toast>] +>>> get_tag_list([cheese.id, toast.id]) +[<Tag: cheese>, <Tag: toast>] +>>> get_tag_list(['cheese', 'toast', 'ŠĐĆŽćžšđ']) +[<Tag: cheese>, <Tag: toast>] +>>> get_tag_list([cheese, toast]) +[<Tag: cheese>, <Tag: toast>] +>>> get_tag_list((cheese, toast)) +(<Tag: cheese>, <Tag: toast>) +>>> get_tag_list(Tag.objects.filter(name__in=['cheese', 'toast'])) +[<Tag: cheese>, <Tag: toast>] +>>> get_tag_list(['cheese', toast]) +Traceback (most recent call last): + ... +ValueError: If a list or tuple of tags is provided, they must all be tag names, Tag objects or Tag ids. +>>> get_tag_list(29) +Traceback (most recent call last): + ... +ValueError: The tag input given was invalid. + +# Normalised Tag input +>>> get_tag(cheese) +<Tag: cheese> +>>> get_tag('cheese') +<Tag: cheese> +>>> get_tag(cheese.id) +<Tag: cheese> +>>> get_tag('mouse') + +# Tag clouds ################################################################## +>>> tags = [] +>>> for line in open(os.path.join(os.path.dirname(__file__), 'tags.txt')).readlines(): +... name, count = line.rstrip().split() +... tag = Tag(name=name) +... tag.count = int(count) +... tags.append(tag) + +>>> sizes = {} +>>> for tag in calculate_cloud(tags, steps=5): +... sizes[tag.font_size] = sizes.get(tag.font_size, 0) + 1 + +# This isn't a pre-calculated test, just making sure it's consistent +>>> sizes +{1: 48, 2: 20, 3: 24, 4: 19, 5: 11} + +>>> sizes = {} +>>> for tag in calculate_cloud(tags, steps=5, distribution=LINEAR): +... sizes[tag.font_size] = sizes.get(tag.font_size, 0) + 1 + +# This isn't a pre-calculated test, just making sure it's consistent +>>> sizes +{1: 97, 2: 12, 3: 7, 4: 2, 5: 4} + +>>> calculate_cloud(tags, steps=5, distribution='cheese') +Traceback (most recent call last): + ... +ValueError: Invalid font size distribution algorithm specified: cheese. + +############## +# Validators # +############## + +>>> isTagList('foo', {}) +>>> isTagList('foo bar baz', {}) +>>> isTagList('foo,bar,baz', {}) +>>> isTagList('foo, bar, baz', {}) +>>> isTagList('foo, ŠĐĆŽćžšđ, baz', {}) +>>> isTagList('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvb bar', {}) +>>> isTagList('', {}) +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.'] +>>> isTagList(' foo', {}) +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.'] +>>> isTagList('foo ', {}) +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.'] +>>> isTagList('foo bar', {}) +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.'] +>>> isTagList('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbn bar', {}) +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must be no longer than 50 characters.'] +>>> isTag('f-o_1o', {}) +>>> isTag('ŠĐĆŽćžšđ', {}) +>>> isTag('f o o', {}) +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens.'] + +############### +# Form Fields # +############### + +>>> t = TagField() +>>> t.clean('foo') +u'foo' +>>> t.clean('foo bar baz') +u'foo bar baz' +>>> t.clean('foo,bar,baz') +u'foo,bar,baz' +>>> t.clean('foo, bar, baz') +u'foo, bar, baz' +>>> t.clean('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvb bar') +u'foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvb bar' +>>> t.clean(' foo') +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.'] +>>> t.clean('foo ') +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.'] +>>> t.clean('foo bar') +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.'] +>>> t.clean('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbn bar') +Traceback (most recent call last): + ... +ValidationError: [u'Tag names must be no longer than 50 characters.'] + +# Ensure that automatically created forms use TagField +>>> TestForm = forms.form_for_model(FormTest) +>>> form = TestForm() +>>> form.fields['tags'].__class__.__name__ +'TagField' +>>> instance = FormTest(tags='one two three') +>>> TestInstanceForm = forms.form_for_instance(instance) +>>> form = TestInstanceForm() +>>> form.fields['tags'].__class__.__name__ +'TagField' + +########### +# Tagging # +########### + +# Basic tagging ############################################################### + +>>> dead = Parrot.objects.create(state='dead') +>>> Tag.objects.update_tags(dead, 'foo bar ter') +>>> Tag.objects.get_for_object(dead) +[<Tag: bar>, <Tag: foo>, <Tag: ter>] +>>> Tag.objects.update_tags(dead, 'foo bar baz') +>>> Tag.objects.get_for_object(dead) +[<Tag: bar>, <Tag: baz>, <Tag: foo>] +>>> Tag.objects.add_tag(dead, 'foo') +>>> Tag.objects.get_for_object(dead) +[<Tag: bar>, <Tag: baz>, <Tag: foo>] +>>> Tag.objects.add_tag(dead, 'zip') +>>> Tag.objects.get_for_object(dead) +[<Tag: bar>, <Tag: baz>, <Tag: foo>, <Tag: zip>] +>>> Tag.objects.add_tag(dead, 'f o o') +Traceback (most recent call last): + ... +AttributeError: An invalid tag name was given: f o o. Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens. + +# Note that doctest in Python 2.4 (and maybe 2.5?) doesn't support non-ascii +# characters in output, so we're displaying the repr() here. +>>> Tag.objects.update_tags(dead, 'ŠĐĆŽćžšđ') +>>> repr(Tag.objects.get_for_object(dead)) +'[<Tag: \xc5\xa0\xc4\x90\xc4\x86\xc5\xbd\xc4\x87\xc5\xbe\xc5\xa1\xc4\x91>]' + +>>> Tag.objects.update_tags(dead, None) +>>> Tag.objects.get_for_object(dead) +[] + +# Using a model's TagField +>>> f1 = FormTest.objects.create(tags=u'test3 test2 test1') +>>> Tag.objects.get_for_object(f1) +[<Tag: test1>, <Tag: test2>, <Tag: test3>] +>>> f1.tags = u'test4' +>>> f1.save() +>>> Tag.objects.get_for_object(f1) +[<Tag: test4>] +>>> f1.tags = '' +>>> f1.save() +>>> Tag.objects.get_for_object(f1) +[] + +# Forcing tags to lowercase +>>> settings.FORCE_LOWERCASE_TAGS = True +>>> Tag.objects.update_tags(dead, 'foO bAr Ter') +>>> Tag.objects.get_for_object(dead) +[<Tag: bar>, <Tag: foo>, <Tag: ter>] +>>> Tag.objects.update_tags(dead, 'foO bAr baZ') +>>> Tag.objects.get_for_object(dead) +[<Tag: bar>, <Tag: baz>, <Tag: foo>] +>>> Tag.objects.add_tag(dead, 'FOO') +>>> Tag.objects.get_for_object(dead) +[<Tag: bar>, <Tag: baz>, <Tag: foo>] +>>> Tag.objects.add_tag(dead, 'Zip') +>>> Tag.objects.get_for_object(dead) +[<Tag: bar>, <Tag: baz>, <Tag: foo>, <Tag: zip>] +>>> Tag.objects.update_tags(dead, None) +>>> f1.tags = u'TEST5' +>>> f1.save() +>>> Tag.objects.get_for_object(f1) +[<Tag: test5>] +>>> f1.tags +u'test5' + +# Retrieving tags by Model #################################################### + +>>> Tag.objects.usage_for_model(Parrot) +[] +>>> parrot_details = ( +... ('pining for the fjords', 9, True, 'foo bar'), +... ('passed on', 6, False, 'bar baz ter'), +... ('no more', 4, True, 'foo ter'), +... ('late', 2, False, 'bar ter'), +... ) + +>>> for state, perch_size, perch_smelly, tags in parrot_details: +... perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) +... parrot = Parrot.objects.create(state=state, perch=perch) +... Tag.objects.update_tags(parrot, tags) + +>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True)] +[(u'bar', 3), (u'baz', 1), (u'foo', 2), (u'ter', 3)] +>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, min_count=2)] +[(u'bar', 3), (u'foo', 2), (u'ter', 3)] + +# Limiting results to a subset of the model +>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(state='no more'))] +[(u'foo', 1), (u'ter', 1)] +>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(state__startswith='p'))] +[(u'bar', 2), (u'baz', 1), (u'foo', 1), (u'ter', 1)] +>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(perch__size__gt=4))] +[(u'bar', 2), (u'baz', 1), (u'foo', 1), (u'ter', 1)] +>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(perch__smelly=True))] +[(u'bar', 1), (u'foo', 2), (u'ter', 1)] +>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, min_count=2, filters=dict(perch__smelly=True))] +[(u'foo', 2)] +>>> [(tag.name, hasattr(tag, 'counts')) for tag in Tag.objects.usage_for_model(Parrot, filters=dict(perch__size__gt=4))] +[(u'bar', False), (u'baz', False), (u'foo', False), (u'ter', False)] +>>> [(tag.name, hasattr(tag, 'counts')) for tag in Tag.objects.usage_for_model(Parrot, filters=dict(perch__size__gt=99))] +[] + +# Related tags +>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar']), Parrot, counts=True)] +[(u'baz', 1), (u'foo', 1), (u'ter', 2)] +>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar']), Parrot, min_count=2)] +[(u'ter', 2)] +>>> [tag.name for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar']), Parrot, counts=False)] +[u'baz', u'foo', u'ter'] +>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar', 'ter']), Parrot, counts=True)] +[(u'baz', 1)] +>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar', 'ter', 'baz']), Parrot, counts=True)] +[] + +# Once again, with feeling (strings) +>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model('bar', Parrot, counts=True)] +[(u'baz', 1), (u'foo', 1), (u'ter', 2)] +>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model('bar', Parrot, min_count=2)] +[(u'ter', 2)] +>>> [tag.name for tag in Tag.objects.related_for_model('bar', Parrot, counts=False)] +[u'baz', u'foo', u'ter'] +>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(['bar', 'ter'], Parrot, counts=True)] +[(u'baz', 1)] +>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(['bar', 'ter', 'baz'], Parrot, counts=True)] +[] + +# Retrieving tagged objects by Model ########################################## + +>>> foo = Tag.objects.get(name='foo') +>>> bar = Tag.objects.get(name='bar') +>>> baz = Tag.objects.get(name='baz') +>>> ter = Tag.objects.get(name='ter') +>>> TaggedItem.objects.get_by_model(Parrot, foo) +[<Parrot: no more>, <Parrot: pining for the fjords>] +>>> TaggedItem.objects.get_by_model(Parrot, bar) +[<Parrot: late>, <Parrot: passed on>, <Parrot: pining for the fjords>] + +# Intersections are supported +>>> TaggedItem.objects.get_by_model(Parrot, [foo, baz]) +[] +>>> TaggedItem.objects.get_by_model(Parrot, [foo, bar]) +[<Parrot: pining for the fjords>] +>>> TaggedItem.objects.get_by_model(Parrot, [bar, ter]) +[<Parrot: late>, <Parrot: passed on>] + +# You can also pass Tag QuerySets +>>> TaggedItem.objects.get_by_model(Parrot, Tag.objects.filter(name__in=['foo', 'baz'])) +[] +>>> TaggedItem.objects.get_by_model(Parrot, Tag.objects.filter(name__in=['foo', 'bar'])) +[<Parrot: pining for the fjords>] +>>> TaggedItem.objects.get_by_model(Parrot, Tag.objects.filter(name__in=['bar', 'ter'])) +[<Parrot: late>, <Parrot: passed on>] + +# You can also pass strings and lists of strings +>>> TaggedItem.objects.get_by_model(Parrot, 'foo baz') +[] +>>> TaggedItem.objects.get_by_model(Parrot, 'foo bar') +[<Parrot: pining for the fjords>] +>>> TaggedItem.objects.get_by_model(Parrot, 'bar ter') +[<Parrot: late>, <Parrot: passed on>] +>>> TaggedItem.objects.get_by_model(Parrot, ['foo', 'baz']) +[] +>>> TaggedItem.objects.get_by_model(Parrot, ['foo', 'bar']) +[<Parrot: pining for the fjords>] +>>> TaggedItem.objects.get_by_model(Parrot, ['bar', 'ter']) +[<Parrot: late>, <Parrot: passed on>] + +# Issue 50 - Get by non-existent tag +>>> TaggedItem.objects.get_by_model(Parrot, 'argatrons') +[] + +# Retrieving related objects by Model ######################################### + +# Related instances of the same Model +>>> l1 = Link.objects.create(name='link 1') +>>> Tag.objects.update_tags(l1, 'tag1 tag2 tag3 tag4 tag5') +>>> l2 = Link.objects.create(name='link 2') +>>> Tag.objects.update_tags(l2, 'tag1 tag2 tag3') +>>> l3 = Link.objects.create(name='link 3') +>>> Tag.objects.update_tags(l3, 'tag1') +>>> l4 = Link.objects.create(name='link 4') +>>> TaggedItem.objects.get_related(l1, Link) +[<Link: link 2>, <Link: link 3>] +>>> TaggedItem.objects.get_related(l1, Link, num=1) +[<Link: link 2>] +>>> TaggedItem.objects.get_related(l4, Link) +[] + +# Related instance of a different Model +>>> a1 = Article.objects.create(name='article 1') +>>> Tag.objects.update_tags(a1, 'tag1 tag2 tag3 tag4') +>>> TaggedItem.objects.get_related(a1, Link) +[<Link: link 1>, <Link: link 2>, <Link: link 3>] +>>> Tag.objects.update_tags(a1, 'tag6') +>>> TaggedItem.objects.get_related(a1, Link) +[] +""" diff --git a/tagging/utils.py b/tagging/utils.py new file mode 100644 index 0000000..af50bac --- /dev/null +++ b/tagging/utils.py @@ -0,0 +1,140 @@ +import math +import re +import types + +from django.db.models.query import QuerySet +from django.utils.encoding import force_unicode, smart_unicode + +# Python 2.3 compatibility +if not hasattr(__builtins__, 'set'): + from sets import Set as set + +find_tag_re = re.compile(r'[-\w]+', re.U) + +def get_tag_name_list(tag_names): + """ + Finds tag names in the given string and return them as a list. + """ + if tag_names is not None: + tag_names = force_unicode(tag_names) + results = find_tag_re.findall(tag_names or '') + return results + +def get_tag_list(tags): + """ + Utility function for accepting tag input in a flexible manner. + + If a ``Tag`` object is given, it will be returned in a list as + its single occupant. + + If given, the tag names in the following will be used to create a + ``Tag`` ``QuerySet``: + + * A string, which may contain multiple tag names. + * A list or tuple of strings corresponding to tag names. + * A list or tuple of integers corresponding to tag ids. + + If given, the following will be returned as-is: + + * A list or tuple of ``Tag`` objects. + * A ``Tag`` ``QuerySet``. + """ + from tagging.models import Tag + if isinstance(tags, Tag): + return [tags] + elif isinstance(tags, QuerySet) and tags.model is Tag: + return tags + elif isinstance(tags, types.StringTypes): + return Tag.objects.filter(name__in=get_tag_name_list(tags)) + elif isinstance(tags, (types.ListType, types.TupleType)): + if len(tags) == 0: + return tags + contents = set() + for item in tags: + if isinstance(item, types.StringTypes): + contents.add('string') + elif isinstance(item, Tag): + contents.add('tag') + elif isinstance(item, (types.IntType, types.LongType)): + contents.add('int') + if len(contents) == 1: + if 'string' in contents: + return Tag.objects.filter(name__in=[smart_unicode(tag) \ + for tag in tags]) + elif 'tag' in contents: + return tags + elif 'int' in contents: + return Tag.objects.filter(id__in=tags) + else: + raise ValueError(u'If a list or tuple of tags is provided, they must all be tag names, Tag objects or Tag ids.') + else: + raise ValueError(u'The tag input given was invalid.') + +def get_tag(tag): + """ + Utility function for accepting single tag input in a flexible + manner. + + If a ``Tag`` object is given it will be returned as-is; if a + string or integer are given, they will be used to lookup the + appropriate ``Tag``. + + If no matching tag can be found, ``None`` will be returned. + """ + from tagging.models import Tag + if isinstance(tag, Tag): + return tag + + try: + if isinstance(tag, types.StringTypes): + return Tag.objects.get(name=tag) + elif isinstance(tag, (types.IntType, types.LongType)): + return Tag.objects.get(id=tag) + except Tag.DoesNotExist: + pass + + return None + +# Font size distribution algorithms +LOGARITHMIC, LINEAR = 1, 2 + +def calculate_cloud(tags, steps=4, distribution=LOGARITHMIC): + """ + Add a ``font_size`` attribute to each tag according to the + frequency of its use, as indicated by its ``count`` + attribute. + + ``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``. + + The algorithm to scale the tags logarithmically is from a + blog post by Anders Pearson, 'Scaling tag clouds': + http://thraxil.com/users/anders/posts/2005/12/13/scaling-tag-clouds/ + """ + if len(tags) > 0: + thresholds = [] + counts = [tag.count for tag in tags] + max_weight = float(max(counts)) + min_weight = float(min(counts)) + + # Set up the appropriate thresholds + if distribution == LOGARITHMIC: + thresholds = [math.pow(max_weight - min_weight + 1, float(i) / float(steps)) \ + for i in range(1, steps + 1)] + elif distribution == LINEAR: + delta = (max_weight - min_weight) / float(steps) + thresholds = [min_weight + i * delta for i in range(1, steps + 1)] + else: + raise ValueError(u'Invalid font size distribution algorithm specified: %s.' % distribution) + + for tag in tags: + font_set = False + for i in range(steps): + if not font_set and tag.count <= thresholds[i]: + tag.font_size = i + 1 + font_set = True + return tags diff --git a/tagging/validators.py b/tagging/validators.py new file mode 100644 index 0000000..363fa6d --- /dev/null +++ b/tagging/validators.py @@ -0,0 +1,33 @@ +import re + +from django.core.validators import ValidationError +from django.utils.encoding import smart_unicode + +from tagging.utils import get_tag_name_list + +tag_re = re.compile(r'^[-\w]+$', re.U) +tag_list_re = re.compile(r'^[-\w]+(?:(?:,\s|[,\s])[-\w]+)*$', re.U) + +def isTagList(field_data, all_data): + """ + Validates that ``field_data`` is a valid list of tag names, + separated by a single comma, a single space or a comma followed + by a space. + """ + if field_data is not None: + field_data = smart_unicode(field_data) + if not tag_list_re.search(field_data): + raise ValidationError(u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.') + tag_names = get_tag_name_list(field_data) + for tag_name in tag_names: + if len(tag_name) > 50: + raise ValidationError(u'Tag names must be no longer than 50 characters.') + +def isTag(field_data, all_data): + """ + Validates that ``field_data`` is a valid tag name. + """ + if field_data is not None: + field_data = smart_unicode(field_data) + if not tag_re.match(field_data): + raise ValidationError(u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens.') diff --git a/tagging/views.py b/tagging/views.py new file mode 100644 index 0000000..4e424f1 --- /dev/null +++ b/tagging/views.py @@ -0,0 +1,48 @@ +from django.http import Http404 +from django.views.generic.list_detail import object_list + +from tagging.models import Tag, TaggedItem +from tagging.utils import get_tag + +def tagged_object_list(request, model=None, tag=None, related_tags=False, + related_tag_counts=True, **kwargs): + """ + A thin wrapper around + ``django.views.generic.list_detail.object_list`` which creates a + ``QuerySet`` containing instances of the given model tagged with + the given tag. + + In addition to the context variables set up by ``object_list``, a + ``tag`` context variable will contain the ``Tag`` instance + for the given tag. + + If ``related_tags`` is ``True``, a ``related_tags`` context variable + will contain tags related to the given tag for the given model. + Additionally, if ``related_tag_counts`` is ``True``, each related tag + will have a ``count`` attribute indicating the number of items which + have it in addition to the given tag. + """ + if model is None: + try: + model = kwargs['model'] + except KeyError: + raise AttributeError(u'tagged_object_list must be called with a model.') + + if tag is None: + try: + tag = kwargs['tag'] + except KeyError: + raise AttributeError(u'tagged_object_list must be called with a tag.') + + tag_instance = get_tag(tag) + if tag_instance is None: + raise Http404(u'No Tag found matching "%s".' % tag) + queryset = TaggedItem.objects.get_by_model(model, tag_instance) + if not kwargs.has_key('extra_context'): + kwargs['extra_context'] = {} + kwargs['extra_context']['tag'] = tag_instance + if related_tags: + kwargs['extra_context']['related_tags'] = \ + Tag.objects.related_for_model(tag_instance, model, + counts=related_tag_counts) + return object_list(request, queryset, **kwargs) |