aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.txt12
-rw-r--r--INSTALL.txt14
-rw-r--r--LICENSE.txt53
-rw-r--r--MANIFEST.in7
-rw-r--r--README.txt10
-rw-r--r--docs/overview.txt596
-rw-r--r--setup.py33
-rw-r--r--tagging/__init__.py1
-rw-r--r--tagging/fields.py109
-rw-r--r--tagging/forms.py22
-rw-r--r--tagging/managers.py366
-rw-r--r--tagging/models.py53
-rw-r--r--tagging/settings.py13
-rw-r--r--tagging/templatetags/__init__.py0
-rw-r--r--tagging/templatetags/tagging_tags.py114
-rw-r--r--tagging/tests/__init__.py0
-rw-r--r--tagging/tests/models.py38
-rw-r--r--tagging/tests/runtests.py8
-rw-r--r--tagging/tests/settings.py27
-rw-r--r--tagging/tests/tags.txt122
-rw-r--r--tagging/tests/tests.py384
-rw-r--r--tagging/utils.py140
-rw-r--r--tagging/validators.py33
-rw-r--r--tagging/views.py48
24 files changed, 2203 insertions, 0 deletions
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
new file mode 100644
index 0000000..074850c
--- /dev/null
+++ b/CHANGELOG.txt
@@ -0,0 +1,12 @@
+========================
+Django Tagging Changelog
+========================
+
+
+Version 0.1, 30 May 2007:
+-------------------------
+
+Packaged from revision 79 in Subversion; download at
+http://django-tagging.googlecode.com/files/tagging-0.1.zip
+
+* First packaged version using distutils.
diff --git a/INSTALL.txt b/INSTALL.txt
new file mode 100644
index 0000000..7f90cca
--- /dev/null
+++ b/INSTALL.txt
@@ -0,0 +1,14 @@
+Thanks for downloading django-tagging.
+
+To install it, run the following command inside this directory:
+
+ python setup.py install
+
+Or if you'd prefer you can simply place the included ``tagging``
+directory somewhere on your Python path, or symlink to it from
+somewhere on your Python path; this is useful if you're working from a
+Subversion checkout.
+
+Note that this application requires Python 2.3 or later, and Django
+0.96 or later. You can obtain Python from http://www.python.org/ and
+Django from http://www.djangoproject.com/. \ No newline at end of file
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..e481d34
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,53 @@
+django-tagging
+--------------
+
+Copyright (c) 2007, Jonathan Buchanan
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+Cab
+---
+
+Copyright (c) 2007, James Bennett
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+ * Neither the name of the author nor the names of other
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..c9301d6
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,7 @@
+include CHANGELOG.txt
+include INSTALL.txt
+include LICENSE.txt
+include MANIFEST.in
+include README.txt
+recursive-include docs *
+recursive-include tagging/tests *
diff --git a/README.txt b/README.txt
new file mode 100644
index 0000000..acb2d47
--- /dev/null
+++ b/README.txt
@@ -0,0 +1,10 @@
+==============
+Django Tagging
+==============
+
+This is a generic tagging application for Django projects
+
+For installation instructions, see the file "INSTALL.txt" in this
+directory; for instructions on how to use this application, and on
+what it provides, see the file "overview.txt" in the "docs/"
+directory. \ No newline at end of file
diff --git a/docs/overview.txt b/docs/overview.txt
new file mode 100644
index 0000000..61cb086
--- /dev/null
+++ b/docs/overview.txt
@@ -0,0 +1,596 @@
+==============
+Django Tagging
+==============
+
+A generic tagging application for `Django`_ projects, which allows
+association of a number of tags with any ``Model`` instance and makes
+retrieval of tags simple.
+
+.. _`Django`: http://www.djangoproject.com
+
+
+Installation
+============
+
+Downloading django-tagging
+--------------------------
+
+There are two options for downloading; one is to download the latest
+packaged version from http://code.google.com/p/django-tagging/ and
+unpack it; inside is a script called setup.py. Type this command::
+
+ python setup.py install
+
+...and the package will install automatically.
+
+The other method is to perform a Subversion checkout from somewhere on
+your Python path:
+
+ svn checkout http://django-tagging.googlecode.com/svn/trunk/tagging/
+
+Keep in mind that the current code in SVN may be different from the
+packaged release, and may contain bugs and backwards-incompatible
+changes.
+
+Using django-tagging in your projects
+-------------------------------------
+
+Once you've downloaded django-tagging and want to use it in your
+projects, do the following::
+
+ 1. Put ``'tagging'`` in your ``INSTALLED_APPS`` setting.
+ 2. Run the command ``manage.py syncdb``.
+
+The ``syncdb`` command creates the necessary database tables and
+creates permission objects for all installed apps that need them.
+
+That's it!
+
+
+Settings
+========
+
+Some of django-tagging's behaviour may be configured by adding the
+appropriate settings to your project's settings file.
+
+The following settings are available:
+
+FORCE_LOWERCASE_TAGS
+--------------------
+
+Default: ``False``
+
+A boolean that turns on/off forcing of tags to lowercase before they
+are saved to the database.
+
+
+Tags
+====
+
+Tags are represented by the ``Tag`` model, which lives in the
+``tagging.models`` module.
+
+API reference
+-------------
+
+Fields
+~~~~~~
+
+``Tag`` objects have the following fields:
+
+ * ``name`` -- The name of the tag. This is a unique value
+ consisting only of unicode alphanumeric characters, numbers,
+ underscores and hyphens.
+
+Manager functions
+~~~~~~~~~~~~~~~~~
+
+The ``Tag`` model has a custom manager which has the following helper
+functions:
+
+ * ``update_tags(obj, tag_names)`` -- Updates tags associated with
+ an object.
+
+ ``tag_names`` is a string containing tag names with which
+ ``obj`` should be tagged. Valid tag names may contain unicode
+ alphanumeric characters, numbers, underscores or hyphens.
+ Multiple tag names may be specified, separated by any number of
+ commas and spaces.
+
+ If ``tag_names`` is ``None`` or ``''``, the object's tags will
+ be cleared.
+
+ * ``add_tag(obj, tag_name)`` -- Associates a tag with an an object.
+
+ ``tag_name`` is a string containing a tag name with which
+ ``obj`` should be tagged. Valid tag names may contain unicode
+ alphanumeric characters, numbers, underscores or hyphens.
+
+ * ``get_for_object(obj)`` -- Returns a ``QuerySet`` containing all
+ ``Tag`` objects associated with ``obj``.
+
+ * ``usage_for_model(Model, counts=False, min_count=None, filters=None)``
+ -- Returns a list of ``Tag`` objects associated with instances
+ of ``Model``.
+
+ If ``counts`` is ``True``, a ``count`` attribute will be added
+ to each tag, indicating how many times it has been associated
+ with instances of ``Model``.
+
+ 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 ``Model`` as the ``filters``
+ argument.
+
+ * ``related_for_model(tags, Model, counts=False, min_count=None)``
+ -- Returns 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``.
+
+ * ``cloud_for_model(Model, steps=4, distribution=LOGARITHMIC,
+ filters=None, min_count=None)`` -- Returns a list of the
+ distinct ``Tag`` objects associated with instances of ``Model``,
+ each having a ``count`` attribute as above and an additional
+ ``font_size`` attribute, for use in creation of a tag cloud (a
+ type of weighted list).
+
+ ``steps`` defines the number of font sizes available -
+ ``font_size`` may 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.
+
+Basic usage
+-----------
+
+Tagging objects and retrieving an object's tags
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Objects may be tagged using the ``update_tags`` helper function::
+
+ >>> from shop.apps.products.models import Widget
+ >>> from tagging.models import Tag
+ >>> widget = Widget.objects.get(pk=1)
+ >>> Tag.objects.update_tags(widget, 'house thing')
+
+Retrieve tags for an object using the ``get_for_object`` helper
+function::
+
+ >>> Tag.objects.get_for_object(widget)
+ [<Tag: house>, <Tag: thing>]
+
+Tags are created, associated and unassociated accordingly when you use
+``update_tags`` and ``add_tags``::
+
+ >>> Tag.objects.update_tags(widget, 'house monkey')
+ >>> Tag.objects.get_for_object(widget)
+ [<Tag: house>, <Tag: monkey>]
+ >>> Tag.objects.add_tag(widget, 'tiles')
+ >>> Tag.objects.get_for_object(widget)
+ [<Tag: house>, <Tag: monkey>, <Tag: tiles>]
+
+Clear an object's tags by passing ``None`` or ``''`` to
+``update_tags``::
+
+ >>> Tag.objects.update_tags(widget, None)
+ >>> Tag.objects.get_for_object(widget)
+ []
+
+Retrieving tags used by a particular model
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To retrieve all tags used for a particular model, use the
+``get_for_model`` helper function::
+
+ >>> widget1 = Widget.objects.get(pk=1)
+ >>> Tag.objects.update_tags(widget1, 'house thing')
+ >>> widget2 = Widget.objects.get(pk=2)
+ >>> Tag.objects.update_tags(widget2, 'cheese toast house')
+ >>> Tag.objects.usage_for_model(Widget)
+ [<Tag: cheese>, <Tag: house>, <Tag: thing>, <Tag: toast>]
+
+To get a count of how many times each tag was used for a particular
+model, pass in ``True`` for the ``counts`` argument::
+
+ >>> tags = Tag.objects.usage_for_model(Widget, counts=True)
+ >>> [(tag.name, tag.count) for tag in tags]
+ [('cheese', 1), ('house', 2), ('thing', 1), ('toast', 1)]
+
+To get counts and limit the tags returned to those with counts above a
+certain size, pass in a ``min_count`` argument::
+
+ >>> tags = Tag.objects.usage_for_model(Widget, min_count=2)
+ >>> [(tag.name, tag.count) for tag in tags]
+ [('house', 2)]
+
+You can also specify a dictionary of `field lookups`_ to be used to
+restrict the tags and counts returned based on a subset of the
+model's instances. For example, the following would retrieve all tags
+used on Widgets created by a user named Alan which have a size
+greater than 99::
+
+ >>> Tag.objects.usage_for_model(Widget, filters=dict(size__gt=99, user__username='Alan'))
+
+.. _`field lookups`: http://www.djangoproject.com/documentation/db-api/#field-lookups
+
+
+Tagged Items
+============
+
+The relationship between a ``Tag`` and an object is represented by
+the ``TaggedItem`` model, which lives in the ``tagging.models``
+module.
+
+API reference
+-------------
+
+Fields
+~~~~~~
+
+``TaggedItem`` objects have the following fields:
+
+ * ``tag`` -- The ``Tag`` an object is associated with.
+ * ``content_type`` -- The ContentType of the associated object.
+ * ``object_id`` -- The id of the associated object.
+ * ``object`` -- The associated object.
+
+Manager functions
+~~~~~~~~~~~~~~~~~
+
+The ``TaggedItem`` model has a custom manager which has the following
+helper functions:
+
+ * ``get_by_model(Model, tag)`` -- If ``tag`` is an instance of a
+ ``Tag``, returns a ``QuerySet`` containing all instances of
+ ``Model`` which are tagged with it.
+
+ If ``tag`` is a list of tags, returns a ``QuerySet`` containing
+ all instances of ``Model`` which are tagged with every tag in
+ the list.
+
+ * ``get_intersection_by_model(Model, tags)`` -- Returns a
+ ``QuerySet`` containing all instances of ``Model`` which are
+ tagged with every tag in the list.
+
+ ``get_by_model`` will call this function behind the scenes when
+ you pass it a list, so it's recommended that you use
+ ``get_by_model`` instead of calling this function directly.
+
+ * ``get_related(obj, Model, num=None)`` - Returns 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.
+
+Basic usage
+-----------
+
+Retrieving tagged objects
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Objects may be retrieved based on their tags using the ``get_by_model``
+helper function::
+
+ >>> from shop.apps.products.models import Widget
+ >>> from tagging.models import Tag
+ >>> house_tag = Tag.objects.get(name='house')
+ >>> TaggedItem.objects.get_by_model(Widget, house_tag)
+ [<Widget: pk=1>, <Widget: pk=2>]
+
+Passing a list of tags to ``get_by_model`` returns an intersection of
+objects which have those tags, i.e. tag1 AND tag2 ... AND tagN::
+
+ >>> thing_tag = Tag.objects.get(name='thing')
+ >>> TaggedItem.objects.get_by_model(Widget, [house_tag, thing_tag])
+ [<Widget: pk=1>]
+
+Functions which take tags are flexible when it comes to tag input::
+
+ >>> TaggedItem.objects.get_by_model(Widget, Tag.objects.filter(name__in=['house', 'thing']))
+ [<Widget: pk=1>]
+ >>> TaggedItem.objects.get_by_model(Widget, 'house thing')
+ [<Widget: pk=1>]
+ >>> TaggedItem.objects.get_by_model(Widget, ['house', 'thing'])
+ [<Widget: pk=1>]
+
+
+Utilities
+=========
+
+Tag-related utility functions are defined in the ``tagging.utils``
+module:
+
+get_tag_name_list(tags_names)
+-----------------------------
+
+Finds tag names in the given string and return them in a list.
+
+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``.
+
+calculate_cloud(tags, steps=4, distribution=tagging.utils.LOGARITHMIC)
+----------------------------------------------------------------------
+
+Adds a ``font_size`` attribute to each tag given 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`` (default) or ``tagging.utils.LINEAR``.
+
+The algorithm to scale the tags logarithmically is from a blog post by
+Anders Pearson, `Scaling tag clouds`_.
+
+.. _`Scaling tag clouds`: http://thraxil.com/users/anders/posts/2005/12/13/scaling-tag-clouds/
+
+
+Model Fields
+============
+
+The ``tagging.fields`` module contains fields which make it easy to
+integrate tagging into your models and into the
+``django.contrib.admin`` application.
+
+Field types
+-----------
+
+``TagField``
+~~~~~~~~~~~~
+
+A ``CharField`` that actually works as a relationship to tags "under
+the hood".
+
+Using this example model::
+
+ class Link(models.Model):
+ ...
+ tags = TagField()
+
+Setting tags::
+
+ >>> l = Link.objects.get(...)
+ >>> l.tags = 'tag1 tag2 tag3'
+
+Getting tags for an instance::
+
+ >>> l.tags
+ 'tag1 tag2 tag3'
+
+Getting tags for a model - i.e. all tags used by all instances of the
+model::
+
+ >>> Link.tags
+ 'tag1 tag2 tag3 tag4 tag5'
+
+This field will also validate that it has been given a valid list of
+tag names, separated by a single comma, a single space or a comma
+followed by a space, using the ``isTagList`` validator from
+``tagging.validators``.
+
+
+Form Fields
+===========
+
+The ``tagging.forms`` module contains a ``Field`` for use with
+Django's `newforms library`_ which takes care of validating tag name
+input when used in your forms.
+
+.. _`newforms library`: http://www.djangoproject.com/documentation/newforms/
+
+Field types
+-----------
+
+``TagField``
+~~~~~~~~~~~~
+
+A form ``Field`` which is displayed as a single-line text input, which
+validates that the input it receives is a valid list of tag names,
+separated by a single comma, a single space or a comma followed by a
+space.
+
+When you generate a form for one of your models automatically, using
+the ``form_for_model`` or ``form_for_instance`` functions provided by
+the newforms library, any ``tagging.fields.TagField`` fields in your
+model will automatically be represented by a
+``tagging.forms.TagField`` in the generated form.
+
+
+Simplified tagging and retrieval of tags with properties
+========================================================
+
+If you're not using ``TagField``, a useful method for simplifying
+tagging and retrieval of tags for your models is to set up a
+property::
+
+ from django.db import models
+ from tagging.models import Tag
+
+ class MyModel(models.Model):
+ name = models.CharField(maxlength=100)
+ tag_list = models.CharField(maxlength=255)
+
+ def save(self):
+ super(MyModel, self).save()
+ self.tags = self.tag_list
+
+ def _get_tags(self):
+ return Tag.objects.get_for_object(self)
+
+ def _set_tags(self, tag_list):
+ Tag.objects.update_tags(self, tag_list)
+
+ tags = property(_get_tags, _set_tags)
+
+ def __str__(self):
+ return self.name
+
+Once you've set this up, you can access and set tags in a fairly
+natural way::
+
+ >>> obj = MyModel.objects.get(pk=1)
+ >>> obj.tags = 'foo bar'
+ >>> obj.tags
+ [<Tag: bar>, <Tag: foo>]
+
+Remember that ``obj.tags`` will return a ``QuerySet``, so you can
+perform further filtering on it, should you need to.
+
+
+Generic Views
+=============
+
+**New in django-tagging development version**
+
+The ``tagging.views`` module contains views to handle common display
+ogic related to tagging.
+
+The following sample URLconf demonstrates using a generic view to list
+items of a particular model which have a given tag::
+
+ from django.conf.urls.defaults import *
+ from tagging.views import tagged_object_list
+ from shop.apps.products.models import Widget
+
+ urlpatterns = patterns('',
+ (r'^widgets/tag/(?P<tag>[^/]+(?u))/$', tagged_object_list,
+ dict(model=Widget, paginate_by=10, allow_empty=True,
+ template_object_name='widget')),
+ )
+
+``tagging.views.tagged_object_list``
+------------------------------------
+
+**Description:**
+
+A view that displays a list of objects for a given model which have a
+given tag. This is a thin wrapper around the
+``django.views.generic.list_detail.object_list`` view, which takes a
+model and a tag as its arguments (in addition to the other optional
+arguments supported by ``object_list``), building the appropriate
+``QuerySet`` for you instead of expecting one to be passed in.
+
+**Required arguments:**
+
+ * ``model``: The Django model class of the object that will be
+ listed.
+
+ * ``tag``: The tag which objects of the given model must have in
+ order to be listed.
+
+**Optional arguments:**
+
+Please refer to the `object_list documentation`_ for additional optional
+arguments which may be given.
+
+ * ``related_tags``: If ``True``, a ``related_tags`` context variable
+ will also contain tags related to the given tag for the given
+ model.
+
+ * ``related_tag_counts``: If ``True`` and ``related_tags`` is
+ ``True``, each related tag will have a ``count`` attribute
+ indicating the number of items which have it in addition to the
+ given tag.
+
+**Template context:**
+
+ * ``tag``: The ``Tag`` instance for the given tag.
+
+Please refer to the `object_list documentation`_ for additional
+template context variables which may be provided.
+
+.. _`object_list documentation`: http://www.djangoproject.com/documentation/generic_views/#django-views-generic-list-detail-object-list
+
+Template tags
+=============
+
+The ``tagging.templatetags.tagging_tags`` module defines a number of
+template tags which may be used to work with tags.
+
+Tag reference
+-------------
+
+tags_for_model
+~~~~~~~~~~~~~~
+
+Retrieves a list of tags associated with the 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 %}
+
+tags_for_object
+~~~~~~~~~~~~~~~
+
+Retrieves a list of tags associated with an object and stores them in
+a context variable.
+
+Example usage::
+
+ {% tags_for_object widget as tag_list %}
+
+tagged_objects
+~~~~~~~~~~~~~~
+
+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 house_tag in products.Widget as widgets %} \ No newline at end of file
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..21ab756
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,33 @@
+from distutils.core import setup
+from distutils.command.install import INSTALL_SCHEMES
+
+# Tell distutils to put the data_files in platform-specific installation
+# locations. See here for an explanation:
+# http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb
+for scheme in INSTALL_SCHEMES.values():
+ scheme['data'] = scheme['purelib']
+
+# Dynamically calculate the version based on tagging.VERSION.
+version_tuple = __import__('tagging').VERSION
+if version_tuple[2] is not None:
+ version = "%d.%d_%s" % version_tuple
+else:
+ version = "%d.%d" % version_tuple[:2]
+
+setup(
+ name = 'tagging',
+ version = version,
+ description = 'Generic tagging application for Django',
+ author = 'Jonathan Buchanan',
+ author_email = 'jonathan.buchanan@gmail.com',
+ url = 'http://code.google.com/p/django-tagging/',
+ packages = ['tagging', 'tagging.templatetags', 'tagging.tests'],
+ package_data = { 'tagging.tests': ['tags.txt']},
+ classifiers = ['Development Status :: 4 - Beta',
+ 'Environment :: Web Environment',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: BSD License',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'Topic :: Utilities'],
+) \ No newline at end of file
diff --git a/tagging/__init__.py b/tagging/__init__.py
new file mode 100644
index 0000000..171b9e0
--- /dev/null
+++ b/tagging/__init__.py
@@ -0,0 +1 @@
+VERSION = (0, 2, 'pre')
diff --git a/tagging/fields.py b/tagging/fields.py
new file mode 100644
index 0000000..1e66458
--- /dev/null
+++ b/tagging/fields.py
@@ -0,0 +1,109 @@
+from django.db.models import signals
+from django.db.models.fields import CharField
+from django.dispatch import dispatcher
+
+from tagging import settings
+from tagging.models import Tag
+from tagging.validators import isTagList
+
+class TagField(CharField):
+ """
+ A "special" character field that actually works as a relationship to tags
+ "under the hood". This exposes a space-separated string of tags, but does
+ the splitting/reordering/etc. under the hood.
+ """
+ def __init__(self, **kwargs):
+ kwargs['max_length'] = kwargs.get('max_length', 255)
+ kwargs['blank'] = kwargs.get('blank', True)
+ kwargs['validator_list'] = [isTagList] + kwargs.get('validator_list', [])
+ super(TagField, self).__init__(**kwargs)
+
+ def contribute_to_class(self, cls, name):
+ super(TagField, self).contribute_to_class(cls, name)
+
+ # Make this object the descriptor for field access.
+ setattr(cls, self.name, self)
+
+ # Save tags back to the database post-save
+ dispatcher.connect(self._save, signal=signals.post_save, sender=cls)
+
+ def __get__(self, instance, owner=None):
+ """
+ Tag getter. Returns an instance's tags if accessed on an instance, and
+ all of a model's tags if called on a class. That is, this model::
+
+ class Link(models.Model):
+ ...
+ tags = TagField()
+
+ Lets you do both of these::
+
+ >>> l = Link.objects.get(...)
+ >>> l.tags
+ 'tag1 tag2 tag3'
+
+ >>> Link.tags
+ 'tag1 tag2 tag3 tag4'
+
+ """
+ # Handle access on the model (i.e. Link.tags)
+ if instance is None:
+ return tags2str(Tag.objects.usage_for_model(owner))
+
+ tags = self._get_instance_tag_cache(instance)
+ if tags is None:
+ if instance._get_pk_val() is None:
+ self._set_instance_tag_cache(instance, '')
+ else:
+ self._set_instance_tag_cache(instance, tags2str(Tag.objects.get_for_object(instance)))
+ return self._get_instance_tag_cache(instance)
+
+ def __set__(self, instance, value):
+ """
+ Set an object's tags.
+ """
+ if instance is None:
+ raise AttributeError('%s can only be set on instances.' % self.name)
+ if settings.FORCE_LOWERCASE_TAGS and value is not None:
+ self._set_instance_tag_cache(instance, value.lower())
+ else:
+ self._set_instance_tag_cache(instance, value)
+
+ def _save(self, signal, sender, instance):
+ """
+ Save tags back to the database
+ """
+ tags = self._get_instance_tag_cache(instance)
+ if tags is not None :
+ Tag.objects.update_tags(instance, tags)
+
+ def __delete__(self, instance):
+ """
+ Clear all of an object's tags.
+ """
+ self._set_instance_tag_cache(instance, '')
+
+ def _get_instance_tag_cache(self, instance):
+ """
+ Helper: get an instance's tag cache.
+ """
+ return getattr(instance, '_%s_cache' % self.attname, None)
+
+ def _set_instance_tag_cache(self, instance, tags):
+ """
+ Helper: set an instance's tag cache.
+ """
+ setattr(instance, '_%s_cache' % self.attname, tags)
+
+ def get_internal_type(self):
+ return 'CharField'
+
+ def formfield(self, **kwargs):
+ from tagging import forms
+ defaults = {'form_class': forms.TagField}
+ defaults.update(kwargs)
+ return super(TagField, self).formfield(**defaults)
+
+# Helper
+def tags2str(tagset):
+ return u' '.join([t.name for t in tagset])
diff --git a/tagging/forms.py b/tagging/forms.py
new file mode 100644
index 0000000..875c598
--- /dev/null
+++ b/tagging/forms.py
@@ -0,0 +1,22 @@
+from django import newforms as forms
+
+from tagging.utils import get_tag_name_list
+from tagging.validators import tag_list_re
+
+class TagField(forms.CharField):
+ def clean(self, value):
+ """
+ Validates that the input is a valid list of tag names,
+ separated by a single comma, a single space or a comma
+ followed by a space.
+ """
+ value = super(TagField, self).clean(value)
+ if value == u'':
+ return value
+ if not tag_list_re.search(value):
+ raise forms.ValidationError(u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.')
+ tag_names = get_tag_name_list(value)
+ for tag_name in tag_names:
+ if len(tag_name) > 50:
+ raise forms.ValidationError(u'Tag names must be no longer than 50 characters.')
+ return value
diff --git a/tagging/managers.py b/tagging/managers.py
new file mode 100644
index 0000000..5e47605
--- /dev/null
+++ b/tagging/managers.py
@@ -0,0 +1,366 @@
+"""
+Custom Managers for generic tagging Models.
+"""
+import types
+
+from django.db import connection
+from django.db.models import Manager
+from django.db.models.query import QuerySet, parse_lookup
+from django.contrib.contenttypes.models import ContentType
+
+from tagging import settings
+from tagging.utils import calculate_cloud, get_tag_name_list, get_tag_list, LOGARITHMIC
+from tagging.validators import tag_re
+
+# Python 2.3 compatibility
+if not hasattr(__builtins__, 'set'):
+ from sets import Set as set
+
+qn = connection.ops.quote_name
+
+class TagManager(Manager):
+ def update_tags(self, obj, tag_names):
+ """
+ Update tags associated with an object.
+ """
+ ctype = ContentType.objects.get_for_model(obj)
+ current_tags = list(self.filter(items__content_type__pk=ctype.id,
+ items__object_id=obj._get_pk_val()))
+ updated_tag_names = set(get_tag_name_list(tag_names))
+ if settings.FORCE_LOWERCASE_TAGS:
+ updated_tag_names = [t.lower() for t in updated_tag_names]
+
+ TaggedItemModel = self._get_related_model_by_accessor('items')
+
+ # Remove tags which no longer apply
+ tags_for_removal = [tag for tag in current_tags \
+ if tag.name not in updated_tag_names]
+ if len(tags_for_removal) > 0:
+ TaggedItemModel._default_manager.filter(content_type__pk=ctype.id,
+ object_id=obj._get_pk_val(),
+ tag__in=tags_for_removal).delete()
+
+ # Add new tags
+ current_tag_names = [tag.name for tag in current_tags]
+ for tag_name in updated_tag_names:
+ if tag_name not in current_tag_names:
+ tag, created = self.get_or_create(name=tag_name)
+ TaggedItemModel._default_manager.create(tag=tag, object=obj)
+
+ def add_tag(self, obj, tag_name):
+ """
+ Associates the given object with a tag.
+ """
+ if not tag_re.match(tag_name):
+ raise AttributeError(u'An invalid tag name was given: %s. Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens.' % tag_name)
+ if settings.FORCE_LOWERCASE_TAGS:
+ tag_name = tag_name.lower()
+ tag, created = self.get_or_create(name=tag_name)
+ ctype = ContentType.objects.get_for_model(obj)
+ TaggedItemModel = self._get_related_model_by_accessor('items')
+ TaggedItemModel._default_manager.get_or_create(
+ tag=tag, content_type=ctype, object_id=obj._get_pk_val())
+
+ def get_for_object(self, obj):
+ """
+ Create a queryset matching all tags associated with the given
+ object.
+ """
+ ctype = ContentType.objects.get_for_model(obj)
+ return self.filter(items__content_type__pk=ctype.id,
+ items__object_id=obj._get_pk_val())
+
+ def usage_for_model(self, Model, counts=False, min_count=None, filters=None):
+ """
+ Obtain a list of tags associated with instances of the given
+ Model.
+
+ If ``counts`` is True, a ``count`` attribute will be added to
+ each tag, indicating how many times it has been used against
+ the Model in question.
+
+ If ``min_count`` is given, only tags which have a ``count``
+ greater than or equal to ``min_count`` will be returned.
+ Passing a value for ``min_count`` implies ``counts=True``.
+
+ To limit the tags (and counts, if specified) returned to those
+ used by a subset of the Model's instances, pass a dictionary
+ of field lookups to be applied to the given Model as the
+ ``filters`` argument.
+ """
+ if filters is None: filters = {}
+ if min_count is not None: counts = True
+
+ model_table = qn(Model._meta.db_table)
+ model_pk = '%s.%s' % (model_table, qn(Model._meta.pk.column))
+ query = """
+ SELECT DISTINCT %(tag)s.id, %(tag)s.name%(count_sql)s
+ FROM
+ %(tag)s
+ INNER JOIN %(tagged_item)s
+ ON %(tag)s.id = %(tagged_item)s.tag_id
+ INNER JOIN %(model)s
+ ON %(tagged_item)s.object_id = %(model_pk)s
+ %%s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ %%s
+ GROUP BY %(tag)s.id, %(tag)s.name
+ %%s
+ ORDER BY %(tag)s.name ASC""" % {
+ 'tag': qn(self.model._meta.db_table),
+ 'count_sql': counts and (', COUNT(%s)' % model_pk) or '',
+ 'tagged_item': qn(self._get_related_model_by_accessor('items')._meta.db_table),
+ 'model': model_table,
+ 'model_pk': model_pk,
+ 'content_type_id': ContentType.objects.get_for_model(Model).id,
+ }
+
+ extra_joins = ''
+ extra_criteria = ''
+ min_count_sql = ''
+ params = []
+ if len(filters) > 0:
+ joins, where, params = parse_lookup(filters.items(), Model._meta)
+ extra_joins = ' '.join(['%s %s AS %s ON %s' % (join_type, table, alias, condition)
+ for (alias, (table, join_type, condition)) in joins.items()])
+ extra_criteria = 'AND %s' % (' AND '.join(where))
+ if min_count is not None:
+ min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk
+ params.append(min_count)
+
+ cursor = connection.cursor()
+ cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params)
+ tags = []
+ for row in cursor.fetchall():
+ t = self.model(*row[:2])
+ if counts:
+ t.count = row[2]
+ tags.append(t)
+ return tags
+
+ def related_for_model(self, tags, Model, counts=False, min_count=None):
+ """
+ Obtain a list of tags related to a given list of tags - that
+ is, other tags used by items which have all the given tags.
+
+ If ``counts`` is True, a ``count`` attribute will be added to
+ each tag, indicating the number of items which have it in
+ addition to the given list of tags.
+
+ If ``min_count`` is given, only tags which have a ``count``
+ greater than or equal to ``min_count`` will be returned.
+ Passing a value for ``min_count`` implies ``counts=True``.
+ """
+ if min_count is not None: counts = True
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ tagged_item_table = qn(self._get_related_model_by_accessor('items')._meta.db_table)
+ query = """
+ SELECT %(tag)s.id, %(tag)s.name%(count_sql)s
+ FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tagged_item)s.object_id IN
+ (
+ SELECT %(tagged_item)s.object_id
+ FROM %(tagged_item)s, %(tag)s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tag)s.id = %(tagged_item)s.tag_id
+ AND %(tag)s.id IN (%(tag_id_placeholders)s)
+ GROUP BY %(tagged_item)s.object_id
+ HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s
+ )
+ AND %(tag)s.id NOT IN (%(tag_id_placeholders)s)
+ GROUP BY %(tag)s.id, %(tag)s.name
+ %(min_count_sql)s
+ ORDER BY %(tag)s.name ASC""" % {
+ 'tag': qn(self.model._meta.db_table),
+ 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '',
+ 'tagged_item': tagged_item_table,
+ 'content_type_id': ContentType.objects.get_for_model(Model).id,
+ 'tag_id_placeholders': ','.join(['%s'] * tag_count),
+ 'tag_count': tag_count,
+ 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '',
+ }
+
+ params = [tag.id for tag in tags] * 2
+ if min_count is not None:
+ params.append(min_count)
+
+ cursor = connection.cursor()
+ cursor.execute(query, params)
+ related = []
+ for row in cursor.fetchall():
+ tag = self.model(*row[:2])
+ if counts is True:
+ tag.count = row[2]
+ related.append(tag)
+ return related
+
+ def cloud_for_model(self, Model, steps=4, distribution=LOGARITHMIC, filters=None, min_count=None):
+ """
+ Obtain a list of tags associated with instances of the given
+ Model, giving each tag a ``count`` attribute indicating how
+ many times it has been used and a ``font_size`` attribute for
+ use in displaying a tag cloud.
+
+ ``steps`` defines the range of font sizes - ``font_size`` will
+ be an integer between 1 and ``steps`` (inclusive).
+
+ ``distribution`` defines the type of font size distribution
+ algorithm which will be used - logarithmic or linear. It must
+ be either ``tagging.utils.LOGARITHMIC`` or
+ ``tagging.utils.LINEAR``.
+
+ To limit the tags displayed in the cloud to those associated
+ with a subset of the Model's instances, pass a dictionary of
+ field lookups to be applied to the given Model as the
+ ``filters`` argument.
+
+ To limit the tags displayed in the cloud to those with a
+ ``count`` greater than or equal to ``min_count``, pass a value
+ for the ``min_count`` argument.
+ """
+ tags = list(self.usage_for_model(Model, counts=True, filters=filters, min_count=min_count))
+ return calculate_cloud(tags, steps)
+
+ def _get_related_model_by_accessor(self, accessor):
+ """
+ Returns the model for the related object accessed by the
+ given attribute name.
+
+ Since we sometimes need to access the ``TaggedItem`` model
+ when managing tagging and the``Tag`` model does not have a
+ field representing this relationship, this method is used to
+ retrieve the ``TaggedItem`` model, avoiding circular imports
+ betweeen the `models` and `managers` modules.
+ """
+ for rel_obj in self.model._meta.get_all_related_objects():
+ if rel_obj.get_accessor_name() == accessor:
+ return rel_obj.model
+ raise ValueError('Could not find a related object with the accessor "%s".' % accessor)
+
+class TaggedItemManager(Manager):
+ def get_by_model(self, Model, tags):
+ """
+ Create a queryset matching instances of the given Model
+ associated with a given Tag or list of Tags.
+ """
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ if tag_count == 0:
+ return [] # No existing tags were given
+ elif tag_count == 1:
+ tag = tags[0] # Optimisation for single tag
+ else:
+ return self.get_intersection_by_model(Model, tags)
+ ctype = ContentType.objects.get_for_model(Model)
+ rel_table = qn(self.model._meta.db_table)
+ return Model._default_manager.extra(
+ tables=[self.model._meta.db_table], # Use a non-explicit join
+ where=[
+ '%s.content_type_id = %%s' % rel_table,
+ '%s.tag_id = %%s' % rel_table,
+ '%s.%s = %s.object_id' % (qn(Model._meta.db_table),
+ qn(Model._meta.pk.column),
+ rel_table)
+ ],
+ params=[ctype.id, tag.id],
+ )
+
+ def get_intersection_by_model(self, Model, tags):
+ """
+ Create a queryset matching instances of the given Model
+ associated with all the given list of Tags.
+
+ FIXME The query currently used to grab the ids of objects
+ which have all the tags should be all that we need run,
+ using a non-explicit join for the QuerySet returned, as
+ in get_by_model, but there's currently no way to get the
+ required GROUP BY and HAVING clauses into Django's ORM.
+
+ Once the ORM is capable of this, we should have a
+ solution which requires only a single query and won't
+ have the problem where the number of ids in the IN
+ clause in the QuerySet could exceed the length allowed,
+ as could currently happen.
+ """
+ tags = get_tag_list(tags)
+ tag_count = len(tags)
+ model_table = qn(Model._meta.db_table)
+ # This query selects the ids of all objects which have all the
+ # given tags.
+ query = """
+ SELECT %(model_pk)s
+ FROM %(model)s, %(tagged_item)s
+ WHERE %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s)
+ AND %(model_pk)s = %(tagged_item)s.object_id
+ GROUP BY %(model_pk)s
+ HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % {
+ 'model_pk': '%s.%s' % (model_table, qn(Model._meta.pk.column)),
+ 'model': model_table,
+ 'tagged_item': qn(self.model._meta.db_table),
+ 'content_type_id': ContentType.objects.get_for_model(Model).id,
+ 'tag_id_placeholders': ','.join(['%s'] * tag_count),
+ 'tag_count': tag_count,
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [tag.id for tag in tags])
+ object_ids = [row[0] for row in cursor.fetchall()]
+ if len(object_ids) > 0:
+ return Model._default_manager.filter(pk__in=object_ids)
+ else:
+ return Model._default_manager.none()
+
+ def get_related(self, obj, Model, num=None):
+ """
+ Retrieve instances of ``Model`` which share tags with the
+ model instance ``obj``, ordered by the number of shared tags
+ in descending order.
+
+ If ``num`` is given, a maximum of ``num`` instances will be
+ returned.
+ """
+ model_table = qn(Model._meta.db_table)
+ content_type = ContentType.objects.get_for_model(obj)
+ related_content_type = ContentType.objects.get_for_model(Model)
+ query = """
+ SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s
+ FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item
+ WHERE %(tagged_item)s.object_id = %%s
+ AND %(tagged_item)s.content_type_id = %(content_type_id)s
+ AND %(tag)s.id = %(tagged_item)s.tag_id
+ AND related_tagged_item.content_type_id = %(related_content_type_id)s
+ AND related_tagged_item.tag_id = %(tagged_item)s.tag_id
+ AND %(model_pk)s = related_tagged_item.object_id"""
+ if content_type.id == related_content_type.id:
+ # Exclude the given instance itself if determining related
+ # instances for the same model.
+ query += """
+ AND related_tagged_item.object_id != %(tagged_item)s.object_id"""
+ query += """
+ GROUP BY %(model_pk)s
+ ORDER BY %(count)s DESC
+ %(limit_offset)s"""
+ query = query % {
+ 'model_pk': '%s.%s' % (model_table, qn(Model._meta.pk.column)),
+ 'count': qn('count'),
+ 'model': model_table,
+ 'tagged_item': qn(self.model._meta.db_table),
+ 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table),
+ 'content_type_id': content_type.id,
+ 'related_content_type_id': related_content_type.id,
+ 'limit_offset': num is not None and connection.ops.limit_offset_sql(num) or '',
+ }
+
+ cursor = connection.cursor()
+ cursor.execute(query, [obj._get_pk_val()])
+ object_ids = [row[0] for row in cursor.fetchall()]
+ if len(object_ids) > 0:
+ # Use in_bulk here instead of an id__in lookup, because id__in would
+ # clobber the ordering.
+ object_dict = Model._default_manager.in_bulk(object_ids)
+ return [object_dict[object_id] for object_id in object_ids]
+ else:
+ return Model._default_manager.none()
diff --git a/tagging/models.py b/tagging/models.py
new file mode 100644
index 0000000..199e5eb
--- /dev/null
+++ b/tagging/models.py
@@ -0,0 +1,53 @@
+"""
+Models for generic tagging.
+"""
+from django.db import models
+from django.contrib.contenttypes import generic
+from django.contrib.contenttypes.models import ContentType
+
+from tagging.managers import TagManager, TaggedItemManager
+from tagging.validators import isTag
+
+class Tag(models.Model):
+ """
+ A basic tag.
+ """
+ name = models.CharField(max_length=50, unique=True, db_index=True, validator_list=[isTag])
+
+ objects = TagManager()
+
+ class Meta:
+ db_table = 'tag'
+ verbose_name = 'Tag'
+ verbose_name_plural = 'Tags'
+ ordering = ('name',)
+
+ class Admin:
+ pass
+
+ def __unicode__(self):
+ return self.name
+
+class TaggedItem(models.Model):
+ """
+ Holds the relationship between a tag and the item being tagged.
+ """
+ tag = models.ForeignKey(Tag, related_name='items')
+ content_type = models.ForeignKey(ContentType)
+ object_id = models.PositiveIntegerField()
+ object = generic.GenericForeignKey('content_type', 'object_id')
+
+ objects = TaggedItemManager()
+
+ class Meta:
+ db_table = 'tagged_item'
+ verbose_name = 'Tagged Item'
+ verbose_name_plural = 'Tagged Items'
+ # Enforce unique tag association per object
+ unique_together = (('tag', 'content_type', 'object_id'),)
+
+ class Admin:
+ pass
+
+ def __unicode__(self):
+ return u'%s [%s]' % (self.object, self.tag)
diff --git a/tagging/settings.py b/tagging/settings.py
new file mode 100644
index 0000000..18321bd
--- /dev/null
+++ b/tagging/settings.py
@@ -0,0 +1,13 @@
+"""
+Convenience module for access of custom tagging application settings,
+which enforces default settings when the main settings module which has
+been configured does not contain the appropriate settings.
+"""
+from django.conf import settings
+
+# Whether to force all tags to lowercase before they are saved to the
+# database. Default is False.
+try:
+ FORCE_LOWERCASE_TAGS = settings.FORCE_LOWERCASE_TAGS
+except AttributeError:
+ FORCE_LOWERCASE_TAGS = False
diff --git a/tagging/templatetags/__init__.py b/tagging/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tagging/templatetags/__init__.py
diff --git a/tagging/templatetags/tagging_tags.py b/tagging/templatetags/tagging_tags.py
new file mode 100644
index 0000000..fb4347f
--- /dev/null
+++ b/tagging/templatetags/tagging_tags.py
@@ -0,0 +1,114 @@
+from django.db.models import get_model
+from django.template import Library, Node, TemplateSyntaxError, resolve_variable
+from tagging.models import Tag, TaggedItem
+
+register = Library()
+
+class TagsForModelNode(Node):
+ def __init__(self, model, context_var, counts):
+ self.model = model
+ self.context_var = context_var
+ self.counts = counts
+
+ def render(self, context):
+ model = get_model(*self.model.split('.'))
+ context[self.context_var] = Tag.objects.usage_for_model(model, counts=self.counts)
+ return ''
+
+class TagsForObjectNode(Node):
+ def __init__(self, obj, context_var):
+ self.obj = obj
+ self.context_var = context_var
+
+ def render(self, context):
+ obj = resolve_variable(self.obj, context)
+ context[self.context_var] = Tag.objects.get_for_object(obj)
+ return ''
+
+class TaggedObjectsNode(Node):
+ def __init__(self, tag, model, context_var):
+ self.tag = tag
+ self.context_var = context_var
+ self.model = model
+
+ def render(self, context):
+ tag = resolve_variable(self.tag, context)
+ model = get_model(*self.model.split('.'))
+ context[self.context_var] = TaggedItem.objects.get_by_model(model,
+ tag)
+ return ''
+
+def do_tags_for_model(parser, token):
+ """
+ Retrieves a list of ``Tag`` objects associated with a given model
+ and stores them in a context variable.
+
+ The model is specified in ``[appname].[modelname]`` format.
+
+ If specified - by providing extra ``with counts`` arguments - adds
+ a ``count`` attribute to each tag containing the number of
+ instances of the given model which have been tagged with it.
+
+ Example usage::
+
+ {% tags_for_model products.Widget as widget_tags %}
+
+ {% tags_for_model products.Widget as widget_tags with counts %}
+ """
+ bits = token.contents.split()
+ len_bits = len(bits)
+ if len_bits not in (4, 6):
+ raise TemplateSyntaxError('%s tag requires either three or five arguments' % bits[0])
+ if bits[2] != 'as':
+ raise TemplateSyntaxError("second argument to %s tag must be 'as'" % bits[0])
+ if len_bits == 6:
+ if bits[4] != 'with':
+ raise TemplateSyntaxError("if given, fourth argument to %s tag must be 'with'" % bits[0])
+ if bits[5] != 'counts':
+ raise TemplateSyntaxError("if given, fifth argument to %s tag must be 'counts'" % bits[0])
+ if len_bits == 4:
+ return TagsForModelNode(bits[1], bits[3], counts=False)
+ else:
+ return TagsForModelNode(bits[1], bits[3], counts=True)
+
+def do_tags_for_object(parser, token):
+ """
+ Retrieves a list of ``Tag`` objects associated with an object and
+ stores them in a context variable.
+
+ Example usage::
+
+ {% tags_for_object foo_object as tag_list %}
+ """
+ bits = token.contents.split()
+ if len(bits) != 4:
+ raise TemplateSyntaxError('%s tag requires exactly three arguments' % bits[0])
+ if bits[2] != 'as':
+ raise TemplateSyntaxError("second argument to %s tag must be 'as'" % bits[0])
+ return TagsForObjectNode(bits[1], bits[3])
+
+def do_tagged_objects(parser, token):
+ """
+ Retrieves a list of objects for a given Model which are tagged with
+ a given Tag and stores them in a context variable.
+
+ The tag must be an instance of a ``Tag``, not the name of a tag.
+
+ The model is specified in ``[appname].[modelname]`` format.
+
+ Example usage::
+
+ {% tagged_objects foo_tag in tv.Model as object_list %}
+ """
+ bits = token.contents.split()
+ if len(bits) != 6:
+ raise TemplateSyntaxError('%s tag requires exactly five arguments' % bits[0])
+ if bits[2] != 'in':
+ raise TemplateSyntaxError("second argument to %s tag must be 'in'" % bits[0])
+ if bits[4] != 'as':
+ raise TemplateSyntaxError("fourth argument to %s tag must be 'as'" % bits[0])
+ return TaggedObjectsNode(bits[1], bits[3], bits[5])
+
+register.tag('tags_for_model', do_tags_for_model)
+register.tag('tags_for_object', do_tags_for_object)
+register.tag('tagged_objects', do_tagged_objects) \ No newline at end of file
diff --git a/tagging/tests/__init__.py b/tagging/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tagging/tests/__init__.py
diff --git a/tagging/tests/models.py b/tagging/tests/models.py
new file mode 100644
index 0000000..86754cd
--- /dev/null
+++ b/tagging/tests/models.py
@@ -0,0 +1,38 @@
+from django.db import models
+
+from tagging.fields import TagField
+
+class Perch(models.Model):
+ size = models.IntegerField()
+ smelly = models.BooleanField(default=True)
+
+class Parrot(models.Model):
+ state = models.CharField(max_length=50)
+ perch = models.ForeignKey(Perch, null=True)
+
+ def __unicode__(self):
+ return self.state
+
+ class Meta:
+ ordering = ['state']
+
+class Link(models.Model):
+ name = models.CharField(max_length=50)
+
+ def __unicode__(self):
+ return self.name
+
+ class Meta:
+ ordering = ['name']
+
+class Article(models.Model):
+ name = models.CharField(max_length=50)
+
+ def __unicode__(self):
+ return self.name
+
+ class Meta:
+ ordering = ['name']
+
+class FormTest(models.Model):
+ tags = TagField()
diff --git a/tagging/tests/runtests.py b/tagging/tests/runtests.py
new file mode 100644
index 0000000..ab1d49c
--- /dev/null
+++ b/tagging/tests/runtests.py
@@ -0,0 +1,8 @@
+import os, sys
+os.environ['DJANGO_SETTINGS_MODULE'] = 'tagging.tests.settings'
+
+from django.test.simple import run_tests
+
+failures = run_tests(None, verbosity=9)
+if failures:
+ sys.exit(failures)
diff --git a/tagging/tests/settings.py b/tagging/tests/settings.py
new file mode 100644
index 0000000..26e659b
--- /dev/null
+++ b/tagging/tests/settings.py
@@ -0,0 +1,27 @@
+import os
+DIRNAME = os.path.dirname(__file__)
+
+DEFAULT_CHARSET = 'utf-8'
+
+DATABASE_ENGINE = 'sqlite3'
+DATABASE_NAME = os.path.join(DIRNAME, 'tagging_test.db')
+
+#DATABASE_ENGINE = 'mysql'
+#DATABASE_NAME = 'tagging_test'
+#DATABASE_USER = 'root'
+#DATABASE_PASSWORD = ''
+#DATABASE_HOST = 'localhost'
+#DATABASE_PORT = '3306'
+
+#DATABASE_ENGINE = 'postgresql_psycopg2'
+#DATABASE_NAME = 'tagging_test'
+#DATABASE_USER = 'postgres'
+#DATABASE_PASSWORD = ''
+#DATABASE_HOST = 'localhost'
+#DATABASE_PORT = '5432'
+
+INSTALLED_APPS = (
+ 'django.contrib.contenttypes',
+ 'tagging',
+ 'tagging.tests',
+)
diff --git a/tagging/tests/tags.txt b/tagging/tests/tags.txt
new file mode 100644
index 0000000..8543411
--- /dev/null
+++ b/tagging/tests/tags.txt
@@ -0,0 +1,122 @@
+NewMedia 53
+Website 45
+PR 44
+Status 44
+Collaboration 41
+Drupal 34
+Journalism 31
+Transparency 30
+Theory 29
+Decentralization 25
+EchoChamberProject 24
+OpenSource 23
+Film 22
+Blog 21
+Interview 21
+Political 21
+Worldview 21
+Communications 19
+Conference 19
+Folksonomy 15
+MediaCriticism 15
+Volunteer 15
+Dialogue 13
+InternationalLaw 13
+Rosen 12
+Evolution 11
+KentBye 11
+Objectivity 11
+Plante 11
+ToDo 11
+Advisor 10
+Civics 10
+Roadmap 10
+Wilber 9
+About 8
+CivicSpace 8
+Ecosystem 8
+Choice 7
+Murphy 7
+Sociology 7
+ACH 6
+del.icio.us 6
+IntelligenceAnalysis 6
+Science 6
+Credibility 5
+Distribution 5
+Diversity 5
+Errors 5
+FinalCutPro 5
+Fundraising 5
+Law 5
+PhilosophyofScience 5
+Podcast 5
+PoliticalBias 5
+Activism 4
+Analysis 4
+CBS 4
+DeceptionDetection 4
+Editing 4
+History 4
+RSS 4
+Social 4
+Subjectivity 4
+Vlog 4
+ABC 3
+ALTubes 3
+Economics 3
+FCC 3
+NYT 3
+Sirota 3
+Sundance 3
+Training 3
+Wiki 3
+XML 3
+Borger 2
+Brody 2
+Deliberation 2
+EcoVillage 2
+Identity 2
+LAMP 2
+Lobe 2
+Maine 2
+May 2
+MediaLogic 2
+Metaphor 2
+Mitchell 2
+NBC 2
+OHanlon 2
+Psychology 2
+Queen 2
+Software 2
+SpiralDynamics 2
+Strobel 2
+Sustainability 2
+Transcripts 2
+Brown 1
+Buddhism 1
+Community 1
+DigitalDivide 1
+Donnelly 1
+Education 1
+FairUse 1
+FireANT 1
+Google 1
+HumanRights 1
+KM 1
+Kwiatkowski 1
+Landay 1
+Loiseau 1
+Math 1
+Music 1
+Nature 1
+Schechter 1
+Screencast 1
+Sivaraksa 1
+Skype 1
+SocialCapital 1
+TagCloud 1
+Thielmann 1
+Thomas 1
+Tiger 1
+Wedgwood 1 \ No newline at end of file
diff --git a/tagging/tests/tests.py b/tagging/tests/tests.py
new file mode 100644
index 0000000..41a7895
--- /dev/null
+++ b/tagging/tests/tests.py
@@ -0,0 +1,384 @@
+# -*- coding: utf-8 -*-
+r"""
+>>> import os
+>>> from django import newforms as forms
+>>> from tagging import settings
+>>> from tagging.models import Tag, TaggedItem
+>>> from tagging.tests.models import Article, Link, Perch, Parrot, FormTest
+>>> from tagging.utils import calculate_cloud, get_tag_name_list, get_tag_list, get_tag, LINEAR
+>>> from tagging.validators import isTagList, isTag
+>>> from tagging.forms import TagField
+
+#############
+# Utilities #
+#############
+
+# Tag input ###################################################################
+
+# Tag names
+>>> get_tag_name_list(None)
+[]
+>>> get_tag_name_list('')
+[]
+>>> get_tag_name_list('foo')
+[u'foo']
+>>> get_tag_name_list('foo bar')
+[u'foo', u'bar']
+>>> get_tag_name_list('foo,bar')
+[u'foo', u'bar']
+>>> get_tag_name_list(', , foo , bar , ,baz, , ,')
+[u'foo', u'bar', u'baz']
+>>> get_tag_name_list('foo,ŠĐĆŽćžšđ')
+[u'foo', u'\u0160\u0110\u0106\u017d\u0107\u017e\u0161\u0111']
+
+# Normalised Tag list input
+>>> cheese = Tag.objects.create(name='cheese')
+>>> toast = Tag.objects.create(name='toast')
+>>> get_tag_list(cheese)
+[<Tag: cheese>]
+>>> get_tag_list('cheese toast')
+[<Tag: cheese>, <Tag: toast>]
+>>> get_tag_list(u'cheese toast')
+[<Tag: cheese>, <Tag: toast>]
+>>> get_tag_list([])
+[]
+>>> get_tag_list(['cheese', 'toast'])
+[<Tag: cheese>, <Tag: toast>]
+>>> get_tag_list([cheese.id, toast.id])
+[<Tag: cheese>, <Tag: toast>]
+>>> get_tag_list(['cheese', 'toast', 'ŠĐĆŽćžšđ'])
+[<Tag: cheese>, <Tag: toast>]
+>>> get_tag_list([cheese, toast])
+[<Tag: cheese>, <Tag: toast>]
+>>> get_tag_list((cheese, toast))
+(<Tag: cheese>, <Tag: toast>)
+>>> get_tag_list(Tag.objects.filter(name__in=['cheese', 'toast']))
+[<Tag: cheese>, <Tag: toast>]
+>>> get_tag_list(['cheese', toast])
+Traceback (most recent call last):
+ ...
+ValueError: If a list or tuple of tags is provided, they must all be tag names, Tag objects or Tag ids.
+>>> get_tag_list(29)
+Traceback (most recent call last):
+ ...
+ValueError: The tag input given was invalid.
+
+# Normalised Tag input
+>>> get_tag(cheese)
+<Tag: cheese>
+>>> get_tag('cheese')
+<Tag: cheese>
+>>> get_tag(cheese.id)
+<Tag: cheese>
+>>> get_tag('mouse')
+
+# Tag clouds ##################################################################
+>>> tags = []
+>>> for line in open(os.path.join(os.path.dirname(__file__), 'tags.txt')).readlines():
+... name, count = line.rstrip().split()
+... tag = Tag(name=name)
+... tag.count = int(count)
+... tags.append(tag)
+
+>>> sizes = {}
+>>> for tag in calculate_cloud(tags, steps=5):
+... sizes[tag.font_size] = sizes.get(tag.font_size, 0) + 1
+
+# This isn't a pre-calculated test, just making sure it's consistent
+>>> sizes
+{1: 48, 2: 20, 3: 24, 4: 19, 5: 11}
+
+>>> sizes = {}
+>>> for tag in calculate_cloud(tags, steps=5, distribution=LINEAR):
+... sizes[tag.font_size] = sizes.get(tag.font_size, 0) + 1
+
+# This isn't a pre-calculated test, just making sure it's consistent
+>>> sizes
+{1: 97, 2: 12, 3: 7, 4: 2, 5: 4}
+
+>>> calculate_cloud(tags, steps=5, distribution='cheese')
+Traceback (most recent call last):
+ ...
+ValueError: Invalid font size distribution algorithm specified: cheese.
+
+##############
+# Validators #
+##############
+
+>>> isTagList('foo', {})
+>>> isTagList('foo bar baz', {})
+>>> isTagList('foo,bar,baz', {})
+>>> isTagList('foo, bar, baz', {})
+>>> isTagList('foo, ŠĐĆŽćžšđ, baz', {})
+>>> isTagList('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvb bar', {})
+>>> isTagList('', {})
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.']
+>>> isTagList(' foo', {})
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.']
+>>> isTagList('foo ', {})
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.']
+>>> isTagList('foo bar', {})
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.']
+>>> isTagList('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbn bar', {})
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must be no longer than 50 characters.']
+>>> isTag('f-o_1o', {})
+>>> isTag('ŠĐĆŽćžšđ', {})
+>>> isTag('f o o', {})
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens.']
+
+###############
+# Form Fields #
+###############
+
+>>> t = TagField()
+>>> t.clean('foo')
+u'foo'
+>>> t.clean('foo bar baz')
+u'foo bar baz'
+>>> t.clean('foo,bar,baz')
+u'foo,bar,baz'
+>>> t.clean('foo, bar, baz')
+u'foo, bar, baz'
+>>> t.clean('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvb bar')
+u'foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvb bar'
+>>> t.clean(' foo')
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.']
+>>> t.clean('foo ')
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.']
+>>> t.clean('foo bar')
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.']
+>>> t.clean('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbn bar')
+Traceback (most recent call last):
+ ...
+ValidationError: [u'Tag names must be no longer than 50 characters.']
+
+# Ensure that automatically created forms use TagField
+>>> TestForm = forms.form_for_model(FormTest)
+>>> form = TestForm()
+>>> form.fields['tags'].__class__.__name__
+'TagField'
+>>> instance = FormTest(tags='one two three')
+>>> TestInstanceForm = forms.form_for_instance(instance)
+>>> form = TestInstanceForm()
+>>> form.fields['tags'].__class__.__name__
+'TagField'
+
+###########
+# Tagging #
+###########
+
+# Basic tagging ###############################################################
+
+>>> dead = Parrot.objects.create(state='dead')
+>>> Tag.objects.update_tags(dead, 'foo bar ter')
+>>> Tag.objects.get_for_object(dead)
+[<Tag: bar>, <Tag: foo>, <Tag: ter>]
+>>> Tag.objects.update_tags(dead, 'foo bar baz')
+>>> Tag.objects.get_for_object(dead)
+[<Tag: bar>, <Tag: baz>, <Tag: foo>]
+>>> Tag.objects.add_tag(dead, 'foo')
+>>> Tag.objects.get_for_object(dead)
+[<Tag: bar>, <Tag: baz>, <Tag: foo>]
+>>> Tag.objects.add_tag(dead, 'zip')
+>>> Tag.objects.get_for_object(dead)
+[<Tag: bar>, <Tag: baz>, <Tag: foo>, <Tag: zip>]
+>>> Tag.objects.add_tag(dead, 'f o o')
+Traceback (most recent call last):
+ ...
+AttributeError: An invalid tag name was given: f o o. Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens.
+
+# Note that doctest in Python 2.4 (and maybe 2.5?) doesn't support non-ascii
+# characters in output, so we're displaying the repr() here.
+>>> Tag.objects.update_tags(dead, 'ŠĐĆŽćžšđ')
+>>> repr(Tag.objects.get_for_object(dead))
+'[<Tag: \xc5\xa0\xc4\x90\xc4\x86\xc5\xbd\xc4\x87\xc5\xbe\xc5\xa1\xc4\x91>]'
+
+>>> Tag.objects.update_tags(dead, None)
+>>> Tag.objects.get_for_object(dead)
+[]
+
+# Using a model's TagField
+>>> f1 = FormTest.objects.create(tags=u'test3 test2 test1')
+>>> Tag.objects.get_for_object(f1)
+[<Tag: test1>, <Tag: test2>, <Tag: test3>]
+>>> f1.tags = u'test4'
+>>> f1.save()
+>>> Tag.objects.get_for_object(f1)
+[<Tag: test4>]
+>>> f1.tags = ''
+>>> f1.save()
+>>> Tag.objects.get_for_object(f1)
+[]
+
+# Forcing tags to lowercase
+>>> settings.FORCE_LOWERCASE_TAGS = True
+>>> Tag.objects.update_tags(dead, 'foO bAr Ter')
+>>> Tag.objects.get_for_object(dead)
+[<Tag: bar>, <Tag: foo>, <Tag: ter>]
+>>> Tag.objects.update_tags(dead, 'foO bAr baZ')
+>>> Tag.objects.get_for_object(dead)
+[<Tag: bar>, <Tag: baz>, <Tag: foo>]
+>>> Tag.objects.add_tag(dead, 'FOO')
+>>> Tag.objects.get_for_object(dead)
+[<Tag: bar>, <Tag: baz>, <Tag: foo>]
+>>> Tag.objects.add_tag(dead, 'Zip')
+>>> Tag.objects.get_for_object(dead)
+[<Tag: bar>, <Tag: baz>, <Tag: foo>, <Tag: zip>]
+>>> Tag.objects.update_tags(dead, None)
+>>> f1.tags = u'TEST5'
+>>> f1.save()
+>>> Tag.objects.get_for_object(f1)
+[<Tag: test5>]
+>>> f1.tags
+u'test5'
+
+# Retrieving tags by Model ####################################################
+
+>>> Tag.objects.usage_for_model(Parrot)
+[]
+>>> parrot_details = (
+... ('pining for the fjords', 9, True, 'foo bar'),
+... ('passed on', 6, False, 'bar baz ter'),
+... ('no more', 4, True, 'foo ter'),
+... ('late', 2, False, 'bar ter'),
+... )
+
+>>> for state, perch_size, perch_smelly, tags in parrot_details:
+... perch = Perch.objects.create(size=perch_size, smelly=perch_smelly)
+... parrot = Parrot.objects.create(state=state, perch=perch)
+... Tag.objects.update_tags(parrot, tags)
+
+>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True)]
+[(u'bar', 3), (u'baz', 1), (u'foo', 2), (u'ter', 3)]
+>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, min_count=2)]
+[(u'bar', 3), (u'foo', 2), (u'ter', 3)]
+
+# Limiting results to a subset of the model
+>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(state='no more'))]
+[(u'foo', 1), (u'ter', 1)]
+>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(state__startswith='p'))]
+[(u'bar', 2), (u'baz', 1), (u'foo', 1), (u'ter', 1)]
+>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(perch__size__gt=4))]
+[(u'bar', 2), (u'baz', 1), (u'foo', 1), (u'ter', 1)]
+>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(perch__smelly=True))]
+[(u'bar', 1), (u'foo', 2), (u'ter', 1)]
+>>> [(tag.name, tag.count) for tag in Tag.objects.usage_for_model(Parrot, min_count=2, filters=dict(perch__smelly=True))]
+[(u'foo', 2)]
+>>> [(tag.name, hasattr(tag, 'counts')) for tag in Tag.objects.usage_for_model(Parrot, filters=dict(perch__size__gt=4))]
+[(u'bar', False), (u'baz', False), (u'foo', False), (u'ter', False)]
+>>> [(tag.name, hasattr(tag, 'counts')) for tag in Tag.objects.usage_for_model(Parrot, filters=dict(perch__size__gt=99))]
+[]
+
+# Related tags
+>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar']), Parrot, counts=True)]
+[(u'baz', 1), (u'foo', 1), (u'ter', 2)]
+>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar']), Parrot, min_count=2)]
+[(u'ter', 2)]
+>>> [tag.name for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar']), Parrot, counts=False)]
+[u'baz', u'foo', u'ter']
+>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar', 'ter']), Parrot, counts=True)]
+[(u'baz', 1)]
+>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar', 'ter', 'baz']), Parrot, counts=True)]
+[]
+
+# Once again, with feeling (strings)
+>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model('bar', Parrot, counts=True)]
+[(u'baz', 1), (u'foo', 1), (u'ter', 2)]
+>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model('bar', Parrot, min_count=2)]
+[(u'ter', 2)]
+>>> [tag.name for tag in Tag.objects.related_for_model('bar', Parrot, counts=False)]
+[u'baz', u'foo', u'ter']
+>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(['bar', 'ter'], Parrot, counts=True)]
+[(u'baz', 1)]
+>>> [(tag.name, tag.count) for tag in Tag.objects.related_for_model(['bar', 'ter', 'baz'], Parrot, counts=True)]
+[]
+
+# Retrieving tagged objects by Model ##########################################
+
+>>> foo = Tag.objects.get(name='foo')
+>>> bar = Tag.objects.get(name='bar')
+>>> baz = Tag.objects.get(name='baz')
+>>> ter = Tag.objects.get(name='ter')
+>>> TaggedItem.objects.get_by_model(Parrot, foo)
+[<Parrot: no more>, <Parrot: pining for the fjords>]
+>>> TaggedItem.objects.get_by_model(Parrot, bar)
+[<Parrot: late>, <Parrot: passed on>, <Parrot: pining for the fjords>]
+
+# Intersections are supported
+>>> TaggedItem.objects.get_by_model(Parrot, [foo, baz])
+[]
+>>> TaggedItem.objects.get_by_model(Parrot, [foo, bar])
+[<Parrot: pining for the fjords>]
+>>> TaggedItem.objects.get_by_model(Parrot, [bar, ter])
+[<Parrot: late>, <Parrot: passed on>]
+
+# You can also pass Tag QuerySets
+>>> TaggedItem.objects.get_by_model(Parrot, Tag.objects.filter(name__in=['foo', 'baz']))
+[]
+>>> TaggedItem.objects.get_by_model(Parrot, Tag.objects.filter(name__in=['foo', 'bar']))
+[<Parrot: pining for the fjords>]
+>>> TaggedItem.objects.get_by_model(Parrot, Tag.objects.filter(name__in=['bar', 'ter']))
+[<Parrot: late>, <Parrot: passed on>]
+
+# You can also pass strings and lists of strings
+>>> TaggedItem.objects.get_by_model(Parrot, 'foo baz')
+[]
+>>> TaggedItem.objects.get_by_model(Parrot, 'foo bar')
+[<Parrot: pining for the fjords>]
+>>> TaggedItem.objects.get_by_model(Parrot, 'bar ter')
+[<Parrot: late>, <Parrot: passed on>]
+>>> TaggedItem.objects.get_by_model(Parrot, ['foo', 'baz'])
+[]
+>>> TaggedItem.objects.get_by_model(Parrot, ['foo', 'bar'])
+[<Parrot: pining for the fjords>]
+>>> TaggedItem.objects.get_by_model(Parrot, ['bar', 'ter'])
+[<Parrot: late>, <Parrot: passed on>]
+
+# Issue 50 - Get by non-existent tag
+>>> TaggedItem.objects.get_by_model(Parrot, 'argatrons')
+[]
+
+# Retrieving related objects by Model #########################################
+
+# Related instances of the same Model
+>>> l1 = Link.objects.create(name='link 1')
+>>> Tag.objects.update_tags(l1, 'tag1 tag2 tag3 tag4 tag5')
+>>> l2 = Link.objects.create(name='link 2')
+>>> Tag.objects.update_tags(l2, 'tag1 tag2 tag3')
+>>> l3 = Link.objects.create(name='link 3')
+>>> Tag.objects.update_tags(l3, 'tag1')
+>>> l4 = Link.objects.create(name='link 4')
+>>> TaggedItem.objects.get_related(l1, Link)
+[<Link: link 2>, <Link: link 3>]
+>>> TaggedItem.objects.get_related(l1, Link, num=1)
+[<Link: link 2>]
+>>> TaggedItem.objects.get_related(l4, Link)
+[]
+
+# Related instance of a different Model
+>>> a1 = Article.objects.create(name='article 1')
+>>> Tag.objects.update_tags(a1, 'tag1 tag2 tag3 tag4')
+>>> TaggedItem.objects.get_related(a1, Link)
+[<Link: link 1>, <Link: link 2>, <Link: link 3>]
+>>> Tag.objects.update_tags(a1, 'tag6')
+>>> TaggedItem.objects.get_related(a1, Link)
+[]
+"""
diff --git a/tagging/utils.py b/tagging/utils.py
new file mode 100644
index 0000000..af50bac
--- /dev/null
+++ b/tagging/utils.py
@@ -0,0 +1,140 @@
+import math
+import re
+import types
+
+from django.db.models.query import QuerySet
+from django.utils.encoding import force_unicode, smart_unicode
+
+# Python 2.3 compatibility
+if not hasattr(__builtins__, 'set'):
+ from sets import Set as set
+
+find_tag_re = re.compile(r'[-\w]+', re.U)
+
+def get_tag_name_list(tag_names):
+ """
+ Finds tag names in the given string and return them as a list.
+ """
+ if tag_names is not None:
+ tag_names = force_unicode(tag_names)
+ results = find_tag_re.findall(tag_names or '')
+ return results
+
+def get_tag_list(tags):
+ """
+ Utility function for accepting tag input in a flexible manner.
+
+ If a ``Tag`` object is given, it will be returned in a list as
+ its single occupant.
+
+ If given, the tag names in the following will be used to create a
+ ``Tag`` ``QuerySet``:
+
+ * A string, which may contain multiple tag names.
+ * A list or tuple of strings corresponding to tag names.
+ * A list or tuple of integers corresponding to tag ids.
+
+ If given, the following will be returned as-is:
+
+ * A list or tuple of ``Tag`` objects.
+ * A ``Tag`` ``QuerySet``.
+ """
+ from tagging.models import Tag
+ if isinstance(tags, Tag):
+ return [tags]
+ elif isinstance(tags, QuerySet) and tags.model is Tag:
+ return tags
+ elif isinstance(tags, types.StringTypes):
+ return Tag.objects.filter(name__in=get_tag_name_list(tags))
+ elif isinstance(tags, (types.ListType, types.TupleType)):
+ if len(tags) == 0:
+ return tags
+ contents = set()
+ for item in tags:
+ if isinstance(item, types.StringTypes):
+ contents.add('string')
+ elif isinstance(item, Tag):
+ contents.add('tag')
+ elif isinstance(item, (types.IntType, types.LongType)):
+ contents.add('int')
+ if len(contents) == 1:
+ if 'string' in contents:
+ return Tag.objects.filter(name__in=[smart_unicode(tag) \
+ for tag in tags])
+ elif 'tag' in contents:
+ return tags
+ elif 'int' in contents:
+ return Tag.objects.filter(id__in=tags)
+ else:
+ raise ValueError(u'If a list or tuple of tags is provided, they must all be tag names, Tag objects or Tag ids.')
+ else:
+ raise ValueError(u'The tag input given was invalid.')
+
+def get_tag(tag):
+ """
+ Utility function for accepting single tag input in a flexible
+ manner.
+
+ If a ``Tag`` object is given it will be returned as-is; if a
+ string or integer are given, they will be used to lookup the
+ appropriate ``Tag``.
+
+ If no matching tag can be found, ``None`` will be returned.
+ """
+ from tagging.models import Tag
+ if isinstance(tag, Tag):
+ return tag
+
+ try:
+ if isinstance(tag, types.StringTypes):
+ return Tag.objects.get(name=tag)
+ elif isinstance(tag, (types.IntType, types.LongType)):
+ return Tag.objects.get(id=tag)
+ except Tag.DoesNotExist:
+ pass
+
+ return None
+
+# Font size distribution algorithms
+LOGARITHMIC, LINEAR = 1, 2
+
+def calculate_cloud(tags, steps=4, distribution=LOGARITHMIC):
+ """
+ Add a ``font_size`` attribute to each tag according to the
+ frequency of its use, as indicated by its ``count``
+ attribute.
+
+ ``steps`` defines the range of font sizes - ``font_size`` will
+ be an integer between 1 and ``steps`` (inclusive).
+
+ ``distribution`` defines the type of font size distribution
+ algorithm which will be used - logarithmic or linear. It must be
+ either ``tagging.utils.LOGARITHMIC`` or ``tagging.utils.LINEAR``.
+
+ The algorithm to scale the tags logarithmically is from a
+ blog post by Anders Pearson, 'Scaling tag clouds':
+ http://thraxil.com/users/anders/posts/2005/12/13/scaling-tag-clouds/
+ """
+ if len(tags) > 0:
+ thresholds = []
+ counts = [tag.count for tag in tags]
+ max_weight = float(max(counts))
+ min_weight = float(min(counts))
+
+ # Set up the appropriate thresholds
+ if distribution == LOGARITHMIC:
+ thresholds = [math.pow(max_weight - min_weight + 1, float(i) / float(steps)) \
+ for i in range(1, steps + 1)]
+ elif distribution == LINEAR:
+ delta = (max_weight - min_weight) / float(steps)
+ thresholds = [min_weight + i * delta for i in range(1, steps + 1)]
+ else:
+ raise ValueError(u'Invalid font size distribution algorithm specified: %s.' % distribution)
+
+ for tag in tags:
+ font_set = False
+ for i in range(steps):
+ if not font_set and tag.count <= thresholds[i]:
+ tag.font_size = i + 1
+ font_set = True
+ return tags
diff --git a/tagging/validators.py b/tagging/validators.py
new file mode 100644
index 0000000..363fa6d
--- /dev/null
+++ b/tagging/validators.py
@@ -0,0 +1,33 @@
+import re
+
+from django.core.validators import ValidationError
+from django.utils.encoding import smart_unicode
+
+from tagging.utils import get_tag_name_list
+
+tag_re = re.compile(r'^[-\w]+$', re.U)
+tag_list_re = re.compile(r'^[-\w]+(?:(?:,\s|[,\s])[-\w]+)*$', re.U)
+
+def isTagList(field_data, all_data):
+ """
+ Validates that ``field_data`` is a valid list of tag names,
+ separated by a single comma, a single space or a comma followed
+ by a space.
+ """
+ if field_data is not None:
+ field_data = smart_unicode(field_data)
+ if not tag_list_re.search(field_data):
+ raise ValidationError(u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens, with a comma, space or comma followed by space used to separate each tag name.')
+ tag_names = get_tag_name_list(field_data)
+ for tag_name in tag_names:
+ if len(tag_name) > 50:
+ raise ValidationError(u'Tag names must be no longer than 50 characters.')
+
+def isTag(field_data, all_data):
+ """
+ Validates that ``field_data`` is a valid tag name.
+ """
+ if field_data is not None:
+ field_data = smart_unicode(field_data)
+ if not tag_re.match(field_data):
+ raise ValidationError(u'Tag names must contain only unicode alphanumeric characters, numbers, underscores or hyphens.')
diff --git a/tagging/views.py b/tagging/views.py
new file mode 100644
index 0000000..4e424f1
--- /dev/null
+++ b/tagging/views.py
@@ -0,0 +1,48 @@
+from django.http import Http404
+from django.views.generic.list_detail import object_list
+
+from tagging.models import Tag, TaggedItem
+from tagging.utils import get_tag
+
+def tagged_object_list(request, model=None, tag=None, related_tags=False,
+ related_tag_counts=True, **kwargs):
+ """
+ A thin wrapper around
+ ``django.views.generic.list_detail.object_list`` which creates a
+ ``QuerySet`` containing instances of the given model tagged with
+ the given tag.
+
+ In addition to the context variables set up by ``object_list``, a
+ ``tag`` context variable will contain the ``Tag`` instance
+ for the given tag.
+
+ If ``related_tags`` is ``True``, a ``related_tags`` context variable
+ will contain tags related to the given tag for the given model.
+ Additionally, if ``related_tag_counts`` is ``True``, each related tag
+ will have a ``count`` attribute indicating the number of items which
+ have it in addition to the given tag.
+ """
+ if model is None:
+ try:
+ model = kwargs['model']
+ except KeyError:
+ raise AttributeError(u'tagged_object_list must be called with a model.')
+
+ if tag is None:
+ try:
+ tag = kwargs['tag']
+ except KeyError:
+ raise AttributeError(u'tagged_object_list must be called with a tag.')
+
+ tag_instance = get_tag(tag)
+ if tag_instance is None:
+ raise Http404(u'No Tag found matching "%s".' % tag)
+ queryset = TaggedItem.objects.get_by_model(model, tag_instance)
+ if not kwargs.has_key('extra_context'):
+ kwargs['extra_context'] = {}
+ kwargs['extra_context']['tag'] = tag_instance
+ if related_tags:
+ kwargs['extra_context']['related_tags'] = \
+ Tag.objects.related_for_model(tag_instance, model,
+ counts=related_tag_counts)
+ return object_list(request, queryset, **kwargs)