diff options
Diffstat (limited to 'tagging')
-rw-r--r-- | tagging/__init__.py | 2 | ||||
-rw-r--r-- | tagging/fields.py | 219 | ||||
-rw-r--r-- | tagging/forms.py | 45 | ||||
-rw-r--r-- | tagging/managers.py | 774 | ||||
-rw-r--r-- | tagging/models.py | 105 | ||||
-rw-r--r-- | tagging/settings.py | 26 | ||||
-rw-r--r-- | tagging/templatetags/tagging_tags.py | 345 | ||||
-rw-r--r-- | tagging/tests/models.py | 76 | ||||
-rw-r--r-- | tagging/tests/runtests.py | 16 | ||||
-rw-r--r-- | tagging/tests/settings.py | 54 | ||||
-rw-r--r-- | tagging/tests/tags.txt | 242 | ||||
-rw-r--r-- | tagging/tests/tests.py | 818 | ||||
-rw-r--r-- | tagging/utils.py | 388 | ||||
-rw-r--r-- | tagging/validators.py | 63 | ||||
-rw-r--r-- | tagging/views.py | 100 |
15 files changed, 1796 insertions, 1477 deletions
diff --git a/tagging/__init__.py b/tagging/__init__.py index 171b9e0..28ed501 100644 --- a/tagging/__init__.py +++ b/tagging/__init__.py @@ -1 +1 @@ -VERSION = (0, 2, 'pre') +VERSION = (0, 2.1, None)
diff --git a/tagging/fields.py b/tagging/fields.py index 1e66458..c9475f6 100644 --- a/tagging/fields.py +++ b/tagging/fields.py @@ -1,109 +1,110 @@ -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]) +"""
+A custom Model Field for tagging.
+"""
+from django.db.models import signals
+from django.db.models.fields import CharField
+from django.dispatch import dispatcher
+from django.utils.translation import ugettext_lazy as _
+
+from tagging import settings
+from tagging.models import Tag
+from tagging.utils import edit_string_for_tags
+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 edit_string_for_tags(Tag.objects.usage_for_model(owner))
+
+ tags = self._get_instance_tag_cache(instance)
+ if tags is None:
+ if instance.pk is None:
+ self._set_instance_tag_cache(instance, '')
+ else:
+ self._set_instance_tag_cache(
+ instance, edit_string_for_tags(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:
+ value = value.lower()
+ 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)
diff --git a/tagging/forms.py b/tagging/forms.py index 875c598..6844039 100644 --- a/tagging/forms.py +++ b/tagging/forms.py @@ -1,22 +1,23 @@ -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 +"""
+Tagging components for Django's ``newforms`` form library.
+"""
+from django import newforms as forms
+from django.utils.translation import ugettext as _
+
+from tagging import settings
+from tagging.utils import parse_tag_input
+
+class TagField(forms.CharField):
+ """
+ A ``CharField`` which validates that its input is a valid list of
+ tag names.
+ """
+ def clean(self, value):
+ value = super(TagField, self).clean(value)
+ if value == u'':
+ return value
+ for tag_name in parse_tag_input(value):
+ if len(tag_name) > settings.MAX_TAG_LENGTH:
+ raise forms.ValidationError(
+ _('Each tag may be no more than %s characters long.') % settings.MAX_TAG_LENGTH)
+ return value
diff --git a/tagging/managers.py b/tagging/managers.py index 5e47605..612c127 100644 --- a/tagging/managers.py +++ b/tagging/managers.py @@ -1,366 +1,408 @@ -""" -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() +"""
+Custom managers for generic tagging models.
+"""
+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 django.utils.translation import ugettext as _
+
+from tagging import settings
+from tagging.utils import calculate_cloud, get_tag_list, parse_tag_input
+from tagging.utils import LOGARITHMIC
+
+# 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.pk,
+ items__object_id=obj.pk))
+ updated_tag_names = parse_tag_input(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):
+ TaggedItemModel._default_manager.filter(content_type__pk=ctype.pk,
+ object_id=obj.pk,
+ 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.
+ """
+ tag_names = parse_tag_input(tag_name)
+ if not len(tag_names):
+ raise AttributeError(_('No tags were given: "%s".') % tag_name)
+ if len(tag_names) > 1:
+ raise AttributeError(_('Multiple tags were given: "%s".') % tag_name)
+ tag_name = tag_names[0]
+ 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.pk)
+
+ 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.pk,
+ items__object_id=obj.pk)
+
+ 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 class.
+
+ If ``counts`` is True, a ``count`` attribute will be added to
+ each tag, indicating how many times it has been used against
+ the Model class 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).pk,
+ }
+
+ 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).pk,
+ '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.pk 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, distribution)
+
+ 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:
+ # No existing tags were given
+ return model._default_manager.none()
+ elif tag_count == 1:
+ # Optimisation for single tag - fall through to the simpler
+ # query below.
+ tag = tags[0]
+ else:
+ return self.get_intersection_by_model(model, tags)
+
+ ctype = ContentType.objects.get_for_model(model)
+ opts = self.model._meta
+ tagged_item_table = qn(opts.db_table)
+ return model._default_manager.extra(
+ tables=[opts.db_table],
+ where=[
+ '%s.content_type_id = %%s' % tagged_item_table,
+ '%s.tag_id = %%s' % tagged_item_table,
+ '%s.%s = %s.object_id' % (qn(model._meta.db_table),
+ qn(model._meta.pk.column),
+ tagged_item_table)
+ ],
+ params=[ctype.pk, tag.pk],
+ )
+
+ 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).pk,
+ 'tag_id_placeholders': ','.join(['%s'] * tag_count),
+ 'tag_count': tag_count,
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [tag.pk 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_union_by_model(self, model, tags):
+ """
+ Create a queryset matching instances of the given Model
+ associated with any of the given list of Tags.
+ """
+ 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 any of
+ # 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""" % {
+ '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).pk,
+ 'tag_id_placeholders': ','.join(['%s'] * tag_count),
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [tag.pk 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.pk == related_content_type.pk:
+ # 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.pk,
+ 'related_content_type_id': related_content_type.pk,
+ 'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '',
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [obj.pk])
+ 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 index 199e5eb..f81c896 100644 --- a/tagging/models.py +++ b/tagging/models.py @@ -1,53 +1,52 @@ -""" -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) +"""
+Models for generic tagging.
+"""
+from django.db import models
+from django.contrib.contenttypes import generic
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import ugettext_lazy as _
+
+from tagging.managers import TagManager, TaggedItemManager
+from tagging.validators import isTag
+
+class Tag(models.Model):
+ """
+ A tag.
+ """
+ name = models.CharField(_('name'), max_length=50, unique=True, db_index=True, validator_list=[isTag])
+
+ objects = TagManager()
+
+ class Meta:
+ ordering = ('name',)
+ verbose_name = _('tag')
+ verbose_name_plural = _('tags')
+
+ 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, verbose_name=_('tag'), related_name='items')
+ content_type = models.ForeignKey(ContentType, verbose_name=_('content type'))
+ object_id = models.PositiveIntegerField(_('object id'), db_index=True)
+ object = generic.GenericForeignKey('content_type', 'object_id')
+
+ objects = TaggedItemManager()
+
+ class Meta:
+ # Enforce unique tag association per object
+ unique_together = (('tag', 'content_type', 'object_id'),)
+ verbose_name = _('tagged item')
+ verbose_name_plural = _('tagged items')
+
+ class Admin:
+ pass
+
+ def __unicode__(self):
+ return u'%s [%s]' % (self.object, self.tag)
diff --git a/tagging/settings.py b/tagging/settings.py index 18321bd..78da3af 100644 --- a/tagging/settings.py +++ b/tagging/settings.py @@ -1,13 +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 +"""
+Convenience module for access of custom tagging application settings,
+which enforces default settings when the main settings module does not
+contain the appropriate settings.
+"""
+from django.conf import settings
+
+# The maximum length of a tag's name.
+MAX_TAG_LENGTH = getattr(settings, 'MAX_TAG_LENGTH', 50)
+
+# Whether to force all tags to lowercase before they are saved to the
+# database.
+FORCE_LOWERCASE_TAGS = getattr(settings, 'FORCE_LOWERCASE_TAGS', False)
diff --git a/tagging/templatetags/tagging_tags.py b/tagging/templatetags/tagging_tags.py index fb4347f..789517a 100644 --- a/tagging/templatetags/tagging_tags.py +++ b/tagging/templatetags/tagging_tags.py @@ -1,114 +1,231 @@ -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 +from django.db.models import get_model
+from django.template import Library, Node, TemplateSyntaxError, Variable, resolve_variable
+from django.utils.translation import ugettext as _
+
+from tagging.models import Tag, TaggedItem
+from tagging.utils import LINEAR, LOGARITHMIC
+
+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('.'))
+ if model is None:
+ raise TemplateSyntaxError(_('tags_for_model tag was given an invalid model: %s') % self.model)
+ context[self.context_var] = Tag.objects.usage_for_model(model, counts=self.counts)
+ return ''
+
+class TagCloudForModelNode(Node):
+ def __init__(self, model, context_var, **kwargs):
+ self.model = model
+ self.context_var = context_var
+ self.kwargs = kwargs
+
+ def render(self, context):
+ model = get_model(*self.model.split('.'))
+ if model is None:
+ raise TemplateSyntaxError(_('tag_cloud_for_model tag was given an invalid model: %s') % self.model)
+ context[self.context_var] = \
+ Tag.objects.cloud_for_model(model, **self.kwargs)
+ return ''
+
+class TagsForObjectNode(Node):
+ def __init__(self, obj, context_var):
+ self.obj = Variable(obj)
+ self.context_var = context_var
+
+ def render(self, context):
+ context[self.context_var] = \
+ Tag.objects.get_for_object(self.obj.resolve(context))
+ return ''
+
+class TaggedObjectsNode(Node):
+ def __init__(self, tag, model, context_var):
+ self.tag = Variable(tag)
+ self.context_var = context_var
+ self.model = model
+
+ def render(self, context):
+ model = get_model(*self.model.split('.'))
+ if model is None:
+ raise TemplateSyntaxError(_('tagged_objects tag was given an invalid model: %s') % self.model)
+ context[self.context_var] = \
+ TaggedItem.objects.get_by_model(model, self.tag.resolve(context))
+ 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.
+
+ Usage::
+
+ {% tags_for_model [model] as [varname] %}
+
+ The model is specified in ``[appname].[modelname]`` format.
+
+ Extended usage::
+
+ {% tags_for_model [model] as [varname] with counts %}
+
+ 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.
+
+ Examples::
+
+ {% 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_tag_cloud_for_model(parser, token):
+ """
+ Retrieves a list of ``Tag`` objects for a given model, with tag
+ cloud attributes set, and stores them in a context variable.
+
+ Usage::
+
+ {% tag_cloud_for_model [model] as [varname] %}
+
+ The model is specified in ``[appname].[modelname]`` format.
+
+ Extended usage::
+
+ {% tag_cloud_for_model [model] as [varname] with [options] %}
+
+ Extra options can be provided after an optional ``with`` argument,
+ with each option being specified in ``[name]=[value]`` format. Valid
+ extra options are:
+
+ ``steps``
+ Integer. Defines the range of font sizes.
+
+ ``min_count``
+ Integer. Defines the minimum number of times a tag must have
+ been used to appear in the cloud.
+
+ ``distribution``
+ One of ``linear`` or ``log``. Defines the font-size
+ distribution algorithm to use when generating the tag cloud.
+
+ Examples::
+
+ {% tag_cloud_for_model products.Widget as widget_tags %}
+ {% tag_cloud_for_model products.Widget as widget_tags with steps=9 min_count=3 distribution=log %}
+
+ """
+ bits = token.contents.split()
+ len_bits = len(bits)
+ if len_bits != 4 and len_bits not in range(6, 9):
+ raise TemplateSyntaxError(_('%s tag requires either three or between five and seven arguments') % bits[0])
+ if bits[2] != 'as':
+ raise TemplateSyntaxError(_("second argument to %s tag must be 'as'") % bits[0])
+ kwargs = {}
+ if len_bits > 5:
+ if bits[4] != 'with':
+ raise TemplateSyntaxError(_("if given, fourth argument to %s tag must be 'with'") % bits[0])
+ for i in range(5, len_bits):
+ try:
+ name, value = bits[i].split('=')
+ if name == 'steps' or name == 'min_count':
+ try:
+ kwargs[str(name)] = int(value)
+ except ValueError:
+ raise TemplateSyntaxError(_("%(tag)s tag's '%(option)s' option was not a valid integer: '%(value)s'") % {
+ 'tag': bits[0],
+ 'option': name,
+ 'value': value,
+ })
+ elif name == 'distribution':
+ if value in ['linear', 'log']:
+ kwargs[str(name)] = {'linear': LINEAR, 'log': LOGARITHMIC}[value]
+ else:
+ raise TemplateSyntaxError(_("%(tag)s tag's '%(option)s' option was not a valid choice: '%(value)s'") % {
+ 'tag': bits[0],
+ 'option': name,
+ 'value': value,
+ })
+ else:
+ raise TemplateSyntaxError(_("%(tag)s tag was given an invalid option: '%(option)s'") % {
+ 'tag': bits[0],
+ 'option': name,
+ })
+ except ValueError:
+ raise TemplateSyntaxError(_("%(tag)s tag was given a badly formatted option: '%(option)s'") % {
+ 'tag': bits[0],
+ 'option': bits[i],
+ })
+ return TagCloudForModelNode(bits[1], bits[3], **kwargs)
+
+def do_tags_for_object(parser, token):
+ """
+ Retrieves a list of ``Tag`` objects associated with an object and
+ stores them in a context variable.
+
+ Usage::
+
+ {% tags_for_object [object] as [varname] %}
+
+ Example::
+
+ {% 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 instances of a given model which are tagged with
+ a given ``Tag`` and stores them in a context variable.
+
+ Usage::
+
+ {% tagged_objects [tag] in [model] as [varname] %}
+
+ The model is specified in ``[appname].[modelname]`` format.
+
+ The tag must be an instance of a ``Tag``, not the name of a tag.
+
+ Example::
+
+ {% tagged_objects comedy_tag in tv.Show as comedies %}
+
+ """
+ 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('tag_cloud_for_model', do_tag_cloud_for_model)
+register.tag('tags_for_object', do_tags_for_object)
+register.tag('tagged_objects', do_tagged_objects)
diff --git a/tagging/tests/models.py b/tagging/tests/models.py index 86754cd..69a5c7b 100644 --- a/tagging/tests/models.py +++ b/tagging/tests/models.py @@ -1,38 +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() +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 index ab1d49c..e66ee86 100644 --- a/tagging/tests/runtests.py +++ b/tagging/tests/runtests.py @@ -1,8 +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) +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 index 26e659b..93ba4b0 100644 --- a/tagging/tests/settings.py +++ b/tagging/tests/settings.py @@ -1,27 +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', -) +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 index 8543411..61bfd9e 100644 --- a/tagging/tests/tags.txt +++ b/tagging/tests/tags.txt @@ -1,122 +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 +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 index 41a7895..b1c8032 100644 --- a/tagging/tests/tests.py +++ b/tagging/tests/tests.py @@ -1,384 +1,434 @@ -# -*- 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) -[] -""" +# -*- coding: utf-8 -*-
+r"""
+>>> import os
+>>> from django import newforms as forms
+>>> from tagging.forms import TagField
+>>> 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_list, get_tag, parse_tag_input
+>>> from tagging.utils import LINEAR
+>>> from tagging.validators import isTagList, isTag
+
+#############
+# Utilities #
+#############
+
+# Tag input ###################################################################
+
+# Simple space-delimited tags
+>>> parse_tag_input('one')
+[u'one']
+>>> parse_tag_input('one two')
+[u'one', u'two']
+>>> parse_tag_input('one two three')
+[u'one', u'three', u'two']
+>>> parse_tag_input('one one two two')
+[u'one', u'two']
+
+# Comma-delimited multiple words - an unquoted comma in the input will trigger
+# this.
+>>> parse_tag_input(',one')
+[u'one']
+>>> parse_tag_input(',one two')
+[u'one two']
+>>> parse_tag_input(',one two three')
+[u'one two three']
+>>> parse_tag_input('a-one, a-two and a-three')
+[u'a-one', u'a-two and a-three']
+
+# Double-quoted multiple words - a completed quote will trigger this.
+# Unclosed quotes are ignored.
+>>> parse_tag_input('"one')
+[u'one']
+>>> parse_tag_input('"one two')
+[u'one', u'two']
+>>> parse_tag_input('"one two three')
+[u'one', u'three', u'two']
+>>> parse_tag_input('"one two"')
+[u'one two']
+>>> parse_tag_input('a-one "a-two and a-three"')
+[u'a-one', u'a-two and a-three']
+
+# No loose commas - split on spaces
+>>> parse_tag_input('one two "thr,ee"')
+[u'one', u'thr,ee', u'two']
+
+# Loose commas - split on commas
+>>> parse_tag_input('"one", two three')
+[u'one', u'two three']
+
+# Double quotes can contain commas
+>>> parse_tag_input('a-one "a-two, and a-three"')
+[u'a-one', u'a-two, and a-three']
+>>> parse_tag_input('"two", one, one, two, "one"')
+[u'one', u'two']
+
+# Bad users! Naughty users!
+>>> parse_tag_input(None)
+[]
+>>> parse_tag_input('')
+[]
+>>> parse_tag_input('"')
+[]
+>>> parse_tag_input('""')
+[]
+>>> parse_tag_input('"' * 7)
+[]
+>>> parse_tag_input(',,,,,,')
+[]
+>>> parse_tag_input('",",",",",",","')
+[u',']
+>>> parse_tag_input('a-one "a-two" and "a-three')
+[u'a-one', u'a-three', u'a-two', u'and']
+
+# 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('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: 30, 3: 19, 4: 15, 5: 10}
+
+>>> 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 distribution algorithm specified: cheese.
+
+# Validators ##################################################################
+
+>>> isTagList('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbn bar', {})
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Each tag may be no more than 50 characters long.']
+
+>>> isTag('"test"', {})
+>>> isTag(',test', {})
+>>> isTag('f o o', {})
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Multiple tags were given.']
+>>> isTagList('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbn bar', {})
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Each tag may be no more than 50 characters long.']
+
+###########
+# 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, ' ')
+Traceback (most recent call last):
+ ...
+AttributeError: No tags were given: " ".
+>>> Tag.objects.add_tag(dead, 'one two')
+Traceback (most recent call last):
+ ...
+AttributeError: Multiple tags were given: "one two".
+
+# 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')
+[]
+
+# Unions
+>>> TaggedItem.objects.get_union_by_model(Parrot, ['foo', 'ter'])
+[<Parrot: late>, <Parrot: no more>, <Parrot: passed on>, <Parrot: pining for the fjords>]
+>>> TaggedItem.objects.get_union_by_model(Parrot, ['bar', 'baz'])
+[<Parrot: late>, <Parrot: passed on>, <Parrot: pining for the fjords>]
+
+# 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)
+[]
+
+################
+# Model Fields #
+################
+
+# TagField ####################################################################
+
+# Ensure that automatically created forms use TagField
+>>> class TestForm(forms.ModelForm):
+... class Meta:
+... model = FormTest
+>>> form = TestForm()
+>>> form.fields['tags'].__class__.__name__
+'TagField'
+
+# Recreating string representaions of tag lists ###############################
+>>> plain = Tag.objects.create(name='plain')
+>>> spaces = Tag.objects.create(name='spa ces')
+>>> comma = Tag.objects.create(name='com,ma')
+
+>>> from tagging.utils import edit_string_for_tags
+>>> edit_string_for_tags([plain])
+u'plain'
+>>> edit_string_for_tags([plain, spaces])
+u'plain, spa ces'
+>>> edit_string_for_tags([plain, spaces, comma])
+u'plain, spa ces, "com,ma"'
+>>> edit_string_for_tags([plain, comma])
+u'plain "com,ma"'
+>>> edit_string_for_tags([comma, spaces])
+u'"com,ma", spa ces'
+
+###############
+# 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 qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbn bar')
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Each tag may be no more than 50 characters long.']
+"""
diff --git a/tagging/utils.py b/tagging/utils.py index af50bac..9bfd07f 100644 --- a/tagging/utils.py +++ b/tagging/utils.py @@ -1,140 +1,248 @@ -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 +"""
+Tagging utilities - from user tag input parsing to tag cloud
+calculation.
+"""
+import math
+import types
+
+from django.db.models.query import QuerySet
+from django.utils.encoding import force_unicode
+from django.utils.translation import ugettext as _
+
+# Python 2.3 compatibility
+if not hasattr(__builtins__, 'set'):
+ from sets import Set as set
+
+def parse_tag_input(input):
+ """
+ Parses tag input, with multiple word input being activated and
+ delineated by commas and double quotes. Quotes take precedence, so
+ they may contain commas.
+
+ Returns a sorted list of unique tag names.
+ """
+ if not input:
+ return []
+
+ input = force_unicode(input)
+
+ # Special case - if there are no commas or double quotes in the
+ # input, we don't *do* a recall... I mean, we know we only need to
+ # split on spaces.
+ if u',' not in input and u'"' not in input:
+ words = list(set(split_strip(input, u' ')))
+ words.sort()
+ return words
+
+ words = []
+ buffer = []
+ # Defer splitting of non-quoted sections until we know if there are
+ # any unquoted commas.
+ to_be_split = []
+ saw_loose_comma = False
+ open_quote = False
+ i = iter(input)
+ try:
+ while 1:
+ c = i.next()
+ if c == u'"':
+ if buffer:
+ to_be_split.append(u''.join(buffer))
+ buffer = []
+ # Find the matching quote
+ open_quote = True
+ c = i.next()
+ while c != u'"':
+ buffer.append(c)
+ c = i.next()
+ if buffer:
+ word = u''.join(buffer).strip()
+ if word:
+ words.append(word)
+ buffer = []
+ open_quote = False
+ else:
+ if not saw_loose_comma and c == u',':
+ saw_loose_comma = True
+ buffer.append(c)
+ except StopIteration:
+ # If we were parsing an open quote which was never closed treat
+ # the buffer as unquoted.
+ if buffer:
+ if open_quote and u',' in buffer:
+ saw_loose_comma = True
+ to_be_split.append(u''.join(buffer))
+ if to_be_split:
+ if saw_loose_comma:
+ delimiter = u','
+ else:
+ delimiter = u' '
+ for chunk in to_be_split:
+ words.extend(split_strip(chunk, delimiter))
+ words = list(set(words))
+ words.sort()
+ return words
+
+def split_strip(input, delimiter=u','):
+ """
+ Splits ``input`` on ``delimiter``, stripping each resulting string
+ and returning a list of non-empty strings.
+ """
+ if not input:
+ return []
+
+ words = [w.strip() for w in input.split(delimiter)]
+ return [w for w in words if w]
+
+def edit_string_for_tags(tags):
+ """
+ Given list of ``Tag`` instances, creates a string representation of
+ the list suitable for editing by the user, such that submitting the
+ given string representation back without changing it will give the
+ same list of tags.
+
+ Tag names which contain commas will be double quoted.
+
+ If any tag name which isn't being quoted contains whitespace, the
+ resulting string of tag names will be comma-delimited, otherwise
+ it will be space-delimited.
+ """
+ names = []
+ use_commas = False
+ for tag in tags:
+ name = tag.name
+ if u',' in name:
+ names.append('"%s"' % name)
+ continue
+ elif u' ' in name:
+ if not use_commas:
+ use_commas = True
+ names.append(name)
+ if use_commas:
+ glue = u', '
+ else:
+ glue = u' '
+ return glue.join(names)
+
+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=parse_tag_input(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=[force_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(_('If a list or tuple of tags is provided, they must all be tag names, Tag objects or Tag ids.'))
+ else:
+ raise ValueError(_('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_thresholds(min_weight, max_weight, steps):
+ delta = (max_weight - min_weight) / float(steps)
+ return [min_weight + i * delta for i in range(1, steps + 1)]
+
+def _calculate_tag_weight(weight, max_weight, distribution):
+ """
+ Logarithmic tag weight calculation is based on code from the
+ `Tag Cloud`_ plugin for Mephisto, by Sven Fuchs.
+
+ .. _`Tag Cloud`: http://www.artweb-design.de/projects/mephisto-plugin-tag-cloud
+ """
+ if distribution == LINEAR or max_weight == 1:
+ return weight
+ elif distribution == LOGARITHMIC:
+ return math.log(weight) * max_weight / math.log(max_weight)
+ raise ValueError(_('Invalid distribution algorithm specified: %s.') % distribution)
+
+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
+ one of ``tagging.utils.LOGARITHMIC`` or ``tagging.utils.LINEAR``.
+ """
+ if len(tags) > 0:
+ counts = [tag.count for tag in tags]
+ min_weight = float(min(counts))
+ max_weight = float(max(counts))
+ thresholds = _calculate_thresholds(min_weight, max_weight, steps)
+ for tag in tags:
+ font_set = False
+ tag_weight = _calculate_tag_weight(tag.count, max_weight, distribution)
+ for i in range(steps):
+ if not font_set and tag_weight <= thresholds[i]:
+ tag.font_size = i + 1
+ font_set = True
+ return tags
diff --git a/tagging/validators.py b/tagging/validators.py index 363fa6d..0daeb17 100644 --- a/tagging/validators.py +++ b/tagging/validators.py @@ -1,33 +1,30 @@ -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.') +"""
+Oldforms validators for tagging related fields - these are still
+required for basic ``django.contrib.admin`` application field validation
+until the ``newforms-admin`` branch lands in trunk.
+"""
+from django.core.validators import ValidationError
+from django.utils.translation import ugettext as _
+
+from tagging import settings
+from tagging.utils import parse_tag_input
+
+def isTagList(field_data, all_data):
+ """
+ Validates that ``field_data`` is a valid list of tags.
+ """
+ for tag_name in parse_tag_input(field_data):
+ if len(tag_name) > settings.MAX_TAG_LENGTH:
+ raise ValidationError(
+ _('Each tag may be no more than %s characters long.') % settings.MAX_TAG_LENGTH)
+
+def isTag(field_data, all_data):
+ """
+ Validates that ``field_data`` is a valid tag.
+ """
+ tag_names = parse_tag_input(field_data)
+ if len(tag_names) > 1:
+ raise ValidationError(_('Multiple tags were given.'))
+ elif len(tag_names[0]) > settings.MAX_TAG_LENGTH:
+ raise ValidationError(
+ _('A tag may be no more than %s characters long.') % settings.MAX_TAG_LENGTH)
diff --git a/tagging/views.py b/tagging/views.py index 4e424f1..26a391e 100644 --- a/tagging/views.py +++ b/tagging/views.py @@ -1,48 +1,52 @@ -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) +"""
+Tagging related views.
+"""
+from django.http import Http404
+from django.utils.translation import ugettext as _
+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
+ 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(_('tagged_object_list must be called with a model.'))
+
+ if tag is None:
+ try:
+ tag = kwargs['tag']
+ except KeyError:
+ raise AttributeError(_('tagged_object_list must be called with a tag.'))
+
+ tag_instance = get_tag(tag)
+ if tag_instance is None:
+ raise Http404(_('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)
|