├── CHANGELOG.txt ├── INSTALL.txt ├── LICENSE.txt ├── MANIFEST.in ├── README.txt ├── docs └── overview.txt ├── setup.py └── tagging ├── __init__.py ├── admin.py ├── fields.py ├── forms.py ├── generic.py ├── managers.py ├── models.py ├── settings.py ├── templatetags ├── __init__.py └── tagging_tags.py ├── tests ├── __init__.py ├── models.py ├── settings.py ├── tags.txt └── tests.py ├── utils.py └── views.py /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | ======================== 2 | Django Tagging Changelog 3 | ======================== 4 | 5 | Version 0.3.1, 22nd January 2010: 6 | --------------------------------- 7 | 8 | * Fixed Django 1.2 support (did not add anything new) 9 | * Fixed #95 — tagging.register won't stomp on model attributes 10 | 11 | Version 0.3.0, 22nd August 2009: 12 | -------------------------------- 13 | 14 | * Fixes for Django 1.0 compatibility. 15 | 16 | * Added a ``tagging.generic`` module for working with list of objects 17 | which have generic relations, containing a ``fetch_content_objects`` 18 | function for retrieving content objects for a list of ``TaggedItem``s 19 | using ``number_of_content_types + 1`` queries rather than the 20 | ``number_of_tagged_items * 2`` queries you'd get by iterating over the 21 | list and accessing each item's ``object`` attribute. 22 | 23 | * Added a ``usage`` method to ``ModelTagManager``. 24 | 25 | * ``TaggedItemManager``'s methods now accept a ``QuerySet`` or a 26 | ``Model`` class. If a ``QuerySet`` is given, it will be used as the 27 | basis for the ``QuerySet``s the methods return, so can be used to 28 | restrict results to a subset of a model's instances. The 29 | `tagged_object_list`` generic view and ModelTaggedItemManager`` 30 | manager have been updated accordingly. 31 | 32 | * Removed ``tagging\tests\runtests.py``, as tests can be run with 33 | ``django-admin.py test --settings=tagging.tests.settings``. 34 | 35 | * A ``tagging.TagDescriptor`` is now added to models when registered. 36 | This returns a ``tagging.managers.ModelTagManager`` when accessed on a 37 | model class, and provide access to and control over tags when used on 38 | an instance. 39 | 40 | * Added ``tagging.register`` to register models with the tagging app. 41 | Initially, a ``tagging.managers.ModelTaggedItemManager`` is added for 42 | convenient access to tagged items. 43 | 44 | * Moved ``TagManager`` and ``TaggedItemManager`` to ``models.py`` - gets 45 | rid of some import related silliness, as ``TagManager`` needs access 46 | to ``TaggedItem``. 47 | 48 | Version 0.2.1, 16th Jan 2008: 49 | ----------------------------- 50 | 51 | * Fixed a bug with space-delimited tag input handling - duplicates 52 | weren't being removed and the list of tag names wasn't sorted. 53 | 54 | Version 0.2, 12th Jan 2008: 55 | --------------------------- 56 | 57 | Packaged from revision 122 in Subversion; download at 58 | http://django-tagging.googlecode.com/files/tagging-0.2.zip 59 | 60 | * Added a ``tag_cloud_for_model`` template tag. 61 | 62 | * Added a ``MAX_TAG_LENGTH`` setting. 63 | 64 | * Multi-word tags are here - simple space-delimited input still works. 65 | Double quotes and/or commas are used to delineate multi- word tags. 66 | As far as valid tag contents - anything goes, at least initially. 67 | 68 | * BACKWARDS-INCOMPATIBLE CHANGE - ``django.utils.get_tag_name_list`` and 69 | related regular expressions have been removed in favour of a new tag 70 | input parsing function, ``django.utils.parse_tag_input``. 71 | 72 | * BACKWARDS-INCOMPATIBLE CHANGE - ``Tag`` and ``TaggedItem`` no longer 73 | declare an explicit ``db_table``. If you can't rename your tables, 74 | you'll have to put these back in manually. 75 | 76 | * Fixed a bug in calculation of logarithmic tag clouds - ``font_size`` 77 | attributes were not being set in some cases when the least used tag in 78 | the cloud had been used more than once. 79 | 80 | * For consistency of return type, ``TaggedItemManager.get_by_model`` now 81 | returns an empty ``QuerySet`` instead of an empty ``list`` if 82 | non-existent tags were given. 83 | 84 | * Fixed a bug caused by ``cloud_for_model`` not passing its 85 | ``distribution`` argument to ``calculate_cloud``. 86 | 87 | * Added ``TaggedItemManager.get_union_by_model`` for looking up items 88 | tagged with any one of a list of tags. 89 | 90 | * Added ``TagManager.add_tag`` for adding a single extra tag to an 91 | object. 92 | 93 | * Tag names can now be forced to lowercase before they are saved to the 94 | database by adding the appropriate ``FORCE_LOWERCASE_TAGS`` setting to 95 | your project's settings module. This feature defaults to being off. 96 | 97 | * Fixed a bug where passing non-existent tag names to 98 | ``TaggedItemManager.get_by_model`` caused database errors with some 99 | backends. 100 | 101 | * Added ``tagged_object_list`` generic view for displaying paginated 102 | lists of objects for a given model which have a given tag, and 103 | optionally related tags for that model. 104 | 105 | 106 | Version 0.1, 30th May 2007: 107 | --------------------------- 108 | 109 | Packaged from revision 79 in Subversion; download at 110 | http://django-tagging.googlecode.com/files/tagging-0.1.zip 111 | 112 | * First packaged version using distutils. 113 | -------------------------------------------------------------------------------- /INSTALL.txt: -------------------------------------------------------------------------------- 1 | Thanks for downloading django-tagging. 2 | 3 | To install it, run the following command inside this directory: 4 | 5 | python setup.py install 6 | 7 | Or if you'd prefer you can simply place the included ``tagging`` 8 | directory somewhere on your Python path, or symlink to it from 9 | somewhere on your Python path; this is useful if you're working from a 10 | Subversion checkout. 11 | 12 | Note that this application requires Python 2.3 or later, and Django 13 | 1.0 or later. You can obtain Python from http://www.python.org/ and 14 | Django from http://www.djangoproject.com/. -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Django Tagging 2 | -------------- 3 | 4 | Copyright (c) 2007, Jonathan Buchanan 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | 23 | Initially based on code from James Bennett's Cab: 24 | 25 | Cab 26 | --- 27 | 28 | Copyright (c) 2007, James Bennett 29 | All rights reserved. 30 | 31 | Redistribution and use in source and binary forms, with or without 32 | modification, are permitted provided that the following conditions are 33 | met: 34 | 35 | * Redistributions of source code must retain the above copyright 36 | notice, this list of conditions and the following disclaimer. 37 | * Redistributions in binary form must reproduce the above 38 | copyright notice, this list of conditions and the following 39 | disclaimer in the documentation and/or other materials provided 40 | with the distribution. 41 | * Neither the name of the author nor the names of other 42 | contributors may be used to endorse or promote products derived 43 | from this software without specific prior written permission. 44 | 45 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 46 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 47 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 48 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 49 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 50 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 51 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 52 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 53 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 54 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 55 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.txt 2 | include INSTALL.txt 3 | include LICENSE.txt 4 | include MANIFEST.in 5 | include README.txt 6 | recursive-include docs *.txt 7 | recursive-include tagging/tests *.txt 8 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | ============== 2 | Django Tagging 3 | ============== 4 | 5 | This is a generic tagging application for Django projects 6 | 7 | For installation instructions, see the file "INSTALL.txt" in this 8 | directory; for instructions on how to use this application, and on 9 | what it provides, see the file "overview.txt" in the "docs/" 10 | directory. -------------------------------------------------------------------------------- /docs/overview.txt: -------------------------------------------------------------------------------- 1 | ============== 2 | Django Tagging 3 | ============== 4 | 5 | A generic tagging application for `Django`_ projects, which allows 6 | association of a number of tags with any Django model instance and makes 7 | retrieval of tags simple. 8 | 9 | .. _`Django`: http://www.djangoproject.com 10 | 11 | .. contents:: 12 | :depth: 3 13 | 14 | 15 | Installation 16 | ============ 17 | 18 | Installing an official release 19 | ------------------------------ 20 | 21 | Official releases are made available from 22 | http://code.google.com/p/django-tagging/ 23 | 24 | Source distribution 25 | ~~~~~~~~~~~~~~~~~~~ 26 | 27 | Download the .zip distribution file and unpack it. Inside is a script 28 | named ``setup.py``. Enter this command:: 29 | 30 | python setup.py install 31 | 32 | ...and the package will install automatically. 33 | 34 | Windows installer 35 | ~~~~~~~~~~~~~~~~~ 36 | 37 | A Windows installer is also made available - download the .exe 38 | distribution file and launch it to install the application. 39 | 40 | An uninstaller will also be created, accessible through Add/Remove 41 | Programs in your Control Panel. 42 | 43 | Installing the development version 44 | ---------------------------------- 45 | 46 | Alternatively, if you'd like to update Django Tagging occasionally to pick 47 | up the latest bug fixes and enhancements before they make it into an 48 | official release, perform a `Subversion`_ checkout instead. The following 49 | command will check the application's development branch out to an 50 | ``tagging-trunk`` directory:: 51 | 52 | svn checkout http://django-tagging.googlecode.com/svn/trunk/ tagging-trunk 53 | 54 | Add the resulting folder to your `PYTHONPATH`_ or symlink (`junction`_, 55 | if you're on Windows) the ``tagging`` directory inside it into a 56 | directory which is on your PYTHONPATH, such as your Python 57 | installation's ``site-packages`` directory. 58 | 59 | You can verify that the application is available on your PYTHONPATH by 60 | opening a Python interpreter and entering the following commands:: 61 | 62 | >>> import tagging 63 | >>> tagging.VERSION 64 | (0, 3, 'pre') 65 | 66 | When you want to update your copy of the Django Tagging source code, run 67 | the command ``svn update`` from within the ``tagging-trunk`` directory. 68 | 69 | .. caution:: 70 | 71 | The development version may contain bugs which are not present in the 72 | release version and introduce backwards-incompatible changes. 73 | 74 | If you're tracking trunk, keep an eye on the `CHANGELOG`_ and the 75 | `backwards-incompatible changes wiki page`_ before you update your 76 | copy of the source code. 77 | 78 | .. _`Subversion`: http://subversion.tigris.org 79 | .. _`PYTHONPATH`: http://www.python.org/doc/2.5.2/tut/node8.html#SECTION008120000000000000000 80 | .. _`junction`: http://www.microsoft.com/technet/sysinternals/FileAndDisk/Junction.mspx 81 | .. _`CHANGELOG`: http://django-tagging.googlecode.com/svn/trunk/CHANGELOG.txt 82 | .. _`backwards-incompatible changes wiki page`: http://code.google.com/p/django-tagging/wiki/BackwardsIncompatibleChanges 83 | 84 | Using Django Tagging in your applications 85 | ----------------------------------------- 86 | 87 | Once you've installed Django Tagging and want to use it in your Django 88 | applications, do the following: 89 | 90 | 1. Put ``'tagging'`` in your ``INSTALLED_APPS`` setting. 91 | 2. Run the command ``manage.py syncdb``. 92 | 93 | The ``syncdb`` command creates the necessary database tables and 94 | creates permission objects for all installed apps that need them. 95 | 96 | That's it! 97 | 98 | 99 | Settings 100 | ======== 101 | 102 | Some of the application's behaviour can be configured by adding the 103 | appropriate settings to your project's settings file. 104 | 105 | The following settings are available: 106 | 107 | FORCE_LOWERCASE_TAGS 108 | -------------------- 109 | 110 | Default: ``False`` 111 | 112 | A boolean that turns on/off forcing of all tag names to lowercase before 113 | they are saved to the database. 114 | 115 | MAX_TAG_LENGTH 116 | -------------- 117 | 118 | Default: ``50`` 119 | 120 | An integer which specifies the maximum length which any tag is allowed 121 | to have. This is used for validation in the ``django.contrib.admin`` 122 | application and in any forms automatically generated using ``ModelForm``. 123 | 124 | 125 | Registering your models 126 | ======================= 127 | 128 | Your Django models can be registered with the tagging application to 129 | access some additional tagging-related features. 130 | 131 | .. note:: 132 | 133 | You don't *have* to register your models in order to use them with 134 | the tagging application - many of the features added by registration 135 | are just convenience wrappers around the tagging API provided by the 136 | ``Tag`` and ``TaggedItem`` models and their managers, as documented 137 | further below. 138 | 139 | The ``register`` function 140 | ------------------------- 141 | 142 | To register a model, import the ``tagging`` module and call its 143 | ``register`` function, like so:: 144 | 145 | from django.db import models 146 | 147 | import tagging 148 | 149 | class Widget(models.Model): 150 | name = models.CharField(max_length=50) 151 | 152 | tagging.register(Widget) 153 | 154 | The following argument is required: 155 | 156 | ``model`` 157 | The model class to be registered. 158 | 159 | An exception will be raised if you attempt to register the same class 160 | more than once. 161 | 162 | The following arguments are optional, with some recommended defaults - 163 | take care to specify different attribute names if the defaults clash 164 | with your model class' definition: 165 | 166 | ``tag_descriptor_attr`` 167 | The name of an attribute in the model class which will hold a tag 168 | descriptor for the model. Default: ``'tags'`` 169 | 170 | See `TagDescriptor`_ below for details about the use of this 171 | descriptor. 172 | 173 | ``tagged_item_manger_attr`` 174 | The name of an attribute in the model class which will hold a custom 175 | manager for accessing tagged items for the model. Default: 176 | ``'tagged'``. 177 | 178 | See `ModelTaggedItemManager`_ below for details about the use of this 179 | manager. 180 | 181 | ``TagDescriptor`` 182 | ----------------- 183 | 184 | When accessed through the model class itself, this descriptor will return 185 | a ``ModelTagManager`` for the model. See `ModelTagManager`_ below for 186 | more details about its use. 187 | 188 | When accessed through a model instance, this descriptor provides a handy 189 | means of retrieving, updating and deleting the instance's tags. For 190 | example:: 191 | 192 | >>> widget = Widget.objects.create(name='Testing descriptor') 193 | >>> widget.tags 194 | [] 195 | >>> widget.tags = 'toast, melted cheese, butter' 196 | >>> widget.tags 197 | [, , ] 198 | >>> del widget.tags 199 | >>> widget.tags 200 | [] 201 | 202 | ``ModelTagManager`` 203 | ------------------- 204 | 205 | A manager for retrieving tags used by a particular model. 206 | 207 | Defines the following methods: 208 | 209 | * ``get_query_set()`` -- as this method is redefined, any ``QuerySets`` 210 | created by this model will be initially restricted to contain the 211 | distinct tags used by all the model's instances. 212 | 213 | * ``cloud(*args, **kwargs)`` -- creates a list of tags used by the 214 | model's instances, with ``count`` and ``font_size`` attributes set for 215 | use in displaying a tag cloud. 216 | 217 | See the documentation on ``Tag``'s manager's `cloud_for_model method`_ 218 | for information on additional arguments which can be given. 219 | 220 | * ``related(self, tags, *args, **kwargs)`` -- creates a list of tags 221 | used by the model's instances, which are also used by all instance 222 | which have the given ``tags``. 223 | 224 | See the documentation on ``Tag``'s manager's 225 | `related_for_model method`_ for information on additional arguments 226 | which can be given. 227 | 228 | * ``usage(self, *args, **kwargs))`` -- creates a list of tags used by 229 | the model's instances, with optional usages counts, restriction based 230 | on usage counts and restriction of the model instances from which 231 | usage and counts are determined. 232 | 233 | See the documentation on ``Tag``'s manager's `usage_for_model method`_ 234 | for information on additional arguments which can be given. 235 | 236 | Example usage:: 237 | 238 | # Create a ``QuerySet`` of tags used by Widget instances 239 | Widget.tags.all() 240 | 241 | # Retrieve a list of tags used by Widget instances with usage counts 242 | Widget.tags.usage(counts=True) 243 | 244 | # Retrieve tags used by instances of WIdget which are also tagged with 245 | # 'cheese' and 'toast' 246 | Widget.tags.related(['cheese', 'toast'], counts=True, min_count=3) 247 | 248 | ``ModelTaggedItemManager`` 249 | -------------------------- 250 | 251 | A manager for retrieving model instance for a particular model, based on 252 | their tags. 253 | 254 | * ``related_to(obj, queryset=None, num=None)`` -- creates a list 255 | of model instances which are related to ``obj``, based on its tags. If 256 | a ``queryset`` argument is provided, it will be used to restrict the 257 | resulting list of model instances. 258 | 259 | If ``num`` is given, a maximum of ``num`` instances will be returned. 260 | 261 | * ``with_all(tags, queryset=None)`` -- creates a ``QuerySet`` containing 262 | model instances which are tagged with *all* the given tags. If a 263 | ``queryset`` argument is provided, it will be used as the basis for 264 | the resulting ``QuerySet``. 265 | 266 | * ``with_any(tags, queryset=None)`` -- creates a ``QuerySet`` containing model 267 | instances which are tagged with *any* the given tags. If a ``queryset`` 268 | argument is provided, it will be used as the basis for the resulting 269 | ``QuerySet``. 270 | 271 | 272 | Tags 273 | ==== 274 | 275 | Tags are represented by the ``Tag`` model, which lives in the 276 | ``tagging.models`` module. 277 | 278 | API reference 279 | ------------- 280 | 281 | Fields 282 | ~~~~~~ 283 | 284 | ``Tag`` objects have the following fields: 285 | 286 | * ``name`` -- The name of the tag. This is a unique value. 287 | 288 | Manager functions 289 | ~~~~~~~~~~~~~~~~~ 290 | 291 | The ``Tag`` model has a custom manager which has the following helper 292 | methods: 293 | 294 | * ``update_tags(obj, tag_names)`` -- updates tags associated with an 295 | object. 296 | 297 | ``tag_names`` is a string containing tag names with which ``obj`` 298 | should be tagged. 299 | 300 | If ``tag_names`` is ``None`` or ``''``, the object's tags will be 301 | cleared. 302 | 303 | * ``add_tag(obj, tag_name)`` -- associates a tag with an an object. 304 | 305 | ``tag_name`` is a string containing a tag name with which ``obj`` 306 | should be tagged. 307 | 308 | * ``get_for_object(obj)`` -- returns a ``QuerySet`` containing all 309 | ``Tag`` objects associated with ``obj``. 310 | 311 | .. _`usage_for_model method`: 312 | 313 | * ``usage_for_model(model, counts=False, min_count=None, filters=None)`` 314 | -- returns a list of ``Tag`` objects associated with instances of 315 | ``model``. 316 | 317 | If ``counts`` is ``True``, a ``count`` attribute will be added to each 318 | tag, indicating how many times it has been associated with instances 319 | of ``model``. 320 | 321 | If ``min_count`` is given, only tags which have a ``count`` greater 322 | than or equal to ``min_count`` will be returned. Passing a value for 323 | ``min_count`` implies ``counts=True``. 324 | 325 | To limit the tags (and counts, if specified) returned to those used by 326 | a subset of the model's instances, pass a dictionary of field lookups 327 | to be applied to ``model`` as the ``filters`` argument. 328 | 329 | .. _`related_for_model method`: 330 | 331 | * ``related_for_model(tags, Model, counts=False, min_count=None)`` 332 | -- returns a list of tags related to a given list of tags - that is, 333 | other tags used by items which have all the given tags. 334 | 335 | If ``counts`` is ``True``, a ``count`` attribute will be added to each 336 | tag, indicating the number of items which have it in addition to the 337 | given list of tags. 338 | 339 | If ``min_count`` is given, only tags which have a ``count`` greater 340 | than or equal to ``min_count`` will be returned. Passing a value for 341 | ``min_count`` implies ``counts=True``. 342 | 343 | .. _`cloud_for_model method`: 344 | 345 | * ``cloud_for_model(Model, steps=4, distribution=LOGARITHMIC, 346 | filters=None, min_count=None)`` -- returns a list of the distinct 347 | ``Tag`` objects associated with instances of ``Model``, each having a 348 | ``count`` attribute as above and an additional ``font_size`` 349 | attribute, for use in creation of a tag cloud (a type of weighted 350 | list). 351 | 352 | ``steps`` defines the number of font sizes available - ``font_size`` 353 | may be an integer between ``1`` and ``steps``, inclusive. 354 | 355 | ``distribution`` defines the type of font size distribution algorithm 356 | which will be used - logarithmic or linear. It must be either 357 | ``tagging.utils.LOGARITHMIC`` or ``tagging.utils.LINEAR``. 358 | 359 | To limit the tags displayed in the cloud to those associated with a 360 | subset of the Model's instances, pass a dictionary of field lookups to 361 | be applied to the given Model as the ``filters`` argument. 362 | 363 | To limit the tags displayed in the cloud to those with a ``count`` 364 | greater than or equal to ``min_count``, pass a value for the 365 | ``min_count`` argument. 366 | 367 | * ``usage_for_queryset(queryset, counts=False, min_count=None)`` -- 368 | Obtains a list of tags associated with instances of a model contained 369 | in the given queryset. 370 | 371 | If ``counts`` is True, a ``count`` attribute will be added to each tag, 372 | indicating how many times it has been used against the Model class in 373 | question. 374 | 375 | If ``min_count`` is given, only tags which have a ``count`` greater 376 | than or equal to ``min_count`` will be returned. 377 | 378 | Passing a value for ``min_count`` implies ``counts=True``. 379 | 380 | Basic usage 381 | ----------- 382 | 383 | Tagging objects and retrieving an object's tags 384 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 385 | 386 | Objects may be tagged using the ``update_tags`` helper function:: 387 | 388 | >>> from shop.apps.products.models import Widget 389 | >>> from tagging.models import Tag 390 | >>> widget = Widget.objects.get(pk=1) 391 | >>> Tag.objects.update_tags(widget, 'house thing') 392 | 393 | Retrieve tags for an object using the ``get_for_object`` helper 394 | function:: 395 | 396 | >>> Tag.objects.get_for_object(widget) 397 | [, ] 398 | 399 | Tags are created, associated and unassociated accordingly when you use 400 | ``update_tags`` and ``add_tag``:: 401 | 402 | >>> Tag.objects.update_tags(widget, 'house monkey') 403 | >>> Tag.objects.get_for_object(widget) 404 | [, ] 405 | >>> Tag.objects.add_tag(widget, 'tiles') 406 | >>> Tag.objects.get_for_object(widget) 407 | [, , ] 408 | 409 | Clear an object's tags by passing ``None`` or ``''`` to 410 | ``update_tags``:: 411 | 412 | >>> Tag.objects.update_tags(widget, None) 413 | >>> Tag.objects.get_for_object(widget) 414 | [] 415 | 416 | Retrieving tags used by a particular model 417 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 418 | 419 | To retrieve all tags used for a particular model, use the 420 | ``get_for_model`` helper function:: 421 | 422 | >>> widget1 = Widget.objects.get(pk=1) 423 | >>> Tag.objects.update_tags(widget1, 'house thing') 424 | >>> widget2 = Widget.objects.get(pk=2) 425 | >>> Tag.objects.update_tags(widget2, 'cheese toast house') 426 | >>> Tag.objects.usage_for_model(Widget) 427 | [, , , ] 428 | 429 | To get a count of how many times each tag was used for a particular 430 | model, pass in ``True`` for the ``counts`` argument:: 431 | 432 | >>> tags = Tag.objects.usage_for_model(Widget, counts=True) 433 | >>> [(tag.name, tag.count) for tag in tags] 434 | [('cheese', 1), ('house', 2), ('thing', 1), ('toast', 1)] 435 | 436 | To get counts and limit the tags returned to those with counts above a 437 | certain size, pass in a ``min_count`` argument:: 438 | 439 | >>> tags = Tag.objects.usage_for_model(Widget, min_count=2) 440 | >>> [(tag.name, tag.count) for tag in tags] 441 | [('house', 2)] 442 | 443 | You can also specify a dictionary of `field lookups`_ to be used to 444 | restrict the tags and counts returned based on a subset of the 445 | model's instances. For example, the following would retrieve all tags 446 | used on Widgets created by a user named Alan which have a size 447 | greater than 99:: 448 | 449 | >>> Tag.objects.usage_for_model(Widget, filters=dict(size__gt=99, user__username='Alan')) 450 | 451 | .. _`field lookups`: http://docs.djangoproject.com/en/dev/topics/db/queries/#field-lookups 452 | 453 | The ``usage_for_queryset`` method allows you to pass a pre-filtered 454 | queryset to be used when determining tag usage:: 455 | 456 | >>> Tag.objects.usage_for_queryset(Widget.objects.filter(size__gt=99, user__username='Alan')) 457 | 458 | Tag input 459 | --------- 460 | 461 | Tag input from users is treated as follows: 462 | 463 | * If the input doesn't contain any commas or double quotes, it is simply 464 | treated as a space-delimited list of tag names. 465 | 466 | * If the input does contain either of these characters, we parse the 467 | input like so: 468 | 469 | * Groups of characters which appear between double quotes take 470 | precedence as multi-word tags (so double quoted tag names may 471 | contain commas). An unclosed double quote will be ignored. 472 | 473 | * For the remaining input, if there are any unquoted commas in the 474 | input, the remainder will be treated as comma-delimited. Otherwise, 475 | it will be treated as space-delimited. 476 | 477 | Examples: 478 | 479 | ====================== ======================================= ================================================ 480 | Tag input string Resulting tags Notes 481 | ====================== ======================================= ================================================ 482 | apple ball cat [``apple``], [``ball``], [``cat``] No commas, so space delimited 483 | apple, ball cat [``apple``], [``ball cat``] Comma present, so comma delimited 484 | "apple, ball" cat dog [``apple, ball``], [``cat``], [``dog``] All commas are quoted, so space delimited 485 | "apple, ball", cat dog [``apple, ball``], [``cat dog``] Contains an unquoted comma, so comma delimited 486 | apple "ball cat" dog [``apple``], [``ball cat``], [``dog``] No commas, so space delimited 487 | "apple" "ball dog [``apple``], [``ball``], [``dog``] Unclosed double quote is ignored 488 | ====================== ======================================= ================================================ 489 | 490 | 491 | Tagged items 492 | ============ 493 | 494 | The relationship between a ``Tag`` and an object is represented by 495 | the ``TaggedItem`` model, which lives in the ``tagging.models`` 496 | module. 497 | 498 | API reference 499 | ------------- 500 | 501 | Fields 502 | ~~~~~~ 503 | 504 | ``TaggedItem`` objects have the following fields: 505 | 506 | * ``tag`` -- The ``Tag`` an object is associated with. 507 | * ``content_type`` -- The ``ContentType`` of the associated model 508 | instance. 509 | * ``object_id`` -- The id of the associated object. 510 | * ``object`` -- The associated object itself, accessible via the 511 | Generic Relations API. 512 | 513 | Manager functions 514 | ~~~~~~~~~~~~~~~~~ 515 | 516 | The ``TaggedItem`` model has a custom manager which has the following 517 | helper methods, which accept either a ``QuerySet`` or a ``Model`` 518 | class as one of their arguments. To restrict the objects which are 519 | returned, pass in a filtered ``QuerySet`` for this argument: 520 | 521 | * ``get_by_model(queryset_or_model, tag)`` -- creates a ``QuerySet`` 522 | containing instances of the specififed model which are tagged with 523 | the given tag or tags. 524 | 525 | * ``get_intersection_by_model(queryset_or_model, tags)`` -- creates a 526 | ``QuerySet`` containing instances of the specified model which are 527 | tagged with every tag in a list of tags. 528 | 529 | ``get_by_model`` will call this function behind the scenes when you 530 | pass it a list, so you can use ``get_by_model`` instead of calling 531 | this method directly. 532 | 533 | * ``get_union_by_model(queryset_or_model, tags)`` -- creates a 534 | ``QuerySet`` containing instances of the specified model which are 535 | tagged with any tag in a list of tags. 536 | 537 | .. _`get_related method`: 538 | 539 | * ``get_related(obj, queryset_or_model, num=None)`` - returns a list of 540 | instances of the specified model which share tags with the model 541 | instance ``obj``, ordered by the number of shared tags in descending 542 | order. 543 | 544 | If ``num`` is given, a maximum of ``num`` instances will be returned. 545 | 546 | Basic usage 547 | ----------- 548 | 549 | Retrieving tagged objects 550 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 551 | 552 | Objects may be retrieved based on their tags using the ``get_by_model`` 553 | manager method:: 554 | 555 | >>> from shop.apps.products.models import Widget 556 | >>> from tagging.models import Tag 557 | >>> house_tag = Tag.objects.get(name='house') 558 | >>> TaggedItem.objects.get_by_model(Widget, house_tag) 559 | [, ] 560 | 561 | Passing a list of tags to ``get_by_model`` returns an intersection of 562 | objects which have those tags, i.e. tag1 AND tag2 ... AND tagN:: 563 | 564 | >>> thing_tag = Tag.objects.get(name='thing') 565 | >>> TaggedItem.objects.get_by_model(Widget, [house_tag, thing_tag]) 566 | [] 567 | 568 | Functions which take tags are flexible when it comes to tag input:: 569 | 570 | >>> TaggedItem.objects.get_by_model(Widget, Tag.objects.filter(name__in=['house', 'thing'])) 571 | [] 572 | >>> TaggedItem.objects.get_by_model(Widget, 'house thing') 573 | [] 574 | >>> TaggedItem.objects.get_by_model(Widget, ['house', 'thing']) 575 | [] 576 | 577 | Restricting objects returned 578 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 579 | 580 | Pass in a ``QuerySet`` to restrict the objects returned:: 581 | 582 | # Retrieve all Widgets which have a price less than 50, tagged with 'house' 583 | TaggedItem.objects.get_by_model(Widget.objects.filter(price__lt=50), 'house') 584 | 585 | # Retrieve all Widgets which have a name starting with 'a', tagged with any 586 | # of 'house', 'garden' or 'water'. 587 | TaggedItem.objects.get_union_by_model(Widget.objects.filter(name__startswith='a'), 588 | ['house', 'garden', 'water']) 589 | 590 | 591 | Utilities 592 | ========= 593 | 594 | Tag-related utility functions are defined in the ``tagging.utils`` 595 | module: 596 | 597 | ``parse_tag_input(input)`` 598 | -------------------------- 599 | 600 | Parses tag input, with multiple word input being activated and 601 | delineated by commas and double quotes. Quotes take precedence, so they 602 | may contain commas. 603 | 604 | Returns a sorted list of unique tag names. 605 | 606 | See `tag input`_ for more details. 607 | 608 | ``edit_string_for_tags(tags)`` 609 | ------------------------------ 610 | Given list of ``Tag`` instances, creates a string representation of the 611 | list suitable for editing by the user, such that submitting the given 612 | string representation back without changing it will give the same list 613 | of tags. 614 | 615 | Tag names which contain commas will be double quoted. 616 | 617 | If any tag name which isn't being quoted contains whitespace, the 618 | resulting string of tag names will be comma-delimited, otherwise it will 619 | be space-delimited. 620 | 621 | ``get_tag_list(tags)`` 622 | ---------------------- 623 | 624 | Utility function for accepting tag input in a flexible manner. 625 | 626 | If a ``Tag`` object is given, it will be returned in a list as its 627 | single occupant. 628 | 629 | If given, the tag names in the following will be used to create a 630 | ``Tag`` ``QuerySet``: 631 | 632 | * A string, which may contain multiple tag names. 633 | * A list or tuple of strings corresponding to tag names. 634 | * A list or tuple of integers corresponding to tag ids. 635 | 636 | If given, the following will be returned as-is: 637 | 638 | * A list or tuple of ``Tag`` objects. 639 | * A ``Tag`` ``QuerySet``. 640 | 641 | ``calculate_cloud(tags, steps=4, distribution=tagging.utils.LOGARITHMIC)`` 642 | -------------------------------------------------------------------------- 643 | 644 | Add a ``font_size`` attribute to each tag according to the frequency of 645 | its use, as indicated by its ``count`` attribute. 646 | 647 | ``steps`` defines the range of font sizes - ``font_size`` will be an 648 | integer between 1 and ``steps`` (inclusive). 649 | 650 | ``distribution`` defines the type of font size distribution algorithm 651 | which will be used - logarithmic or linear. It must be one of 652 | ``tagging.utils.LOGARITHMIC`` or ``tagging.utils.LINEAR``. 653 | 654 | 655 | Model Fields 656 | ============ 657 | 658 | The ``tagging.fields`` module contains fields which make it easy to 659 | integrate tagging into your models and into the 660 | ``django.contrib.admin`` application. 661 | 662 | Field types 663 | ----------- 664 | 665 | ``TagField`` 666 | ~~~~~~~~~~~~ 667 | 668 | A ``CharField`` that actually works as a relationship to tags "under 669 | the hood". 670 | 671 | Using this example model:: 672 | 673 | class Link(models.Model): 674 | ... 675 | tags = TagField() 676 | 677 | Setting tags:: 678 | 679 | >>> l = Link.objects.get(...) 680 | >>> l.tags = 'tag1 tag2 tag3' 681 | 682 | Getting tags for an instance:: 683 | 684 | >>> l.tags 685 | 'tag1 tag2 tag3' 686 | 687 | Getting tags for a model - i.e. all tags used by all instances of the 688 | model:: 689 | 690 | >>> Link.tags 691 | 'tag1 tag2 tag3 tag4 tag5' 692 | 693 | This field will also validate that it has been given a valid list of 694 | tag names, separated by a single comma, a single space or a comma 695 | followed by a space. 696 | 697 | 698 | Form fields 699 | =========== 700 | 701 | The ``tagging.forms`` module contains a ``Field`` for use with 702 | Django's `forms library`_ which takes care of validating tag name 703 | input when used in your forms. 704 | 705 | .. _`forms library`: http://docs.djangoproject.com/en/dev/topics/forms/ 706 | 707 | Field types 708 | ----------- 709 | 710 | ``TagField`` 711 | ~~~~~~~~~~~~ 712 | 713 | A form ``Field`` which is displayed as a single-line text input, which 714 | validates that the input it receives is a valid list of tag names. 715 | 716 | When you generate a form for one of your models automatically, using 717 | the ``ModelForm`` class, any ``tagging.fields.TagField`` fields in your 718 | model will automatically be represented by a ``tagging.forms.TagField`` 719 | in the generated form. 720 | 721 | 722 | Generic views 723 | ============= 724 | 725 | The ``tagging.views`` module contains views to handle simple cases of 726 | common display logic related to tagging. 727 | 728 | ``tagging.views.tagged_object_list`` 729 | ------------------------------------ 730 | 731 | **Description:** 732 | 733 | A view that displays a list of objects for a given model which have a 734 | given tag. This is a thin wrapper around the 735 | ``django.views.generic.list_detail.object_list`` view, which takes a 736 | model and a tag as its arguments (in addition to the other optional 737 | arguments supported by ``object_list``), building the appropriate 738 | ``QuerySet`` for you instead of expecting one to be passed in. 739 | 740 | **Required arguments:** 741 | 742 | * ``queryset_or_model``: A ``QuerySet`` or Django model class for the 743 | object which will be listed. 744 | 745 | * ``tag``: The tag which objects of the given model must have in 746 | order to be listed. 747 | 748 | **Optional arguments:** 749 | 750 | Please refer to the `object_list documentation`_ for additional optional 751 | arguments which may be given. 752 | 753 | * ``related_tags``: If ``True``, a ``related_tags`` context variable 754 | will also contain tags related to the given tag for the given 755 | model. 756 | 757 | * ``related_tag_counts``: If ``True`` and ``related_tags`` is 758 | ``True``, each related tag will have a ``count`` attribute 759 | indicating the number of items which have it in addition to the 760 | given tag. 761 | 762 | **Template context:** 763 | 764 | Please refer to the `object_list documentation`_ for additional 765 | template context variables which may be provided. 766 | 767 | * ``tag``: The ``Tag`` instance for the given tag. 768 | 769 | .. _`object_list documentation`: http://docs.djangoproject.com/en/dev/ref/generic-views/#django-views-generic-list-detail-object-list 770 | 771 | Example usage 772 | ~~~~~~~~~~~~~ 773 | 774 | The following sample URLconf demonstrates using this generic view to 775 | list items of a particular model class which have a given tag:: 776 | 777 | from django.conf.urls.defaults import * 778 | 779 | from tagging.views import tagged_object_list 780 | 781 | from shop.apps.products.models import Widget 782 | 783 | urlpatterns = patterns('', 784 | url(r'^widgets/tag/(?P[^/]+)/$', 785 | tagged_object_list, 786 | dict(queryset_or_model=Widget, paginate_by=10, allow_empty=True, 787 | template_object_name='widget'), 788 | name='widget_tag_detail'), 789 | ) 790 | 791 | The following sample view demonstrates wrapping this generic view to 792 | perform filtering of the objects which are listed:: 793 | 794 | from myapp.models import People 795 | 796 | from tagging.views import tagged_object_list 797 | 798 | def tagged_people(request, country_code, tag): 799 | queryset = People.objects.filter(country__code=country_code) 800 | return tagged_object_list(request, queryset, tag, paginate_by=25, 801 | allow_empty=True, template_object_name='people') 802 | 803 | 804 | Template tags 805 | ============= 806 | 807 | The ``tagging.templatetags.tagging_tags`` module defines a number of 808 | template tags which may be used to work with tags. 809 | 810 | Tag reference 811 | ------------- 812 | 813 | tags_for_model 814 | ~~~~~~~~~~~~~~ 815 | 816 | Retrieves a list of ``Tag`` objects associated with a given model and 817 | stores them in a context variable. 818 | 819 | Usage:: 820 | 821 | {% tags_for_model [model] as [varname] %} 822 | 823 | The model is specified in ``[appname].[modelname]`` format. 824 | 825 | Extended usage:: 826 | 827 | {% tags_for_model [model] as [varname] with counts %} 828 | 829 | If specified - by providing extra ``with counts`` arguments - adds a 830 | ``count`` attribute to each tag containing the number of instances of 831 | the given model which have been tagged with it. 832 | 833 | Examples:: 834 | 835 | {% tags_for_model products.Widget as widget_tags %} 836 | {% tags_for_model products.Widget as widget_tags with counts %} 837 | 838 | tag_cloud_for_model 839 | ~~~~~~~~~~~~~~~~~~~ 840 | 841 | Retrieves a list of ``Tag`` objects for a given model, with tag cloud 842 | attributes set, and stores them in a context variable. 843 | 844 | Usage:: 845 | 846 | {% tag_cloud_for_model [model] as [varname] %} 847 | 848 | The model is specified in ``[appname].[modelname]`` format. 849 | 850 | Extended usage:: 851 | 852 | {% tag_cloud_for_model [model] as [varname] with [options] %} 853 | 854 | Extra options can be provided after an optional ``with`` argument, with 855 | each option being specified in ``[name]=[value]`` format. Valid extra 856 | options are: 857 | 858 | ``steps`` 859 | Integer. Defines the range of font sizes. 860 | 861 | ``min_count`` 862 | Integer. Defines the minimum number of times a tag must have 863 | been used to appear in the cloud. 864 | 865 | ``distribution`` 866 | One of ``linear`` or ``log``. Defines the font-size 867 | distribution algorithm to use when generating the tag cloud. 868 | 869 | Examples:: 870 | 871 | {% tag_cloud_for_model products.Widget as widget_tags %} 872 | {% tag_cloud_for_model products.Widget as widget_tags with steps=9 min_count=3 distribution=log %} 873 | 874 | tags_for_object 875 | ~~~~~~~~~~~~~~~ 876 | 877 | Retrieves a list of ``Tag`` objects associated with an object and stores 878 | them in a context variable. 879 | 880 | Usage:: 881 | 882 | {% tags_for_object [object] as [varname] %} 883 | 884 | Example:: 885 | 886 | {% tags_for_object foo_object as tag_list %} 887 | 888 | tagged_objects 889 | ~~~~~~~~~~~~~~ 890 | 891 | Retrieves a list of instances of a given model which are tagged with a 892 | given ``Tag`` and stores them in a context variable. 893 | 894 | Usage:: 895 | 896 | {% tagged_objects [tag] in [model] as [varname] %} 897 | 898 | The model is specified in ``[appname].[modelname]`` format. 899 | 900 | The tag must be an instance of a ``Tag``, not the name of a tag. 901 | 902 | Example:: 903 | 904 | {% tagged_objects comedy_tag in tv.Show as comedies %} 905 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based entirely on Django's own ``setup.py``. 3 | """ 4 | import os 5 | from distutils.command.install import INSTALL_SCHEMES 6 | from distutils.core import setup 7 | 8 | import tagging 9 | 10 | 11 | 12 | def fullsplit(path, result=None): 13 | """ 14 | Split a pathname into components (the opposite of os.path.join) in a 15 | platform-neutral way. 16 | """ 17 | if result is None: 18 | result = [] 19 | head, tail = os.path.split(path) 20 | if head == '': 21 | return [tail] + result 22 | if head == path: 23 | return result 24 | return fullsplit(head, [tail] + result) 25 | 26 | # Tell distutils to put the data_files in platform-specific installation 27 | # locations. See here for an explanation: 28 | # http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb 29 | for scheme in INSTALL_SCHEMES.values(): 30 | scheme['data'] = scheme['purelib'] 31 | 32 | # Compile the list of packages available, because distutils doesn't have 33 | # an easy way to do this. 34 | packages, data_files = [], [] 35 | root_dir = os.path.dirname(__file__) 36 | tagging_dir = os.path.join(root_dir, 'tagging') 37 | pieces = fullsplit(root_dir) 38 | if pieces[-1] == '': 39 | len_root_dir = len(pieces) - 1 40 | else: 41 | len_root_dir = len(pieces) 42 | 43 | for dirpath, dirnames, filenames in os.walk(tagging_dir): 44 | # Ignore dirnames that start with '.' 45 | for i, dirname in enumerate(dirnames): 46 | if dirname.startswith('.'): del dirnames[i] 47 | if '__init__.py' in filenames: 48 | packages.append('.'.join(fullsplit(dirpath)[len_root_dir:])) 49 | elif filenames: 50 | data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]]) 51 | 52 | 53 | setup( 54 | name = 'django-tagging', 55 | version = tagging.get_version(), 56 | description = 'Generic tagging application for Django', 57 | author = 'Jonathan Buchanan', 58 | author_email = 'jonathan.buchanan@gmail.com', 59 | url = 'http://code.google.com/p/django-tagging/', 60 | packages = packages, 61 | data_files = data_files, 62 | classifiers = ['Development Status :: 4 - Beta', 63 | 'Environment :: Web Environment', 64 | 'Framework :: Django', 65 | 'Intended Audience :: Developers', 66 | 'License :: OSI Approved :: BSD License', 67 | 'Operating System :: OS Independent', 68 | 'Programming Language :: Python', 69 | 'Topic :: Utilities'], 70 | ) 71 | -------------------------------------------------------------------------------- /tagging/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 4, 0, "dev", 1) 2 | 3 | 4 | 5 | def get_version(): 6 | if VERSION[3] == "final": 7 | return "%s.%s.%s" % (VERSION[0], VERSION[1], VERSION[2]) 8 | elif VERSION[3] == "dev": 9 | if VERSION[2] == 0: 10 | return "%s.%s.%s%s" % (VERSION[0], VERSION[1], VERSION[3], VERSION[4]) 11 | return "%s.%s.%s.%s%s" % (VERSION[0], VERSION[1], VERSION[2], VERSION[3], VERSION[4]) 12 | else: 13 | return "%s.%s.%s%s" % (VERSION[0], VERSION[1], VERSION[2], VERSION[3]) 14 | 15 | 16 | __version__ = get_version() 17 | 18 | 19 | class AlreadyRegistered(Exception): 20 | """ 21 | An attempt was made to register a model more than once. 22 | """ 23 | pass 24 | 25 | 26 | registry = [] 27 | 28 | 29 | def register(model, tag_descriptor_attr='tags', 30 | tagged_item_manager_attr='tagged'): 31 | """ 32 | Sets the given model class up for working with tags. 33 | """ 34 | 35 | from tagging.managers import ModelTaggedItemManager, TagDescriptor 36 | 37 | if model in registry: 38 | raise AlreadyRegistered("The model '%s' has already been " 39 | "registered." % model._meta.object_name) 40 | if hasattr(model, tag_descriptor_attr): 41 | raise AttributeError("'%s' already has an attribute '%s'. You must " 42 | "provide a custom tag_descriptor_attr to register." % ( 43 | model._meta.object_name, 44 | tag_descriptor_attr, 45 | ) 46 | ) 47 | if hasattr(model, tagged_item_manager_attr): 48 | raise AttributeError("'%s' already has an attribute '%s'. You must " 49 | "provide a custom tagged_item_manager_attr to register." % ( 50 | model._meta.object_name, 51 | tagged_item_manager_attr, 52 | ) 53 | ) 54 | 55 | # Add tag descriptor 56 | setattr(model, tag_descriptor_attr, TagDescriptor()) 57 | 58 | # Add custom manager 59 | ModelTaggedItemManager().contribute_to_class(model, tagged_item_manager_attr) 60 | 61 | # Finally register in registry 62 | registry.append(model) 63 | -------------------------------------------------------------------------------- /tagging/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from tagging.models import Tag, TaggedItem 3 | from tagging.forms import TagAdminForm 4 | 5 | class TagAdmin(admin.ModelAdmin): 6 | form = TagAdminForm 7 | 8 | admin.site.register(TaggedItem) 9 | admin.site.register(Tag, TagAdmin) 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tagging/fields.py: -------------------------------------------------------------------------------- 1 | """ 2 | A custom Model Field for tagging. 3 | """ 4 | from django.db.models import signals 5 | from django.db.models.fields import CharField 6 | from django.utils.translation import ugettext_lazy as _ 7 | 8 | from tagging import settings 9 | from tagging.models import Tag 10 | from tagging.utils import edit_string_for_tags 11 | 12 | class TagField(CharField): 13 | """ 14 | A "special" character field that actually works as a relationship to tags 15 | "under the hood". This exposes a space-separated string of tags, but does 16 | the splitting/reordering/etc. under the hood. 17 | """ 18 | def __init__(self, *args, **kwargs): 19 | kwargs['max_length'] = kwargs.get('max_length', 255) 20 | kwargs['blank'] = kwargs.get('blank', True) 21 | kwargs['default'] = kwargs.get('default', '') 22 | super(TagField, self).__init__(*args, **kwargs) 23 | 24 | def contribute_to_class(self, cls, name): 25 | super(TagField, self).contribute_to_class(cls, name) 26 | 27 | # Make this object the descriptor for field access. 28 | setattr(cls, self.name, self) 29 | 30 | # Save tags back to the database post-save 31 | signals.post_save.connect(self._save, cls, True) 32 | 33 | # Update tags from Tag objects post-init 34 | signals.post_init.connect(self._update, cls, True) 35 | 36 | def __get__(self, instance, owner=None): 37 | """ 38 | Tag getter. Returns an instance's tags if accessed on an instance, and 39 | all of a model's tags if called on a class. That is, this model:: 40 | 41 | class Link(models.Model): 42 | ... 43 | tags = TagField() 44 | 45 | Lets you do both of these:: 46 | 47 | >>> l = Link.objects.get(...) 48 | >>> l.tags 49 | 'tag1 tag2 tag3' 50 | 51 | >>> Link.tags 52 | 'tag1 tag2 tag3 tag4' 53 | 54 | """ 55 | # Handle access on the model (i.e. Link.tags) 56 | if instance is None: 57 | return edit_string_for_tags(Tag.objects.usage_for_model(owner)) 58 | 59 | return self._get_instance_tag_cache(instance) 60 | 61 | def __set__(self, instance, value): 62 | """ 63 | Set an object's tags. 64 | """ 65 | if instance is None: 66 | raise AttributeError(_('%s can only be set on instances.') % self.name) 67 | if settings.FORCE_LOWERCASE_TAGS and value is not None: 68 | value = value.lower() 69 | self._set_instance_tag_cache(instance, value) 70 | 71 | def _save(self, **kwargs): #signal, sender, instance): 72 | """ 73 | Save tags back to the database 74 | """ 75 | tags = self._get_instance_tag_cache(kwargs['instance']) 76 | Tag.objects.update_tags(kwargs['instance'], tags) 77 | 78 | def _update(self, **kwargs): #signal, sender, instance): 79 | """ 80 | Update tag cache from TaggedItem objects. 81 | """ 82 | instance = kwargs['instance'] 83 | self._update_instance_tag_cache(instance) 84 | 85 | def __delete__(self, instance): 86 | """ 87 | Clear all of an object's tags. 88 | """ 89 | self._set_instance_tag_cache(instance, '') 90 | 91 | def _get_instance_tag_cache(self, instance): 92 | """ 93 | Helper: get an instance's tag cache. 94 | """ 95 | return getattr(instance, '_%s_cache' % self.attname, None) 96 | 97 | def _set_instance_tag_cache(self, instance, tags): 98 | """ 99 | Helper: set an instance's tag cache. 100 | """ 101 | setattr(instance, '_%s_cache' % self.attname, tags) 102 | 103 | def _update_instance_tag_cache(self, instance): 104 | """ 105 | Helper: update an instance's tag cache from actual Tags. 106 | """ 107 | # for an unsaved object, leave the default value alone 108 | if instance.pk is not None: 109 | tags = edit_string_for_tags(Tag.objects.get_for_object(instance)) 110 | self._set_instance_tag_cache(instance, tags) 111 | 112 | def get_internal_type(self): 113 | return 'CharField' 114 | 115 | def formfield(self, **kwargs): 116 | from tagging import forms 117 | defaults = {'form_class': forms.TagField} 118 | defaults.update(kwargs) 119 | return super(TagField, self).formfield(**defaults) 120 | -------------------------------------------------------------------------------- /tagging/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tagging components for Django's form library. 3 | """ 4 | from django import forms 5 | from django.utils.translation import ugettext as _ 6 | 7 | from tagging import settings 8 | from tagging.models import Tag 9 | from tagging.utils import parse_tag_input 10 | 11 | class TagAdminForm(forms.ModelForm): 12 | class Meta: 13 | model = Tag 14 | 15 | def clean_name(self): 16 | value = self.cleaned_data['name'] 17 | tag_names = parse_tag_input(value) 18 | if len(tag_names) > 1: 19 | raise forms.ValidationError(_('Multiple tags were given.')) 20 | elif len(tag_names[0]) > settings.MAX_TAG_LENGTH: 21 | raise forms.ValidationError( 22 | _('A tag may be no more than %s characters long.') % 23 | settings.MAX_TAG_LENGTH) 24 | return value 25 | 26 | class TagField(forms.CharField): 27 | """ 28 | A ``CharField`` which validates that its input is a valid list of 29 | tag names. 30 | """ 31 | def clean(self, value): 32 | value = super(TagField, self).clean(value) 33 | if value == u'': 34 | return value 35 | for tag_name in parse_tag_input(value): 36 | if len(tag_name) > settings.MAX_TAG_LENGTH: 37 | raise forms.ValidationError( 38 | _('Each tag may be no more than %s characters long.') % 39 | settings.MAX_TAG_LENGTH) 40 | return value 41 | -------------------------------------------------------------------------------- /tagging/generic.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | 3 | def fetch_content_objects(tagged_items, select_related_for=None): 4 | """ 5 | Retrieves ``ContentType`` and content objects for the given list of 6 | ``TaggedItems``, grouping the retrieval of content objects by model 7 | type to reduce the number of queries executed. 8 | 9 | This results in ``number_of_content_types + 1`` queries rather than 10 | the ``number_of_tagged_items * 2`` queries you'd get by iterating 11 | over the list and accessing each item's ``object`` attribute. 12 | 13 | A ``select_related_for`` argument can be used to specify a list of 14 | of model names (corresponding to the ``model`` field of a 15 | ``ContentType``) for which ``select_related`` should be used when 16 | retrieving model instances. 17 | """ 18 | if select_related_for is None: select_related_for = [] 19 | 20 | # Group content object pks by their content type pks 21 | objects = {} 22 | for item in tagged_items: 23 | objects.setdefault(item.content_type_id, []).append(item.object_id) 24 | 25 | # Retrieve content types and content objects in bulk 26 | content_types = ContentType._default_manager.in_bulk(objects.keys()) 27 | for content_type_pk, object_pks in objects.iteritems(): 28 | model = content_types[content_type_pk].model_class() 29 | if content_types[content_type_pk].model in select_related_for: 30 | objects[content_type_pk] = model._default_manager.select_related().in_bulk(object_pks) 31 | else: 32 | objects[content_type_pk] = model._default_manager.in_bulk(object_pks) 33 | 34 | # Set content types and content objects in the appropriate cache 35 | # attributes, so accessing the 'content_type' and 'object' 36 | # attributes on each tagged item won't result in further database 37 | # hits. 38 | for item in tagged_items: 39 | item._object_cache = objects[item.content_type_id][item.object_id] 40 | item._content_type_cache = content_types[item.content_type_id] 41 | -------------------------------------------------------------------------------- /tagging/managers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom managers for Django models registered with the tagging 3 | application. 4 | """ 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import models 7 | 8 | from tagging.models import Tag, TaggedItem 9 | 10 | class ModelTagManager(models.Manager): 11 | """ 12 | A manager for retrieving tags for a particular model. 13 | """ 14 | def get_query_set(self): 15 | ctype = ContentType.objects.get_for_model(self.model) 16 | return Tag.objects.filter( 17 | items__content_type__pk=ctype.pk).distinct() 18 | 19 | def cloud(self, *args, **kwargs): 20 | return Tag.objects.cloud_for_model(self.model, *args, **kwargs) 21 | 22 | def related(self, tags, *args, **kwargs): 23 | return Tag.objects.related_for_model(tags, self.model, *args, **kwargs) 24 | 25 | def usage(self, *args, **kwargs): 26 | return Tag.objects.usage_for_model(self.model, *args, **kwargs) 27 | 28 | class ModelTaggedItemManager(models.Manager): 29 | """ 30 | A manager for retrieving model instances based on their tags. 31 | """ 32 | def related_to(self, obj, queryset=None, num=None): 33 | if queryset is None: 34 | return TaggedItem.objects.get_related(obj, self.model, num=num) 35 | else: 36 | return TaggedItem.objects.get_related(obj, queryset, num=num) 37 | 38 | def with_all(self, tags, queryset=None): 39 | if queryset is None: 40 | return TaggedItem.objects.get_by_model(self.model, tags) 41 | else: 42 | return TaggedItem.objects.get_by_model(queryset, tags) 43 | 44 | def with_any(self, tags, queryset=None): 45 | if queryset is None: 46 | return TaggedItem.objects.get_union_by_model(self.model, tags) 47 | else: 48 | return TaggedItem.objects.get_union_by_model(queryset, tags) 49 | 50 | class TagDescriptor(object): 51 | """ 52 | A descriptor which provides access to a ``ModelTagManager`` for 53 | model classes and simple retrieval, updating and deletion of tags 54 | for model instances. 55 | """ 56 | def __get__(self, instance, owner): 57 | if not instance: 58 | tag_manager = ModelTagManager() 59 | tag_manager.model = owner 60 | return tag_manager 61 | else: 62 | return Tag.objects.get_for_object(instance) 63 | 64 | def __set__(self, instance, value): 65 | Tag.objects.update_tags(instance, value) 66 | 67 | def __delete__(self, instance): 68 | Tag.objects.update_tags(instance, None) 69 | -------------------------------------------------------------------------------- /tagging/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Models and managers for generic tagging. 3 | """ 4 | # Python 2.3 compatibility 5 | try: 6 | set 7 | except NameError: 8 | from sets import Set as set 9 | 10 | from django.contrib.contenttypes import generic 11 | from django.contrib.contenttypes.models import ContentType 12 | from django.db import connection, models 13 | from django.db.models.query import QuerySet 14 | from django.utils.translation import ugettext_lazy as _ 15 | 16 | from tagging import settings 17 | from tagging.utils import calculate_cloud, get_tag_list, get_queryset_and_model, parse_tag_input 18 | from tagging.utils import LOGARITHMIC 19 | 20 | qn = connection.ops.quote_name 21 | 22 | ############ 23 | # Managers # 24 | ############ 25 | 26 | class TagManager(models.Manager): 27 | def update_tags(self, obj, tag_names): 28 | """ 29 | Update tags associated with an object. 30 | """ 31 | ctype = ContentType.objects.get_for_model(obj) 32 | current_tags = list(self.filter(items__content_type__pk=ctype.pk, 33 | items__object_id=obj.pk)) 34 | updated_tag_names = parse_tag_input(tag_names) 35 | if settings.FORCE_LOWERCASE_TAGS: 36 | updated_tag_names = [t.lower() for t in updated_tag_names] 37 | 38 | # Remove tags which no longer apply 39 | tags_for_removal = [tag for tag in current_tags \ 40 | if tag.name not in updated_tag_names] 41 | if len(tags_for_removal): 42 | TaggedItem._default_manager.filter(content_type__pk=ctype.pk, 43 | object_id=obj.pk, 44 | tag__in=tags_for_removal).delete() 45 | # Add new tags 46 | current_tag_names = [tag.name for tag in current_tags] 47 | for tag_name in updated_tag_names: 48 | if tag_name not in current_tag_names: 49 | tag, created = self.get_or_create(name=tag_name) 50 | TaggedItem._default_manager.create(tag=tag, object=obj) 51 | 52 | def add_tag(self, obj, tag_name): 53 | """ 54 | Associates the given object with a tag. 55 | """ 56 | tag_names = parse_tag_input(tag_name) 57 | if not len(tag_names): 58 | raise AttributeError(_('No tags were given: "%s".') % tag_name) 59 | if len(tag_names) > 1: 60 | raise AttributeError(_('Multiple tags were given: "%s".') % tag_name) 61 | tag_name = tag_names[0] 62 | if settings.FORCE_LOWERCASE_TAGS: 63 | tag_name = tag_name.lower() 64 | tag, created = self.get_or_create(name=tag_name) 65 | ctype = ContentType.objects.get_for_model(obj) 66 | TaggedItem._default_manager.get_or_create( 67 | tag=tag, content_type=ctype, object_id=obj.pk) 68 | 69 | def get_for_object(self, obj): 70 | """ 71 | Create a queryset matching all tags associated with the given 72 | object. 73 | """ 74 | ctype = ContentType.objects.get_for_model(obj) 75 | return self.filter(items__content_type__pk=ctype.pk, 76 | items__object_id=obj.pk) 77 | 78 | def _get_usage(self, model, counts=False, min_count=None, extra_joins=None, extra_criteria=None, params=None): 79 | """ 80 | Perform the custom SQL query for ``usage_for_model`` and 81 | ``usage_for_queryset``. 82 | """ 83 | if min_count is not None: counts = True 84 | 85 | model_table = qn(model._meta.db_table) 86 | model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column)) 87 | query = """ 88 | SELECT DISTINCT %(tag)s.id, %(tag)s.name%(count_sql)s 89 | FROM 90 | %(tag)s 91 | INNER JOIN %(tagged_item)s 92 | ON %(tag)s.id = %(tagged_item)s.tag_id 93 | INNER JOIN %(model)s 94 | ON %(tagged_item)s.object_id = %(model_pk)s 95 | %%s 96 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 97 | %%s 98 | GROUP BY %(tag)s.id, %(tag)s.name 99 | %%s 100 | ORDER BY %(tag)s.name ASC""" % { 101 | 'tag': qn(self.model._meta.db_table), 102 | 'count_sql': counts and (', COUNT(%s)' % model_pk) or '', 103 | 'tagged_item': qn(TaggedItem._meta.db_table), 104 | 'model': model_table, 105 | 'model_pk': model_pk, 106 | 'content_type_id': ContentType.objects.get_for_model(model).pk, 107 | } 108 | 109 | min_count_sql = '' 110 | if min_count is not None: 111 | min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk 112 | params.append(min_count) 113 | 114 | cursor = connection.cursor() 115 | cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), params) 116 | tags = [] 117 | for row in cursor.fetchall(): 118 | t = self.model(*row[:2]) 119 | if counts: 120 | t.count = row[2] 121 | tags.append(t) 122 | return tags 123 | 124 | def usage_for_model(self, model, counts=False, min_count=None, filters=None): 125 | """ 126 | Obtain a list of tags associated with instances of the given 127 | Model class. 128 | 129 | If ``counts`` is True, a ``count`` attribute will be added to 130 | each tag, indicating how many times it has been used against 131 | the Model class in question. 132 | 133 | If ``min_count`` is given, only tags which have a ``count`` 134 | greater than or equal to ``min_count`` will be returned. 135 | Passing a value for ``min_count`` implies ``counts=True``. 136 | 137 | To limit the tags (and counts, if specified) returned to those 138 | used by a subset of the Model's instances, pass a dictionary 139 | of field lookups to be applied to the given Model as the 140 | ``filters`` argument. 141 | """ 142 | if filters is None: filters = {} 143 | 144 | queryset = model._default_manager.filter() 145 | for f in filters.items(): 146 | queryset.query.add_filter(f) 147 | usage = self.usage_for_queryset(queryset, counts, min_count) 148 | 149 | return usage 150 | 151 | def usage_for_queryset(self, queryset, counts=False, min_count=None): 152 | """ 153 | Obtain a list of tags associated with instances of a model 154 | contained in the given queryset. 155 | 156 | If ``counts`` is True, a ``count`` attribute will be added to 157 | each tag, indicating how many times it has been used against 158 | the Model class in question. 159 | 160 | If ``min_count`` is given, only tags which have a ``count`` 161 | greater than or equal to ``min_count`` will be returned. 162 | Passing a value for ``min_count`` implies ``counts=True``. 163 | """ 164 | 165 | if getattr(queryset.query, 'get_compiler', None): 166 | # Django 1.2+ 167 | compiler = queryset.query.get_compiler(using='default') 168 | extra_joins = ' '.join(compiler.get_from_clause()[0][1:]) 169 | where, params = queryset.query.where.as_sql( 170 | compiler.quote_name_unless_alias, compiler.connection 171 | ) 172 | else: 173 | # Django pre-1.2 174 | extra_joins = ' '.join(queryset.query.get_from_clause()[0][1:]) 175 | where, params = queryset.query.where.as_sql() 176 | 177 | if where: 178 | extra_criteria = 'AND %s' % where 179 | else: 180 | extra_criteria = '' 181 | return self._get_usage(queryset.model, counts, min_count, extra_joins, extra_criteria, params) 182 | 183 | def related_for_model(self, tags, model, counts=False, min_count=None): 184 | """ 185 | Obtain a list of tags related to a given list of tags - that 186 | is, other tags used by items which have all the given tags. 187 | 188 | If ``counts`` is True, a ``count`` attribute will be added to 189 | each tag, indicating the number of items which have it in 190 | addition to the given list of tags. 191 | 192 | If ``min_count`` is given, only tags which have a ``count`` 193 | greater than or equal to ``min_count`` will be returned. 194 | Passing a value for ``min_count`` implies ``counts=True``. 195 | """ 196 | if min_count is not None: counts = True 197 | tags = get_tag_list(tags) 198 | tag_count = len(tags) 199 | tagged_item_table = qn(TaggedItem._meta.db_table) 200 | query = """ 201 | SELECT %(tag)s.id, %(tag)s.name%(count_sql)s 202 | FROM %(tagged_item)s INNER JOIN %(tag)s ON %(tagged_item)s.tag_id = %(tag)s.id 203 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 204 | AND %(tagged_item)s.object_id IN 205 | ( 206 | SELECT %(tagged_item)s.object_id 207 | FROM %(tagged_item)s, %(tag)s 208 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 209 | AND %(tag)s.id = %(tagged_item)s.tag_id 210 | AND %(tag)s.id IN (%(tag_id_placeholders)s) 211 | GROUP BY %(tagged_item)s.object_id 212 | HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s 213 | ) 214 | AND %(tag)s.id NOT IN (%(tag_id_placeholders)s) 215 | GROUP BY %(tag)s.id, %(tag)s.name 216 | %(min_count_sql)s 217 | ORDER BY %(tag)s.name ASC""" % { 218 | 'tag': qn(self.model._meta.db_table), 219 | 'count_sql': counts and ', COUNT(%s.object_id)' % tagged_item_table or '', 220 | 'tagged_item': tagged_item_table, 221 | 'content_type_id': ContentType.objects.get_for_model(model).pk, 222 | 'tag_id_placeholders': ','.join(['%s'] * tag_count), 223 | 'tag_count': tag_count, 224 | 'min_count_sql': min_count is not None and ('HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '', 225 | } 226 | 227 | params = [tag.pk for tag in tags] * 2 228 | if min_count is not None: 229 | params.append(min_count) 230 | 231 | cursor = connection.cursor() 232 | cursor.execute(query, params) 233 | related = [] 234 | for row in cursor.fetchall(): 235 | tag = self.model(*row[:2]) 236 | if counts is True: 237 | tag.count = row[2] 238 | related.append(tag) 239 | return related 240 | 241 | def cloud_for_model(self, model, steps=4, distribution=LOGARITHMIC, 242 | filters=None, min_count=None): 243 | """ 244 | Obtain a list of tags associated with instances of the given 245 | Model, giving each tag a ``count`` attribute indicating how 246 | many times it has been used and a ``font_size`` attribute for 247 | use in displaying a tag cloud. 248 | 249 | ``steps`` defines the range of font sizes - ``font_size`` will 250 | be an integer between 1 and ``steps`` (inclusive). 251 | 252 | ``distribution`` defines the type of font size distribution 253 | algorithm which will be used - logarithmic or linear. It must 254 | be either ``tagging.utils.LOGARITHMIC`` or 255 | ``tagging.utils.LINEAR``. 256 | 257 | To limit the tags displayed in the cloud to those associated 258 | with a subset of the Model's instances, pass a dictionary of 259 | field lookups to be applied to the given Model as the 260 | ``filters`` argument. 261 | 262 | To limit the tags displayed in the cloud to those with a 263 | ``count`` greater than or equal to ``min_count``, pass a value 264 | for the ``min_count`` argument. 265 | """ 266 | tags = list(self.usage_for_model(model, counts=True, filters=filters, 267 | min_count=min_count)) 268 | return calculate_cloud(tags, steps, distribution) 269 | 270 | class TaggedItemManager(models.Manager): 271 | """ 272 | FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING`` 273 | SQL clauses required by many of this manager's methods into 274 | Django's ORM. 275 | 276 | For now, we manually execute a query to retrieve the PKs of 277 | objects we're interested in, then use the ORM's ``__in`` 278 | lookup to return a ``QuerySet``. 279 | 280 | Now that the queryset-refactor branch is in the trunk, this can be 281 | tidied up significantly. 282 | """ 283 | def get_by_model(self, queryset_or_model, tags): 284 | """ 285 | Create a ``QuerySet`` containing instances of the specified 286 | model associated with a given tag or list of tags. 287 | """ 288 | tags = get_tag_list(tags) 289 | tag_count = len(tags) 290 | if tag_count == 0: 291 | # No existing tags were given 292 | queryset, model = get_queryset_and_model(queryset_or_model) 293 | return model._default_manager.none() 294 | elif tag_count == 1: 295 | # Optimisation for single tag - fall through to the simpler 296 | # query below. 297 | tag = tags[0] 298 | else: 299 | return self.get_intersection_by_model(queryset_or_model, tags) 300 | 301 | queryset, model = get_queryset_and_model(queryset_or_model) 302 | content_type = ContentType.objects.get_for_model(model) 303 | opts = self.model._meta 304 | tagged_item_table = qn(opts.db_table) 305 | return queryset.extra( 306 | tables=[opts.db_table], 307 | where=[ 308 | '%s.content_type_id = %%s' % tagged_item_table, 309 | '%s.tag_id = %%s' % tagged_item_table, 310 | '%s.%s = %s.object_id' % (qn(model._meta.db_table), 311 | qn(model._meta.pk.column), 312 | tagged_item_table) 313 | ], 314 | params=[content_type.pk, tag.pk], 315 | ) 316 | 317 | def get_intersection_by_model(self, queryset_or_model, tags): 318 | """ 319 | Create a ``QuerySet`` containing instances of the specified 320 | model associated with *all* of the given list of tags. 321 | """ 322 | tags = get_tag_list(tags) 323 | tag_count = len(tags) 324 | queryset, model = get_queryset_and_model(queryset_or_model) 325 | 326 | if not tag_count: 327 | return model._default_manager.none() 328 | 329 | model_table = qn(model._meta.db_table) 330 | # This query selects the ids of all objects which have all the 331 | # given tags. 332 | query = """ 333 | SELECT %(model_pk)s 334 | FROM %(model)s, %(tagged_item)s 335 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 336 | AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s) 337 | AND %(model_pk)s = %(tagged_item)s.object_id 338 | GROUP BY %(model_pk)s 339 | HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % { 340 | 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), 341 | 'model': model_table, 342 | 'tagged_item': qn(self.model._meta.db_table), 343 | 'content_type_id': ContentType.objects.get_for_model(model).pk, 344 | 'tag_id_placeholders': ','.join(['%s'] * tag_count), 345 | 'tag_count': tag_count, 346 | } 347 | 348 | cursor = connection.cursor() 349 | cursor.execute(query, [tag.pk for tag in tags]) 350 | object_ids = [row[0] for row in cursor.fetchall()] 351 | if len(object_ids) > 0: 352 | return queryset.filter(pk__in=object_ids) 353 | else: 354 | return model._default_manager.none() 355 | 356 | def get_union_by_model(self, queryset_or_model, tags): 357 | """ 358 | Create a ``QuerySet`` containing instances of the specified 359 | model associated with *any* of the given list of tags. 360 | """ 361 | tags = get_tag_list(tags) 362 | tag_count = len(tags) 363 | queryset, model = get_queryset_and_model(queryset_or_model) 364 | 365 | if not tag_count: 366 | return model._default_manager.none() 367 | 368 | model_table = qn(model._meta.db_table) 369 | # This query selects the ids of all objects which have any of 370 | # the given tags. 371 | query = """ 372 | SELECT %(model_pk)s 373 | FROM %(model)s, %(tagged_item)s 374 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 375 | AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s) 376 | AND %(model_pk)s = %(tagged_item)s.object_id 377 | GROUP BY %(model_pk)s""" % { 378 | 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), 379 | 'model': model_table, 380 | 'tagged_item': qn(self.model._meta.db_table), 381 | 'content_type_id': ContentType.objects.get_for_model(model).pk, 382 | 'tag_id_placeholders': ','.join(['%s'] * tag_count), 383 | } 384 | 385 | cursor = connection.cursor() 386 | cursor.execute(query, [tag.pk for tag in tags]) 387 | object_ids = [row[0] for row in cursor.fetchall()] 388 | if len(object_ids) > 0: 389 | return queryset.filter(pk__in=object_ids) 390 | else: 391 | return model._default_manager.none() 392 | 393 | def get_related(self, obj, queryset_or_model, num=None): 394 | """ 395 | Retrieve a list of instances of the specified model which share 396 | tags with the model instance ``obj``, ordered by the number of 397 | shared tags in descending order. 398 | 399 | If ``num`` is given, a maximum of ``num`` instances will be 400 | returned. 401 | """ 402 | queryset, model = get_queryset_and_model(queryset_or_model) 403 | model_table = qn(model._meta.db_table) 404 | content_type = ContentType.objects.get_for_model(obj) 405 | related_content_type = ContentType.objects.get_for_model(model) 406 | query = """ 407 | SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s 408 | FROM %(model)s, %(tagged_item)s, %(tag)s, %(tagged_item)s related_tagged_item 409 | WHERE %(tagged_item)s.object_id = %%s 410 | AND %(tagged_item)s.content_type_id = %(content_type_id)s 411 | AND %(tag)s.id = %(tagged_item)s.tag_id 412 | AND related_tagged_item.content_type_id = %(related_content_type_id)s 413 | AND related_tagged_item.tag_id = %(tagged_item)s.tag_id 414 | AND %(model_pk)s = related_tagged_item.object_id""" 415 | if content_type.pk == related_content_type.pk: 416 | # Exclude the given instance itself if determining related 417 | # instances for the same model. 418 | query += """ 419 | AND related_tagged_item.object_id != %(tagged_item)s.object_id""" 420 | query += """ 421 | GROUP BY %(model_pk)s 422 | ORDER BY %(count)s DESC 423 | %(limit_offset)s""" 424 | query = query % { 425 | 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), 426 | 'count': qn('count'), 427 | 'model': model_table, 428 | 'tagged_item': qn(self.model._meta.db_table), 429 | 'tag': qn(self.model._meta.get_field('tag').rel.to._meta.db_table), 430 | 'content_type_id': content_type.pk, 431 | 'related_content_type_id': related_content_type.pk, 432 | # Hardcoding this for now just to get tests working again - this 433 | # should now be handled by the query object. 434 | 'limit_offset': num is not None and 'LIMIT %s' or '', 435 | } 436 | 437 | cursor = connection.cursor() 438 | params = [obj.pk] 439 | if num is not None: 440 | params.append(num) 441 | cursor.execute(query, params) 442 | object_ids = [row[0] for row in cursor.fetchall()] 443 | if len(object_ids) > 0: 444 | # Use in_bulk here instead of an id__in lookup, because id__in would 445 | # clobber the ordering. 446 | object_dict = queryset.in_bulk(object_ids) 447 | return [object_dict[object_id] for object_id in object_ids \ 448 | if object_id in object_dict] 449 | else: 450 | return [] 451 | 452 | ########## 453 | # Models # 454 | ########## 455 | 456 | class Tag(models.Model): 457 | """ 458 | A tag. 459 | """ 460 | name = models.CharField(_('name'), max_length=50, unique=True, db_index=True) 461 | 462 | objects = TagManager() 463 | 464 | class Meta: 465 | ordering = ('name',) 466 | verbose_name = _('tag') 467 | verbose_name_plural = _('tags') 468 | 469 | def __unicode__(self): 470 | return self.name 471 | 472 | class TaggedItem(models.Model): 473 | """ 474 | Holds the relationship between a tag and the item being tagged. 475 | """ 476 | tag = models.ForeignKey(Tag, verbose_name=_('tag'), related_name='items') 477 | content_type = models.ForeignKey(ContentType, verbose_name=_('content type')) 478 | object_id = models.PositiveIntegerField(_('object id'), db_index=True) 479 | object = generic.GenericForeignKey('content_type', 'object_id') 480 | 481 | objects = TaggedItemManager() 482 | 483 | class Meta: 484 | # Enforce unique tag association per object 485 | unique_together = (('tag', 'content_type', 'object_id'),) 486 | verbose_name = _('tagged item') 487 | verbose_name_plural = _('tagged items') 488 | 489 | def __unicode__(self): 490 | return u'%s [%s]' % (self.object, self.tag) 491 | -------------------------------------------------------------------------------- /tagging/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Convenience module for access of custom tagging application settings, 3 | which enforces default settings when the main settings module does not 4 | contain the appropriate settings. 5 | """ 6 | from django.conf import settings 7 | 8 | # The maximum length of a tag's name. 9 | MAX_TAG_LENGTH = getattr(settings, 'MAX_TAG_LENGTH', 50) 10 | 11 | # Whether to force all tags to lowercase before they are saved to the 12 | # database. 13 | FORCE_LOWERCASE_TAGS = getattr(settings, 'FORCE_LOWERCASE_TAGS', False) 14 | -------------------------------------------------------------------------------- /tagging/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brosner/django-tagging/a5a8f7bf832072f9876628592c4960c7cb086ac3/tagging/templatetags/__init__.py -------------------------------------------------------------------------------- /tagging/templatetags/tagging_tags.py: -------------------------------------------------------------------------------- 1 | from django.db.models import get_model 2 | from django.template import Library, Node, TemplateSyntaxError, Variable, resolve_variable 3 | from django.utils.translation import ugettext as _ 4 | 5 | from tagging.models import Tag, TaggedItem 6 | from tagging.utils import LINEAR, LOGARITHMIC 7 | 8 | register = Library() 9 | 10 | class TagsForModelNode(Node): 11 | def __init__(self, model, context_var, counts): 12 | self.model = model 13 | self.context_var = context_var 14 | self.counts = counts 15 | 16 | def render(self, context): 17 | model = get_model(*self.model.split('.')) 18 | if model is None: 19 | raise TemplateSyntaxError(_('tags_for_model tag was given an invalid model: %s') % self.model) 20 | context[self.context_var] = Tag.objects.usage_for_model(model, counts=self.counts) 21 | return '' 22 | 23 | class TagCloudForModelNode(Node): 24 | def __init__(self, model, context_var, **kwargs): 25 | self.model = model 26 | self.context_var = context_var 27 | self.kwargs = kwargs 28 | 29 | def render(self, context): 30 | model = get_model(*self.model.split('.')) 31 | if model is None: 32 | raise TemplateSyntaxError(_('tag_cloud_for_model tag was given an invalid model: %s') % self.model) 33 | context[self.context_var] = \ 34 | Tag.objects.cloud_for_model(model, **self.kwargs) 35 | return '' 36 | 37 | class TagsForObjectNode(Node): 38 | def __init__(self, obj, context_var): 39 | self.obj = Variable(obj) 40 | self.context_var = context_var 41 | 42 | def render(self, context): 43 | context[self.context_var] = \ 44 | Tag.objects.get_for_object(self.obj.resolve(context)) 45 | return '' 46 | 47 | class TaggedObjectsNode(Node): 48 | def __init__(self, tag, model, context_var): 49 | self.tag = Variable(tag) 50 | self.context_var = context_var 51 | self.model = model 52 | 53 | def render(self, context): 54 | model = get_model(*self.model.split('.')) 55 | if model is None: 56 | raise TemplateSyntaxError(_('tagged_objects tag was given an invalid model: %s') % self.model) 57 | context[self.context_var] = \ 58 | TaggedItem.objects.get_by_model(model, self.tag.resolve(context)) 59 | return '' 60 | 61 | def do_tags_for_model(parser, token): 62 | """ 63 | Retrieves a list of ``Tag`` objects associated with a given model 64 | and stores them in a context variable. 65 | 66 | Usage:: 67 | 68 | {% tags_for_model [model] as [varname] %} 69 | 70 | The model is specified in ``[appname].[modelname]`` format. 71 | 72 | Extended usage:: 73 | 74 | {% tags_for_model [model] as [varname] with counts %} 75 | 76 | If specified - by providing extra ``with counts`` arguments - adds 77 | a ``count`` attribute to each tag containing the number of 78 | instances of the given model which have been tagged with it. 79 | 80 | Examples:: 81 | 82 | {% tags_for_model products.Widget as widget_tags %} 83 | {% tags_for_model products.Widget as widget_tags with counts %} 84 | 85 | """ 86 | bits = token.contents.split() 87 | len_bits = len(bits) 88 | if len_bits not in (4, 6): 89 | raise TemplateSyntaxError(_('%s tag requires either three or five arguments') % bits[0]) 90 | if bits[2] != 'as': 91 | raise TemplateSyntaxError(_("second argument to %s tag must be 'as'") % bits[0]) 92 | if len_bits == 6: 93 | if bits[4] != 'with': 94 | raise TemplateSyntaxError(_("if given, fourth argument to %s tag must be 'with'") % bits[0]) 95 | if bits[5] != 'counts': 96 | raise TemplateSyntaxError(_("if given, fifth argument to %s tag must be 'counts'") % bits[0]) 97 | if len_bits == 4: 98 | return TagsForModelNode(bits[1], bits[3], counts=False) 99 | else: 100 | return TagsForModelNode(bits[1], bits[3], counts=True) 101 | 102 | def do_tag_cloud_for_model(parser, token): 103 | """ 104 | Retrieves a list of ``Tag`` objects for a given model, with tag 105 | cloud attributes set, and stores them in a context variable. 106 | 107 | Usage:: 108 | 109 | {% tag_cloud_for_model [model] as [varname] %} 110 | 111 | The model is specified in ``[appname].[modelname]`` format. 112 | 113 | Extended usage:: 114 | 115 | {% tag_cloud_for_model [model] as [varname] with [options] %} 116 | 117 | Extra options can be provided after an optional ``with`` argument, 118 | with each option being specified in ``[name]=[value]`` format. Valid 119 | extra options are: 120 | 121 | ``steps`` 122 | Integer. Defines the range of font sizes. 123 | 124 | ``min_count`` 125 | Integer. Defines the minimum number of times a tag must have 126 | been used to appear in the cloud. 127 | 128 | ``distribution`` 129 | One of ``linear`` or ``log``. Defines the font-size 130 | distribution algorithm to use when generating the tag cloud. 131 | 132 | Examples:: 133 | 134 | {% tag_cloud_for_model products.Widget as widget_tags %} 135 | {% tag_cloud_for_model products.Widget as widget_tags with steps=9 min_count=3 distribution=log %} 136 | 137 | """ 138 | bits = token.contents.split() 139 | len_bits = len(bits) 140 | if len_bits != 4 and len_bits not in range(6, 9): 141 | raise TemplateSyntaxError(_('%s tag requires either three or between five and seven arguments') % bits[0]) 142 | if bits[2] != 'as': 143 | raise TemplateSyntaxError(_("second argument to %s tag must be 'as'") % bits[0]) 144 | kwargs = {} 145 | if len_bits > 5: 146 | if bits[4] != 'with': 147 | raise TemplateSyntaxError(_("if given, fourth argument to %s tag must be 'with'") % bits[0]) 148 | for i in range(5, len_bits): 149 | try: 150 | name, value = bits[i].split('=') 151 | if name == 'steps' or name == 'min_count': 152 | try: 153 | kwargs[str(name)] = int(value) 154 | except ValueError: 155 | raise TemplateSyntaxError(_("%(tag)s tag's '%(option)s' option was not a valid integer: '%(value)s'") % { 156 | 'tag': bits[0], 157 | 'option': name, 158 | 'value': value, 159 | }) 160 | elif name == 'distribution': 161 | if value in ['linear', 'log']: 162 | kwargs[str(name)] = {'linear': LINEAR, 'log': LOGARITHMIC}[value] 163 | else: 164 | raise TemplateSyntaxError(_("%(tag)s tag's '%(option)s' option was not a valid choice: '%(value)s'") % { 165 | 'tag': bits[0], 166 | 'option': name, 167 | 'value': value, 168 | }) 169 | else: 170 | raise TemplateSyntaxError(_("%(tag)s tag was given an invalid option: '%(option)s'") % { 171 | 'tag': bits[0], 172 | 'option': name, 173 | }) 174 | except ValueError: 175 | raise TemplateSyntaxError(_("%(tag)s tag was given a badly formatted option: '%(option)s'") % { 176 | 'tag': bits[0], 177 | 'option': bits[i], 178 | }) 179 | return TagCloudForModelNode(bits[1], bits[3], **kwargs) 180 | 181 | def do_tags_for_object(parser, token): 182 | """ 183 | Retrieves a list of ``Tag`` objects associated with an object and 184 | stores them in a context variable. 185 | 186 | Usage:: 187 | 188 | {% tags_for_object [object] as [varname] %} 189 | 190 | Example:: 191 | 192 | {% tags_for_object foo_object as tag_list %} 193 | """ 194 | bits = token.contents.split() 195 | if len(bits) != 4: 196 | raise TemplateSyntaxError(_('%s tag requires exactly three arguments') % bits[0]) 197 | if bits[2] != 'as': 198 | raise TemplateSyntaxError(_("second argument to %s tag must be 'as'") % bits[0]) 199 | return TagsForObjectNode(bits[1], bits[3]) 200 | 201 | def do_tagged_objects(parser, token): 202 | """ 203 | Retrieves a list of instances of a given model which are tagged with 204 | a given ``Tag`` and stores them in a context variable. 205 | 206 | Usage:: 207 | 208 | {% tagged_objects [tag] in [model] as [varname] %} 209 | 210 | The model is specified in ``[appname].[modelname]`` format. 211 | 212 | The tag must be an instance of a ``Tag``, not the name of a tag. 213 | 214 | Example:: 215 | 216 | {% tagged_objects comedy_tag in tv.Show as comedies %} 217 | 218 | """ 219 | bits = token.contents.split() 220 | if len(bits) != 6: 221 | raise TemplateSyntaxError(_('%s tag requires exactly five arguments') % bits[0]) 222 | if bits[2] != 'in': 223 | raise TemplateSyntaxError(_("second argument to %s tag must be 'in'") % bits[0]) 224 | if bits[4] != 'as': 225 | raise TemplateSyntaxError(_("fourth argument to %s tag must be 'as'") % bits[0]) 226 | return TaggedObjectsNode(bits[1], bits[3], bits[5]) 227 | 228 | register.tag('tags_for_model', do_tags_for_model) 229 | register.tag('tag_cloud_for_model', do_tag_cloud_for_model) 230 | register.tag('tags_for_object', do_tags_for_object) 231 | register.tag('tagged_objects', do_tagged_objects) 232 | -------------------------------------------------------------------------------- /tagging/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brosner/django-tagging/a5a8f7bf832072f9876628592c4960c7cb086ac3/tagging/tests/__init__.py -------------------------------------------------------------------------------- /tagging/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from tagging.fields import TagField 4 | 5 | class Perch(models.Model): 6 | size = models.IntegerField() 7 | smelly = models.BooleanField(default=True) 8 | 9 | class Parrot(models.Model): 10 | state = models.CharField(max_length=50) 11 | perch = models.ForeignKey(Perch, null=True) 12 | 13 | def __unicode__(self): 14 | return self.state 15 | 16 | class Meta: 17 | ordering = ['state'] 18 | 19 | class Link(models.Model): 20 | name = models.CharField(max_length=50) 21 | 22 | def __unicode__(self): 23 | return self.name 24 | 25 | class Meta: 26 | ordering = ['name'] 27 | 28 | class Article(models.Model): 29 | name = models.CharField(max_length=50) 30 | 31 | def __unicode__(self): 32 | return self.name 33 | 34 | class Meta: 35 | ordering = ['name'] 36 | 37 | class FormTest(models.Model): 38 | tags = TagField('Test', help_text='Test') 39 | 40 | class FormTestNull(models.Model): 41 | tags = TagField(null=True) 42 | 43 | -------------------------------------------------------------------------------- /tagging/tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | DIRNAME = os.path.dirname(__file__) 3 | 4 | DEFAULT_CHARSET = 'utf-8' 5 | 6 | test_engine = os.environ.get("TAGGING_TEST_ENGINE", "sqlite3") 7 | 8 | DATABASE_ENGINE = test_engine 9 | DATABASE_NAME = os.environ.get("TAGGING_DATABASE_NAME", "tagging_test") 10 | DATABASE_USER = os.environ.get("TAGGING_DATABASE_USER", "") 11 | DATABASE_PASSWORD = os.environ.get("TAGGING_DATABASE_PASSWORD", "") 12 | DATABASE_HOST = os.environ.get("TAGGING_DATABASE_HOST", "localhost") 13 | 14 | if test_engine == "sqlite": 15 | DATABASE_NAME = os.path.join(DIRNAME, 'tagging_test.db') 16 | DATABASE_HOST = "" 17 | elif test_engine == "mysql": 18 | DATABASE_PORT = os.environ.get("TAGGING_DATABASE_PORT", 3306) 19 | elif test_engine == "postgresql_psycopg2": 20 | DATABASE_PORT = os.environ.get("TAGGING_DATABASE_PORT", 5432) 21 | 22 | 23 | INSTALLED_APPS = ( 24 | 'django.contrib.contenttypes', 25 | 'tagging', 26 | 'tagging.tests', 27 | ) 28 | -------------------------------------------------------------------------------- /tagging/tests/tags.txt: -------------------------------------------------------------------------------- 1 | NewMedia 53 2 | Website 45 3 | PR 44 4 | Status 44 5 | Collaboration 41 6 | Drupal 34 7 | Journalism 31 8 | Transparency 30 9 | Theory 29 10 | Decentralization 25 11 | EchoChamberProject 24 12 | OpenSource 23 13 | Film 22 14 | Blog 21 15 | Interview 21 16 | Political 21 17 | Worldview 21 18 | Communications 19 19 | Conference 19 20 | Folksonomy 15 21 | MediaCriticism 15 22 | Volunteer 15 23 | Dialogue 13 24 | InternationalLaw 13 25 | Rosen 12 26 | Evolution 11 27 | KentBye 11 28 | Objectivity 11 29 | Plante 11 30 | ToDo 11 31 | Advisor 10 32 | Civics 10 33 | Roadmap 10 34 | Wilber 9 35 | About 8 36 | CivicSpace 8 37 | Ecosystem 8 38 | Choice 7 39 | Murphy 7 40 | Sociology 7 41 | ACH 6 42 | del.icio.us 6 43 | IntelligenceAnalysis 6 44 | Science 6 45 | Credibility 5 46 | Distribution 5 47 | Diversity 5 48 | Errors 5 49 | FinalCutPro 5 50 | Fundraising 5 51 | Law 5 52 | PhilosophyofScience 5 53 | Podcast 5 54 | PoliticalBias 5 55 | Activism 4 56 | Analysis 4 57 | CBS 4 58 | DeceptionDetection 4 59 | Editing 4 60 | History 4 61 | RSS 4 62 | Social 4 63 | Subjectivity 4 64 | Vlog 4 65 | ABC 3 66 | ALTubes 3 67 | Economics 3 68 | FCC 3 69 | NYT 3 70 | Sirota 3 71 | Sundance 3 72 | Training 3 73 | Wiki 3 74 | XML 3 75 | Borger 2 76 | Brody 2 77 | Deliberation 2 78 | EcoVillage 2 79 | Identity 2 80 | LAMP 2 81 | Lobe 2 82 | Maine 2 83 | May 2 84 | MediaLogic 2 85 | Metaphor 2 86 | Mitchell 2 87 | NBC 2 88 | OHanlon 2 89 | Psychology 2 90 | Queen 2 91 | Software 2 92 | SpiralDynamics 2 93 | Strobel 2 94 | Sustainability 2 95 | Transcripts 2 96 | Brown 1 97 | Buddhism 1 98 | Community 1 99 | DigitalDivide 1 100 | Donnelly 1 101 | Education 1 102 | FairUse 1 103 | FireANT 1 104 | Google 1 105 | HumanRights 1 106 | KM 1 107 | Kwiatkowski 1 108 | Landay 1 109 | Loiseau 1 110 | Math 1 111 | Music 1 112 | Nature 1 113 | Schechter 1 114 | Screencast 1 115 | Sivaraksa 1 116 | Skype 1 117 | SocialCapital 1 118 | TagCloud 1 119 | Thielmann 1 120 | Thomas 1 121 | Tiger 1 122 | Wedgwood 1 -------------------------------------------------------------------------------- /tagging/tests/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | from django import forms 5 | from django.db.models import Q 6 | from django.test import TestCase 7 | from tagging.forms import TagField 8 | from tagging import settings 9 | from tagging.models import Tag, TaggedItem 10 | from tagging.tests.models import Article, Link, Perch, Parrot, FormTest, FormTestNull 11 | from tagging.utils import calculate_cloud, edit_string_for_tags, get_tag_list, get_tag, parse_tag_input 12 | from tagging.utils import LINEAR 13 | 14 | ############# 15 | # Utilities # 16 | ############# 17 | 18 | class TestParseTagInput(TestCase): 19 | def test_with_simple_space_delimited_tags(self): 20 | """ Test with simple space-delimited tags. """ 21 | 22 | self.assertEquals(parse_tag_input('one'), [u'one']) 23 | self.assertEquals(parse_tag_input('one two'), [u'one', u'two']) 24 | self.assertEquals(parse_tag_input('one two three'), [u'one', u'three', u'two']) 25 | self.assertEquals(parse_tag_input('one one two two'), [u'one', u'two']) 26 | 27 | def test_with_comma_delimited_multiple_words(self): 28 | """ Test with comma-delimited multiple words. 29 | An unquoted comma in the input will trigger this. """ 30 | 31 | self.assertEquals(parse_tag_input(',one'), [u'one']) 32 | self.assertEquals(parse_tag_input(',one two'), [u'one two']) 33 | self.assertEquals(parse_tag_input(',one two three'), [u'one two three']) 34 | self.assertEquals(parse_tag_input('a-one, a-two and a-three'), 35 | [u'a-one', u'a-two and a-three']) 36 | 37 | def test_with_double_quoted_multiple_words(self): 38 | """ Test with double-quoted multiple words. 39 | A completed quote will trigger this. Unclosed quotes are ignored. """ 40 | 41 | self.assertEquals(parse_tag_input('"one'), [u'one']) 42 | self.assertEquals(parse_tag_input('"one two'), [u'one', u'two']) 43 | self.assertEquals(parse_tag_input('"one two three'), [u'one', u'three', u'two']) 44 | self.assertEquals(parse_tag_input('"one two"'), [u'one two']) 45 | self.assertEquals(parse_tag_input('a-one "a-two and a-three"'), 46 | [u'a-one', u'a-two and a-three']) 47 | 48 | def test_with_no_loose_commas(self): 49 | """ Test with no loose commas -- split on spaces. """ 50 | self.assertEquals(parse_tag_input('one two "thr,ee"'), [u'one', u'thr,ee', u'two']) 51 | 52 | def test_with_loose_commas(self): 53 | """ Loose commas - split on commas """ 54 | self.assertEquals(parse_tag_input('"one", two three'), [u'one', u'two three']) 55 | 56 | def test_tags_with_double_quotes_can_contain_commas(self): 57 | """ Double quotes can contain commas """ 58 | self.assertEquals(parse_tag_input('a-one "a-two, and a-three"'), 59 | [u'a-one', u'a-two, and a-three']) 60 | self.assertEquals(parse_tag_input('"two", one, one, two, "one"'), 61 | [u'one', u'two']) 62 | 63 | def test_with_naughty_input(self): 64 | """ Test with naughty input. """ 65 | 66 | # Bad users! Naughty users! 67 | self.assertEquals(parse_tag_input(None), []) 68 | self.assertEquals(parse_tag_input(''), []) 69 | self.assertEquals(parse_tag_input('"'), []) 70 | self.assertEquals(parse_tag_input('""'), []) 71 | self.assertEquals(parse_tag_input('"' * 7), []) 72 | self.assertEquals(parse_tag_input(',,,,,,'), []) 73 | self.assertEquals(parse_tag_input('",",",",",",","'), [u',']) 74 | self.assertEquals(parse_tag_input('a-one "a-two" and "a-three'), 75 | [u'a-one', u'a-three', u'a-two', u'and']) 76 | 77 | class TestNormalisedTagListInput(TestCase): 78 | def setUp(self): 79 | self.cheese = Tag.objects.create(name='cheese') 80 | self.toast = Tag.objects.create(name='toast') 81 | 82 | def test_single_tag_object_as_input(self): 83 | self.assertEquals(get_tag_list(self.cheese), [self.cheese]) 84 | 85 | def test_space_delimeted_string_as_input(self): 86 | ret = get_tag_list('cheese toast') 87 | self.assertEquals(len(ret), 2) 88 | self.failUnless(self.cheese in ret) 89 | self.failUnless(self.toast in ret) 90 | 91 | def test_comma_delimeted_string_as_input(self): 92 | ret = get_tag_list('cheese,toast') 93 | self.assertEquals(len(ret), 2) 94 | self.failUnless(self.cheese in ret) 95 | self.failUnless(self.toast in ret) 96 | 97 | def test_with_empty_list(self): 98 | self.assertEquals(get_tag_list([]), []) 99 | 100 | def test_list_of_two_strings(self): 101 | ret = get_tag_list(['cheese', 'toast']) 102 | self.assertEquals(len(ret), 2) 103 | self.failUnless(self.cheese in ret) 104 | self.failUnless(self.toast in ret) 105 | 106 | def test_list_of_tag_primary_keys(self): 107 | ret = get_tag_list([self.cheese.id, self.toast.id]) 108 | self.assertEquals(len(ret), 2) 109 | self.failUnless(self.cheese in ret) 110 | self.failUnless(self.toast in ret) 111 | 112 | def test_list_of_strings_with_strange_nontag_string(self): 113 | ret = get_tag_list(['cheese', 'toast', 'ŠĐĆŽćžšđ']) 114 | self.assertEquals(len(ret), 2) 115 | self.failUnless(self.cheese in ret) 116 | self.failUnless(self.toast in ret) 117 | 118 | def test_list_of_tag_instances(self): 119 | ret = get_tag_list([self.cheese, self.toast]) 120 | self.assertEquals(len(ret), 2) 121 | self.failUnless(self.cheese in ret) 122 | self.failUnless(self.toast in ret) 123 | 124 | def test_tuple_of_instances(self): 125 | ret = get_tag_list((self.cheese, self.toast)) 126 | self.assertEquals(len(ret), 2) 127 | self.failUnless(self.cheese in ret) 128 | self.failUnless(self.toast in ret) 129 | 130 | def test_with_tag_filter(self): 131 | ret = get_tag_list(Tag.objects.filter(name__in=['cheese', 'toast'])) 132 | self.assertEquals(len(ret), 2) 133 | self.failUnless(self.cheese in ret) 134 | self.failUnless(self.toast in ret) 135 | 136 | def test_with_invalid_input_mix_of_string_and_instance(self): 137 | try: 138 | get_tag_list(['cheese', self.toast]) 139 | except ValueError, ve: 140 | self.assertEquals(str(ve), 141 | 'If a list or tuple of tags is provided, they must all be tag names, Tag objects or Tag ids.') 142 | except Exception, e: 143 | raise self.failureException('the wrong type of exception was raised: type [%s] value [%]' %\ 144 | (str(type(e)), str(e))) 145 | else: 146 | raise self.failureException('a ValueError exception was supposed to be raised!') 147 | 148 | def test_with_invalid_input(self): 149 | try: 150 | get_tag_list(29) 151 | except ValueError, ve: 152 | self.assertEquals(str(ve), 'The tag input given was invalid.') 153 | except Exception, e: 154 | raise self.failureException('the wrong type of exception was raised: type [%s] value [%s]' %\ 155 | (str(type(e)), str(e))) 156 | else: 157 | raise self.failureException('a ValueError exception was supposed to be raised!') 158 | 159 | def test_with_tag_instance(self): 160 | self.assertEquals(get_tag(self.cheese), self.cheese) 161 | 162 | def test_with_string(self): 163 | self.assertEquals(get_tag('cheese'), self.cheese) 164 | 165 | def test_with_primary_key(self): 166 | self.assertEquals(get_tag(self.cheese.id), self.cheese) 167 | 168 | def test_nonexistent_tag(self): 169 | self.assertEquals(get_tag('mouse'), None) 170 | 171 | class TestCalculateCloud(TestCase): 172 | def setUp(self): 173 | self.tags = [] 174 | for line in open(os.path.join(os.path.dirname(__file__), 'tags.txt')).readlines(): 175 | name, count = line.rstrip().split() 176 | tag = Tag(name=name) 177 | tag.count = int(count) 178 | self.tags.append(tag) 179 | 180 | def test_default_distribution(self): 181 | sizes = {} 182 | for tag in calculate_cloud(self.tags, steps=5): 183 | sizes[tag.font_size] = sizes.get(tag.font_size, 0) + 1 184 | 185 | # This isn't a pre-calculated test, just making sure it's consistent 186 | self.assertEquals(sizes[1], 48) 187 | self.assertEquals(sizes[2], 30) 188 | self.assertEquals(sizes[3], 19) 189 | self.assertEquals(sizes[4], 15) 190 | self.assertEquals(sizes[5], 10) 191 | 192 | def test_linear_distribution(self): 193 | sizes = {} 194 | for tag in calculate_cloud(self.tags, steps=5, distribution=LINEAR): 195 | sizes[tag.font_size] = sizes.get(tag.font_size, 0) + 1 196 | 197 | # This isn't a pre-calculated test, just making sure it's consistent 198 | self.assertEquals(sizes[1], 97) 199 | self.assertEquals(sizes[2], 12) 200 | self.assertEquals(sizes[3], 7) 201 | self.assertEquals(sizes[4], 2) 202 | self.assertEquals(sizes[5], 4) 203 | 204 | def test_invalid_distribution(self): 205 | try: 206 | calculate_cloud(self.tags, steps=5, distribution='cheese') 207 | except ValueError, ve: 208 | self.assertEquals(str(ve), 'Invalid distribution algorithm specified: cheese.') 209 | except Exception, e: 210 | raise self.failureException('the wrong type of exception was raised: type [%s] value [%s]' %\ 211 | (str(type(e)), str(e))) 212 | else: 213 | raise self.failureException('a ValueError exception was supposed to be raised!') 214 | 215 | ########### 216 | # Tagging # 217 | ########### 218 | 219 | class TestBasicTagging(TestCase): 220 | def setUp(self): 221 | self.dead_parrot = Parrot.objects.create(state='dead') 222 | 223 | def test_update_tags(self): 224 | Tag.objects.update_tags(self.dead_parrot, 'foo,bar,"ter"') 225 | tags = Tag.objects.get_for_object(self.dead_parrot) 226 | self.assertEquals(len(tags), 3) 227 | self.failUnless(get_tag('foo') in tags) 228 | self.failUnless(get_tag('bar') in tags) 229 | self.failUnless(get_tag('ter') in tags) 230 | 231 | Tag.objects.update_tags(self.dead_parrot, '"foo" bar "baz"') 232 | tags = Tag.objects.get_for_object(self.dead_parrot) 233 | self.assertEquals(len(tags), 3) 234 | self.failUnless(get_tag('bar') in tags) 235 | self.failUnless(get_tag('baz') in tags) 236 | self.failUnless(get_tag('foo') in tags) 237 | 238 | def test_add_tag(self): 239 | # start off in a known, mildly interesting state 240 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 241 | tags = Tag.objects.get_for_object(self.dead_parrot) 242 | self.assertEquals(len(tags), 3) 243 | self.failUnless(get_tag('bar') in tags) 244 | self.failUnless(get_tag('baz') in tags) 245 | self.failUnless(get_tag('foo') in tags) 246 | 247 | # try to add a tag that already exists 248 | Tag.objects.add_tag(self.dead_parrot, 'foo') 249 | tags = Tag.objects.get_for_object(self.dead_parrot) 250 | self.assertEquals(len(tags), 3) 251 | self.failUnless(get_tag('bar') in tags) 252 | self.failUnless(get_tag('baz') in tags) 253 | self.failUnless(get_tag('foo') in tags) 254 | 255 | # now add a tag that doesn't already exist 256 | Tag.objects.add_tag(self.dead_parrot, 'zip') 257 | tags = Tag.objects.get_for_object(self.dead_parrot) 258 | self.assertEquals(len(tags), 4) 259 | self.failUnless(get_tag('zip') in tags) 260 | self.failUnless(get_tag('bar') in tags) 261 | self.failUnless(get_tag('baz') in tags) 262 | self.failUnless(get_tag('foo') in tags) 263 | 264 | def test_add_tag_invalid_input_no_tags_specified(self): 265 | # start off in a known, mildly interesting state 266 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 267 | tags = Tag.objects.get_for_object(self.dead_parrot) 268 | self.assertEquals(len(tags), 3) 269 | self.failUnless(get_tag('bar') in tags) 270 | self.failUnless(get_tag('baz') in tags) 271 | self.failUnless(get_tag('foo') in tags) 272 | 273 | try: 274 | Tag.objects.add_tag(self.dead_parrot, ' ') 275 | except AttributeError, ae: 276 | self.assertEquals(str(ae), 'No tags were given: " ".') 277 | except Exception, e: 278 | raise self.failureException('the wrong type of exception was raised: type [%s] value [%s]' %\ 279 | (str(type(e)), str(e))) 280 | else: 281 | raise self.failureException('an AttributeError exception was supposed to be raised!') 282 | 283 | def test_add_tag_invalid_input_multiple_tags_specified(self): 284 | # start off in a known, mildly interesting state 285 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 286 | tags = Tag.objects.get_for_object(self.dead_parrot) 287 | self.assertEquals(len(tags), 3) 288 | self.failUnless(get_tag('bar') in tags) 289 | self.failUnless(get_tag('baz') in tags) 290 | self.failUnless(get_tag('foo') in tags) 291 | 292 | try: 293 | Tag.objects.add_tag(self.dead_parrot, 'one two') 294 | except AttributeError, ae: 295 | self.assertEquals(str(ae), 'Multiple tags were given: "one two".') 296 | except Exception, e: 297 | raise self.failureException('the wrong type of exception was raised: type [%s] value [%s]' %\ 298 | (str(type(e)), str(e))) 299 | else: 300 | raise self.failureException('an AttributeError exception was supposed to be raised!') 301 | 302 | def test_update_tags_exotic_characters(self): 303 | # start off in a known, mildly interesting state 304 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 305 | tags = Tag.objects.get_for_object(self.dead_parrot) 306 | self.assertEquals(len(tags), 3) 307 | self.failUnless(get_tag('bar') in tags) 308 | self.failUnless(get_tag('baz') in tags) 309 | self.failUnless(get_tag('foo') in tags) 310 | 311 | Tag.objects.update_tags(self.dead_parrot, u'ŠĐĆŽćžšđ') 312 | tags = Tag.objects.get_for_object(self.dead_parrot) 313 | self.assertEquals(len(tags), 1) 314 | self.assertEquals(tags[0].name, u'ŠĐĆŽćžšđ') 315 | 316 | Tag.objects.update_tags(self.dead_parrot, u'你好') 317 | tags = Tag.objects.get_for_object(self.dead_parrot) 318 | self.assertEquals(len(tags), 1) 319 | self.assertEquals(tags[0].name, u'你好') 320 | 321 | def test_update_tags_with_none(self): 322 | # start off in a known, mildly interesting state 323 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 324 | tags = Tag.objects.get_for_object(self.dead_parrot) 325 | self.assertEquals(len(tags), 3) 326 | self.failUnless(get_tag('bar') in tags) 327 | self.failUnless(get_tag('baz') in tags) 328 | self.failUnless(get_tag('foo') in tags) 329 | 330 | Tag.objects.update_tags(self.dead_parrot, None) 331 | tags = Tag.objects.get_for_object(self.dead_parrot) 332 | self.assertEquals(len(tags), 0) 333 | 334 | class TestModelTagField(TestCase): 335 | """ Test the 'tags' field on models. """ 336 | 337 | def test_create_with_tags_specified(self): 338 | f1 = FormTest.objects.create(tags=u'test3 test2 test1') 339 | tags = Tag.objects.get_for_object(f1) 340 | test1_tag = get_tag('test1') 341 | test2_tag = get_tag('test2') 342 | test3_tag = get_tag('test3') 343 | self.failUnless(None not in (test1_tag, test2_tag, test3_tag)) 344 | self.assertEquals(len(tags), 3) 345 | self.failUnless(test1_tag in tags) 346 | self.failUnless(test2_tag in tags) 347 | self.failUnless(test3_tag in tags) 348 | 349 | def test_update_via_tags_field(self): 350 | f1 = FormTest.objects.create(tags=u'test3 test2 test1') 351 | tags = Tag.objects.get_for_object(f1) 352 | test1_tag = get_tag('test1') 353 | test2_tag = get_tag('test2') 354 | test3_tag = get_tag('test3') 355 | self.failUnless(None not in (test1_tag, test2_tag, test3_tag)) 356 | self.assertEquals(len(tags), 3) 357 | self.failUnless(test1_tag in tags) 358 | self.failUnless(test2_tag in tags) 359 | self.failUnless(test3_tag in tags) 360 | 361 | f1.tags = u'test4' 362 | f1.save() 363 | tags = Tag.objects.get_for_object(f1) 364 | test4_tag = get_tag('test4') 365 | self.assertEquals(len(tags), 1) 366 | self.assertEquals(tags[0], test4_tag) 367 | 368 | f1.tags = '' 369 | f1.save() 370 | tags = Tag.objects.get_for_object(f1) 371 | self.assertEquals(len(tags), 0) 372 | 373 | def test_update_via_tags(self): 374 | f1 = FormTest.objects.create(tags=u'one two three') 375 | Tag.objects.get(name='three').delete() 376 | t2 = Tag.objects.get(name='two') 377 | t2.name = 'new' 378 | t2.save() 379 | f1again = FormTest.objects.get(pk=f1.pk) 380 | self.failIf('three' in f1again.tags) 381 | self.failIf('two' in f1again.tags) 382 | self.failUnless('new' in f1again.tags) 383 | 384 | def test_creation_without_specifying_tags(self): 385 | f1 = FormTest() 386 | self.assertEquals(f1.tags, '') 387 | 388 | def test_creation_with_nullable_tags_field(self): 389 | f1 = FormTestNull() 390 | self.assertEquals(f1.tags, '') 391 | 392 | class TestSettings(TestCase): 393 | def setUp(self): 394 | self.original_force_lower_case_tags = settings.FORCE_LOWERCASE_TAGS 395 | self.dead_parrot = Parrot.objects.create(state='dead') 396 | 397 | def tearDown(self): 398 | settings.FORCE_LOWERCASE_TAGS = self.original_force_lower_case_tags 399 | 400 | def test_force_lowercase_tags(self): 401 | """ Test forcing tags to lowercase. """ 402 | 403 | settings.FORCE_LOWERCASE_TAGS = True 404 | 405 | Tag.objects.update_tags(self.dead_parrot, 'foO bAr Ter') 406 | tags = Tag.objects.get_for_object(self.dead_parrot) 407 | self.assertEquals(len(tags), 3) 408 | foo_tag = get_tag('foo') 409 | bar_tag = get_tag('bar') 410 | ter_tag = get_tag('ter') 411 | self.failUnless(foo_tag in tags) 412 | self.failUnless(bar_tag in tags) 413 | self.failUnless(ter_tag in tags) 414 | 415 | Tag.objects.update_tags(self.dead_parrot, 'foO bAr baZ') 416 | tags = Tag.objects.get_for_object(self.dead_parrot) 417 | baz_tag = get_tag('baz') 418 | self.assertEquals(len(tags), 3) 419 | self.failUnless(bar_tag in tags) 420 | self.failUnless(baz_tag in tags) 421 | self.failUnless(foo_tag in tags) 422 | 423 | Tag.objects.add_tag(self.dead_parrot, 'FOO') 424 | tags = Tag.objects.get_for_object(self.dead_parrot) 425 | self.assertEquals(len(tags), 3) 426 | self.failUnless(bar_tag in tags) 427 | self.failUnless(baz_tag in tags) 428 | self.failUnless(foo_tag in tags) 429 | 430 | Tag.objects.add_tag(self.dead_parrot, 'Zip') 431 | tags = Tag.objects.get_for_object(self.dead_parrot) 432 | self.assertEquals(len(tags), 4) 433 | zip_tag = get_tag('zip') 434 | self.failUnless(bar_tag in tags) 435 | self.failUnless(baz_tag in tags) 436 | self.failUnless(foo_tag in tags) 437 | self.failUnless(zip_tag in tags) 438 | 439 | f1 = FormTest.objects.create() 440 | f1.tags = u'TEST5' 441 | f1.save() 442 | tags = Tag.objects.get_for_object(f1) 443 | test5_tag = get_tag('test5') 444 | self.assertEquals(len(tags), 1) 445 | self.failUnless(test5_tag in tags) 446 | self.assertEquals(f1.tags, u'test5') 447 | 448 | class TestTagUsageForModelBaseCase(TestCase): 449 | def test_tag_usage_for_model_empty(self): 450 | self.assertEquals(Tag.objects.usage_for_model(Parrot), []) 451 | 452 | class TestTagUsageForModel(TestCase): 453 | def setUp(self): 454 | parrot_details = ( 455 | ('pining for the fjords', 9, True, 'foo bar'), 456 | ('passed on', 6, False, 'bar baz ter'), 457 | ('no more', 4, True, 'foo ter'), 458 | ('late', 2, False, 'bar ter'), 459 | ) 460 | 461 | for state, perch_size, perch_smelly, tags in parrot_details: 462 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 463 | parrot = Parrot.objects.create(state=state, perch=perch) 464 | Tag.objects.update_tags(parrot, tags) 465 | 466 | def test_tag_usage_for_model(self): 467 | tag_usage = Tag.objects.usage_for_model(Parrot, counts=True) 468 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 469 | self.assertEquals(len(relevant_attribute_list), 4) 470 | self.failUnless((u'bar', 3) in relevant_attribute_list) 471 | self.failUnless((u'baz', 1) in relevant_attribute_list) 472 | self.failUnless((u'foo', 2) in relevant_attribute_list) 473 | self.failUnless((u'ter', 3) in relevant_attribute_list) 474 | 475 | def test_tag_usage_for_model_with_min_count(self): 476 | tag_usage = Tag.objects.usage_for_model(Parrot, min_count = 2) 477 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 478 | self.assertEquals(len(relevant_attribute_list), 3) 479 | self.failUnless((u'bar', 3) in relevant_attribute_list) 480 | self.failUnless((u'foo', 2) in relevant_attribute_list) 481 | self.failUnless((u'ter', 3) in relevant_attribute_list) 482 | 483 | def test_tag_usage_with_filter_on_model_objects(self): 484 | tag_usage = Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(state='no more')) 485 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 486 | self.assertEquals(len(relevant_attribute_list), 2) 487 | self.failUnless((u'foo', 1) in relevant_attribute_list) 488 | self.failUnless((u'ter', 1) in relevant_attribute_list) 489 | 490 | tag_usage = Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(state__startswith='p')) 491 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 492 | self.assertEquals(len(relevant_attribute_list), 4) 493 | self.failUnless((u'bar', 2) in relevant_attribute_list) 494 | self.failUnless((u'baz', 1) in relevant_attribute_list) 495 | self.failUnless((u'foo', 1) in relevant_attribute_list) 496 | self.failUnless((u'ter', 1) in relevant_attribute_list) 497 | 498 | tag_usage = Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(perch__size__gt=4)) 499 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 500 | self.assertEquals(len(relevant_attribute_list), 4) 501 | self.failUnless((u'bar', 2) in relevant_attribute_list) 502 | self.failUnless((u'baz', 1) in relevant_attribute_list) 503 | self.failUnless((u'foo', 1) in relevant_attribute_list) 504 | self.failUnless((u'ter', 1) in relevant_attribute_list) 505 | 506 | tag_usage = Tag.objects.usage_for_model(Parrot, counts=True, filters=dict(perch__smelly=True)) 507 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 508 | self.assertEquals(len(relevant_attribute_list), 3) 509 | self.failUnless((u'bar', 1) in relevant_attribute_list) 510 | self.failUnless((u'foo', 2) in relevant_attribute_list) 511 | self.failUnless((u'ter', 1) in relevant_attribute_list) 512 | 513 | tag_usage = Tag.objects.usage_for_model(Parrot, min_count=2, filters=dict(perch__smelly=True)) 514 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 515 | self.assertEquals(len(relevant_attribute_list), 1) 516 | self.failUnless((u'foo', 2) in relevant_attribute_list) 517 | 518 | tag_usage = Tag.objects.usage_for_model(Parrot, filters=dict(perch__size__gt=4)) 519 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) for tag in tag_usage] 520 | self.assertEquals(len(relevant_attribute_list), 4) 521 | self.failUnless((u'bar', False) in relevant_attribute_list) 522 | self.failUnless((u'baz', False) in relevant_attribute_list) 523 | self.failUnless((u'foo', False) in relevant_attribute_list) 524 | self.failUnless((u'ter', False) in relevant_attribute_list) 525 | 526 | tag_usage = Tag.objects.usage_for_model(Parrot, filters=dict(perch__size__gt=99)) 527 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) for tag in tag_usage] 528 | self.assertEquals(len(relevant_attribute_list), 0) 529 | 530 | class TestTagsRelatedForModel(TestCase): 531 | def setUp(self): 532 | parrot_details = ( 533 | ('pining for the fjords', 9, True, 'foo bar'), 534 | ('passed on', 6, False, 'bar baz ter'), 535 | ('no more', 4, True, 'foo ter'), 536 | ('late', 2, False, 'bar ter'), 537 | ) 538 | 539 | for state, perch_size, perch_smelly, tags in parrot_details: 540 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 541 | parrot = Parrot.objects.create(state=state, perch=perch) 542 | Tag.objects.update_tags(parrot, tags) 543 | 544 | def test_related_for_model_with_tag_query_sets_as_input(self): 545 | related_tags = Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar']), Parrot, counts=True) 546 | relevant_attribute_list = [(tag.name, tag.count) for tag in related_tags] 547 | self.assertEquals(len(relevant_attribute_list), 3) 548 | self.failUnless((u'baz', 1) in relevant_attribute_list) 549 | self.failUnless((u'foo', 1) in relevant_attribute_list) 550 | self.failUnless((u'ter', 2) in relevant_attribute_list) 551 | 552 | related_tags = Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar']), Parrot, min_count=2) 553 | relevant_attribute_list = [(tag.name, tag.count) for tag in related_tags] 554 | self.assertEquals(len(relevant_attribute_list), 1) 555 | self.failUnless((u'ter', 2) in relevant_attribute_list) 556 | 557 | related_tags = Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar']), Parrot, counts=False) 558 | relevant_attribute_list = [tag.name for tag in related_tags] 559 | self.assertEquals(len(relevant_attribute_list), 3) 560 | self.failUnless(u'baz' in relevant_attribute_list) 561 | self.failUnless(u'foo' in relevant_attribute_list) 562 | self.failUnless(u'ter' in relevant_attribute_list) 563 | 564 | related_tags = Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar', 'ter']), Parrot, counts=True) 565 | relevant_attribute_list = [(tag.name, tag.count) for tag in related_tags] 566 | self.assertEquals(len(relevant_attribute_list), 1) 567 | self.failUnless((u'baz', 1) in relevant_attribute_list) 568 | 569 | related_tags = Tag.objects.related_for_model(Tag.objects.filter(name__in=['bar', 'ter', 'baz']), Parrot, counts=True) 570 | relevant_attribute_list = [(tag.name, tag.count) for tag in related_tags] 571 | self.assertEquals(len(relevant_attribute_list), 0) 572 | 573 | def test_related_for_model_with_tag_strings_as_input(self): 574 | # Once again, with feeling (strings) 575 | related_tags = Tag.objects.related_for_model('bar', Parrot, counts=True) 576 | relevant_attribute_list = [(tag.name, tag.count) for tag in related_tags] 577 | self.assertEquals(len(relevant_attribute_list), 3) 578 | self.failUnless((u'baz', 1) in relevant_attribute_list) 579 | self.failUnless((u'foo', 1) in relevant_attribute_list) 580 | self.failUnless((u'ter', 2) in relevant_attribute_list) 581 | 582 | related_tags = Tag.objects.related_for_model('bar', Parrot, min_count=2) 583 | relevant_attribute_list = [(tag.name, tag.count) for tag in related_tags] 584 | self.assertEquals(len(relevant_attribute_list), 1) 585 | self.failUnless((u'ter', 2) in relevant_attribute_list) 586 | 587 | related_tags = Tag.objects.related_for_model('bar', Parrot, counts=False) 588 | relevant_attribute_list = [tag.name for tag in related_tags] 589 | self.assertEquals(len(relevant_attribute_list), 3) 590 | self.failUnless(u'baz' in relevant_attribute_list) 591 | self.failUnless(u'foo' in relevant_attribute_list) 592 | self.failUnless(u'ter' in relevant_attribute_list) 593 | 594 | related_tags = Tag.objects.related_for_model(['bar', 'ter'], Parrot, counts=True) 595 | relevant_attribute_list = [(tag.name, tag.count) for tag in related_tags] 596 | self.assertEquals(len(relevant_attribute_list), 1) 597 | self.failUnless((u'baz', 1) in relevant_attribute_list) 598 | 599 | related_tags = Tag.objects.related_for_model(['bar', 'ter', 'baz'], Parrot, counts=True) 600 | relevant_attribute_list = [(tag.name, tag.count) for tag in related_tags] 601 | self.assertEquals(len(relevant_attribute_list), 0) 602 | 603 | class TestGetTaggedObjectsByModel(TestCase): 604 | def setUp(self): 605 | parrot_details = ( 606 | ('pining for the fjords', 9, True, 'foo bar'), 607 | ('passed on', 6, False, 'bar baz ter'), 608 | ('no more', 4, True, 'foo ter'), 609 | ('late', 2, False, 'bar ter'), 610 | ) 611 | 612 | for state, perch_size, perch_smelly, tags in parrot_details: 613 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 614 | parrot = Parrot.objects.create(state=state, perch=perch) 615 | Tag.objects.update_tags(parrot, tags) 616 | 617 | self.foo = Tag.objects.get(name='foo') 618 | self.bar = Tag.objects.get(name='bar') 619 | self.baz = Tag.objects.get(name='baz') 620 | self.ter = Tag.objects.get(name='ter') 621 | 622 | self.pining_for_the_fjords_parrot = Parrot.objects.get(state='pining for the fjords') 623 | self.passed_on_parrot = Parrot.objects.get(state='passed on') 624 | self.no_more_parrot = Parrot.objects.get(state='no more') 625 | self.late_parrot = Parrot.objects.get(state='late') 626 | 627 | def test_get_by_model_simple(self): 628 | parrots = TaggedItem.objects.get_by_model(Parrot, self.foo) 629 | self.assertEquals(len(parrots), 2) 630 | self.failUnless(self.no_more_parrot in parrots) 631 | self.failUnless(self.pining_for_the_fjords_parrot in parrots) 632 | 633 | parrots = TaggedItem.objects.get_by_model(Parrot, self.bar) 634 | self.assertEquals(len(parrots), 3) 635 | self.failUnless(self.late_parrot in parrots) 636 | self.failUnless(self.passed_on_parrot in parrots) 637 | self.failUnless(self.pining_for_the_fjords_parrot in parrots) 638 | 639 | def test_get_by_model_intersection(self): 640 | parrots = TaggedItem.objects.get_by_model(Parrot, [self.foo, self.baz]) 641 | self.assertEquals(len(parrots), 0) 642 | 643 | parrots = TaggedItem.objects.get_by_model(Parrot, [self.foo, self.bar]) 644 | self.assertEquals(len(parrots), 1) 645 | self.failUnless(self.pining_for_the_fjords_parrot in parrots) 646 | 647 | parrots = TaggedItem.objects.get_by_model(Parrot, [self.bar, self.ter]) 648 | self.assertEquals(len(parrots), 2) 649 | self.failUnless(self.late_parrot in parrots) 650 | self.failUnless(self.passed_on_parrot in parrots) 651 | 652 | # Issue 114 - Intersection with non-existant tags 653 | parrots = TaggedItem.objects.get_intersection_by_model(Parrot, []) 654 | self.assertEquals(len(parrots), 0) 655 | 656 | def test_get_by_model_with_tag_querysets_as_input(self): 657 | parrots = TaggedItem.objects.get_by_model(Parrot, Tag.objects.filter(name__in=['foo', 'baz'])) 658 | self.assertEquals(len(parrots), 0) 659 | 660 | parrots = TaggedItem.objects.get_by_model(Parrot, Tag.objects.filter(name__in=['foo', 'bar'])) 661 | self.assertEquals(len(parrots), 1) 662 | self.failUnless(self.pining_for_the_fjords_parrot in parrots) 663 | 664 | parrots = TaggedItem.objects.get_by_model(Parrot, Tag.objects.filter(name__in=['bar', 'ter'])) 665 | self.assertEquals(len(parrots), 2) 666 | self.failUnless(self.late_parrot in parrots) 667 | self.failUnless(self.passed_on_parrot in parrots) 668 | 669 | def test_get_by_model_with_strings_as_input(self): 670 | parrots = TaggedItem.objects.get_by_model(Parrot, 'foo baz') 671 | self.assertEquals(len(parrots), 0) 672 | 673 | parrots = TaggedItem.objects.get_by_model(Parrot, 'foo bar') 674 | self.assertEquals(len(parrots), 1) 675 | self.failUnless(self.pining_for_the_fjords_parrot in parrots) 676 | 677 | parrots = TaggedItem.objects.get_by_model(Parrot, 'bar ter') 678 | self.assertEquals(len(parrots), 2) 679 | self.failUnless(self.late_parrot in parrots) 680 | self.failUnless(self.passed_on_parrot in parrots) 681 | 682 | def test_get_by_model_with_lists_of_strings_as_input(self): 683 | parrots = TaggedItem.objects.get_by_model(Parrot, ['foo', 'baz']) 684 | self.assertEquals(len(parrots), 0) 685 | 686 | parrots = TaggedItem.objects.get_by_model(Parrot, ['foo', 'bar']) 687 | self.assertEquals(len(parrots), 1) 688 | self.failUnless(self.pining_for_the_fjords_parrot in parrots) 689 | 690 | parrots = TaggedItem.objects.get_by_model(Parrot, ['bar', 'ter']) 691 | self.assertEquals(len(parrots), 2) 692 | self.failUnless(self.late_parrot in parrots) 693 | self.failUnless(self.passed_on_parrot in parrots) 694 | 695 | def test_get_by_nonexistent_tag(self): 696 | # Issue 50 - Get by non-existent tag 697 | parrots = TaggedItem.objects.get_by_model(Parrot, 'argatrons') 698 | self.assertEquals(len(parrots), 0) 699 | 700 | def test_get_union_by_model(self): 701 | parrots = TaggedItem.objects.get_union_by_model(Parrot, ['foo', 'ter']) 702 | self.assertEquals(len(parrots), 4) 703 | self.failUnless(self.late_parrot in parrots) 704 | self.failUnless(self.no_more_parrot in parrots) 705 | self.failUnless(self.passed_on_parrot in parrots) 706 | self.failUnless(self.pining_for_the_fjords_parrot in parrots) 707 | 708 | parrots = TaggedItem.objects.get_union_by_model(Parrot, ['bar', 'baz']) 709 | self.assertEquals(len(parrots), 3) 710 | self.failUnless(self.late_parrot in parrots) 711 | self.failUnless(self.passed_on_parrot in parrots) 712 | self.failUnless(self.pining_for_the_fjords_parrot in parrots) 713 | 714 | # Issue 114 - Union with non-existant tags 715 | parrots = TaggedItem.objects.get_union_by_model(Parrot, []) 716 | self.assertEquals(len(parrots), 0) 717 | 718 | class TestGetRelatedTaggedItems(TestCase): 719 | def setUp(self): 720 | parrot_details = ( 721 | ('pining for the fjords', 9, True, 'foo bar'), 722 | ('passed on', 6, False, 'bar baz ter'), 723 | ('no more', 4, True, 'foo ter'), 724 | ('late', 2, False, 'bar ter'), 725 | ) 726 | 727 | for state, perch_size, perch_smelly, tags in parrot_details: 728 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 729 | parrot = Parrot.objects.create(state=state, perch=perch) 730 | Tag.objects.update_tags(parrot, tags) 731 | 732 | self.l1 = Link.objects.create(name='link 1') 733 | Tag.objects.update_tags(self.l1, 'tag1 tag2 tag3 tag4 tag5') 734 | self.l2 = Link.objects.create(name='link 2') 735 | Tag.objects.update_tags(self.l2, 'tag1 tag2 tag3') 736 | self.l3 = Link.objects.create(name='link 3') 737 | Tag.objects.update_tags(self.l3, 'tag1') 738 | self.l4 = Link.objects.create(name='link 4') 739 | 740 | self.a1 = Article.objects.create(name='article 1') 741 | Tag.objects.update_tags(self.a1, 'tag1 tag2 tag3 tag4') 742 | 743 | def test_get_related_objects_of_same_model(self): 744 | related_objects = TaggedItem.objects.get_related(self.l1, Link) 745 | self.assertEquals(len(related_objects), 2) 746 | self.failUnless(self.l2 in related_objects) 747 | self.failUnless(self.l3 in related_objects) 748 | 749 | related_objects = TaggedItem.objects.get_related(self.l4, Link) 750 | self.assertEquals(len(related_objects), 0) 751 | 752 | def test_get_related_objects_of_same_model_limited_number_of_results(self): 753 | # This fails on Oracle because it has no support for a 'LIMIT' clause. 754 | # See http://asktom.oracle.com/pls/asktom/f?p=100:11:0::::P11_QUESTION_ID:127412348064 755 | 756 | # ask for no more than 1 result 757 | related_objects = TaggedItem.objects.get_related(self.l1, Link, num=1) 758 | self.assertEquals(len(related_objects), 1) 759 | self.failUnless(self.l2 in related_objects) 760 | 761 | def test_get_related_objects_of_same_model_limit_related_items(self): 762 | related_objects = TaggedItem.objects.get_related(self.l1, Link.objects.exclude(name='link 3')) 763 | self.assertEquals(len(related_objects), 1) 764 | self.failUnless(self.l2 in related_objects) 765 | 766 | def test_get_related_objects_of_different_model(self): 767 | related_objects = TaggedItem.objects.get_related(self.a1, Link) 768 | self.assertEquals(len(related_objects), 3) 769 | self.failUnless(self.l1 in related_objects) 770 | self.failUnless(self.l2 in related_objects) 771 | self.failUnless(self.l3 in related_objects) 772 | 773 | Tag.objects.update_tags(self.a1, 'tag6') 774 | related_objects = TaggedItem.objects.get_related(self.a1, Link) 775 | self.assertEquals(len(related_objects), 0) 776 | 777 | class TestTagUsageForQuerySet(TestCase): 778 | def setUp(self): 779 | parrot_details = ( 780 | ('pining for the fjords', 9, True, 'foo bar'), 781 | ('passed on', 6, False, 'bar baz ter'), 782 | ('no more', 4, True, 'foo ter'), 783 | ('late', 2, False, 'bar ter'), 784 | ) 785 | 786 | for state, perch_size, perch_smelly, tags in parrot_details: 787 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 788 | parrot = Parrot.objects.create(state=state, perch=perch) 789 | Tag.objects.update_tags(parrot, tags) 790 | 791 | def test_tag_usage_for_queryset(self): 792 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(state='no more'), counts=True) 793 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 794 | self.assertEquals(len(relevant_attribute_list), 2) 795 | self.failUnless((u'foo', 1) in relevant_attribute_list) 796 | self.failUnless((u'ter', 1) in relevant_attribute_list) 797 | 798 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(state__startswith='p'), counts=True) 799 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 800 | self.assertEquals(len(relevant_attribute_list), 4) 801 | self.failUnless((u'bar', 2) in relevant_attribute_list) 802 | self.failUnless((u'baz', 1) in relevant_attribute_list) 803 | self.failUnless((u'foo', 1) in relevant_attribute_list) 804 | self.failUnless((u'ter', 1) in relevant_attribute_list) 805 | 806 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(perch__size__gt=4), counts=True) 807 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 808 | self.assertEquals(len(relevant_attribute_list), 4) 809 | self.failUnless((u'bar', 2) in relevant_attribute_list) 810 | self.failUnless((u'baz', 1) in relevant_attribute_list) 811 | self.failUnless((u'foo', 1) in relevant_attribute_list) 812 | self.failUnless((u'ter', 1) in relevant_attribute_list) 813 | 814 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(perch__smelly=True), counts=True) 815 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 816 | self.assertEquals(len(relevant_attribute_list), 3) 817 | self.failUnless((u'bar', 1) in relevant_attribute_list) 818 | self.failUnless((u'foo', 2) in relevant_attribute_list) 819 | self.failUnless((u'ter', 1) in relevant_attribute_list) 820 | 821 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(perch__smelly=True), min_count=2) 822 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 823 | self.assertEquals(len(relevant_attribute_list), 1) 824 | self.failUnless((u'foo', 2) in relevant_attribute_list) 825 | 826 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(perch__size__gt=4)) 827 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) for tag in tag_usage] 828 | self.assertEquals(len(relevant_attribute_list), 4) 829 | self.failUnless((u'bar', False) in relevant_attribute_list) 830 | self.failUnless((u'baz', False) in relevant_attribute_list) 831 | self.failUnless((u'foo', False) in relevant_attribute_list) 832 | self.failUnless((u'ter', False) in relevant_attribute_list) 833 | 834 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(perch__size__gt=99)) 835 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) for tag in tag_usage] 836 | self.assertEquals(len(relevant_attribute_list), 0) 837 | 838 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(Q(perch__size__gt=6) | Q(state__startswith='l')), counts=True) 839 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 840 | self.assertEquals(len(relevant_attribute_list), 3) 841 | self.failUnless((u'bar', 2) in relevant_attribute_list) 842 | self.failUnless((u'foo', 1) in relevant_attribute_list) 843 | self.failUnless((u'ter', 1) in relevant_attribute_list) 844 | 845 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(Q(perch__size__gt=6) | Q(state__startswith='l')), min_count=2) 846 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 847 | self.assertEquals(len(relevant_attribute_list), 1) 848 | self.failUnless((u'bar', 2) in relevant_attribute_list) 849 | 850 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.filter(Q(perch__size__gt=6) | Q(state__startswith='l'))) 851 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) for tag in tag_usage] 852 | self.assertEquals(len(relevant_attribute_list), 3) 853 | self.failUnless((u'bar', False) in relevant_attribute_list) 854 | self.failUnless((u'foo', False) in relevant_attribute_list) 855 | self.failUnless((u'ter', False) in relevant_attribute_list) 856 | 857 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.exclude(state='passed on'), counts=True) 858 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 859 | self.assertEquals(len(relevant_attribute_list), 3) 860 | self.failUnless((u'bar', 2) in relevant_attribute_list) 861 | self.failUnless((u'foo', 2) in relevant_attribute_list) 862 | self.failUnless((u'ter', 2) in relevant_attribute_list) 863 | 864 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.exclude(state__startswith='p'), min_count=2) 865 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 866 | self.assertEquals(len(relevant_attribute_list), 1) 867 | self.failUnless((u'ter', 2) in relevant_attribute_list) 868 | 869 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.exclude(Q(perch__size__gt=6) | Q(perch__smelly=False)), counts=True) 870 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 871 | self.assertEquals(len(relevant_attribute_list), 2) 872 | self.failUnless((u'foo', 1) in relevant_attribute_list) 873 | self.failUnless((u'ter', 1) in relevant_attribute_list) 874 | 875 | tag_usage = Tag.objects.usage_for_queryset(Parrot.objects.exclude(perch__smelly=True).filter(state__startswith='l'), counts=True) 876 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 877 | self.assertEquals(len(relevant_attribute_list), 2) 878 | self.failUnless((u'bar', 1) in relevant_attribute_list) 879 | self.failUnless((u'ter', 1) in relevant_attribute_list) 880 | 881 | ################ 882 | # Model Fields # 883 | ################ 884 | 885 | class TestTagFieldInForms(TestCase): 886 | def test_tag_field_in_modelform(self): 887 | # Ensure that automatically created forms use TagField 888 | class TestForm(forms.ModelForm): 889 | class Meta: 890 | model = FormTest 891 | 892 | form = TestForm() 893 | self.assertEquals(form.fields['tags'].__class__.__name__, 'TagField') 894 | 895 | def test_recreation_of_tag_list_string_representations(self): 896 | plain = Tag.objects.create(name='plain') 897 | spaces = Tag.objects.create(name='spa ces') 898 | comma = Tag.objects.create(name='com,ma') 899 | self.assertEquals(edit_string_for_tags([plain]), u'plain') 900 | self.assertEquals(edit_string_for_tags([plain, spaces]), u'plain, spa ces') 901 | self.assertEquals(edit_string_for_tags([plain, spaces, comma]), u'plain, spa ces, "com,ma"') 902 | self.assertEquals(edit_string_for_tags([plain, comma]), u'plain "com,ma"') 903 | self.assertEquals(edit_string_for_tags([comma, spaces]), u'"com,ma", spa ces') 904 | 905 | def test_tag_d_validation(self): 906 | t = TagField() 907 | self.assertEquals(t.clean('foo'), u'foo') 908 | self.assertEquals(t.clean('foo bar baz'), u'foo bar baz') 909 | self.assertEquals(t.clean('foo,bar,baz'), u'foo,bar,baz') 910 | self.assertEquals(t.clean('foo, bar, baz'), u'foo, bar, baz') 911 | self.assertEquals(t.clean('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvb bar'), 912 | u'foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvb bar') 913 | try: 914 | t.clean('foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbn bar') 915 | except forms.ValidationError, ve: 916 | self.assertEquals(str(ve), "[u'Each tag may be no more than 50 characters long.']") 917 | except Exception, e: 918 | raise e 919 | else: 920 | raise self.failureException('a ValidationError exception was supposed to have been raised.') 921 | -------------------------------------------------------------------------------- /tagging/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tagging utilities - from user tag input parsing to tag cloud 3 | calculation. 4 | """ 5 | import math 6 | import types 7 | 8 | from django.db.models.query import QuerySet 9 | from django.utils.encoding import force_unicode 10 | from django.utils.translation import ugettext as _ 11 | 12 | # Python 2.3 compatibility 13 | try: 14 | set 15 | except NameError: 16 | from sets import Set as set 17 | 18 | def parse_tag_input(input): 19 | """ 20 | Parses tag input, with multiple word input being activated and 21 | delineated by commas and double quotes. Quotes take precedence, so 22 | they may contain commas. 23 | 24 | Returns a sorted list of unique tag names. 25 | """ 26 | if not input: 27 | return [] 28 | 29 | input = force_unicode(input) 30 | 31 | # Special case - if there are no commas or double quotes in the 32 | # input, we don't *do* a recall... I mean, we know we only need to 33 | # split on spaces. 34 | if u',' not in input and u'"' not in input: 35 | words = list(set(split_strip(input, u' '))) 36 | words.sort() 37 | return words 38 | 39 | words = [] 40 | buffer = [] 41 | # Defer splitting of non-quoted sections until we know if there are 42 | # any unquoted commas. 43 | to_be_split = [] 44 | saw_loose_comma = False 45 | open_quote = False 46 | i = iter(input) 47 | try: 48 | while 1: 49 | c = i.next() 50 | if c == u'"': 51 | if buffer: 52 | to_be_split.append(u''.join(buffer)) 53 | buffer = [] 54 | # Find the matching quote 55 | open_quote = True 56 | c = i.next() 57 | while c != u'"': 58 | buffer.append(c) 59 | c = i.next() 60 | if buffer: 61 | word = u''.join(buffer).strip() 62 | if word: 63 | words.append(word) 64 | buffer = [] 65 | open_quote = False 66 | else: 67 | if not saw_loose_comma and c == u',': 68 | saw_loose_comma = True 69 | buffer.append(c) 70 | except StopIteration: 71 | # If we were parsing an open quote which was never closed treat 72 | # the buffer as unquoted. 73 | if buffer: 74 | if open_quote and u',' in buffer: 75 | saw_loose_comma = True 76 | to_be_split.append(u''.join(buffer)) 77 | if to_be_split: 78 | if saw_loose_comma: 79 | delimiter = u',' 80 | else: 81 | delimiter = u' ' 82 | for chunk in to_be_split: 83 | words.extend(split_strip(chunk, delimiter)) 84 | words = list(set(words)) 85 | words.sort() 86 | return words 87 | 88 | def split_strip(input, delimiter=u','): 89 | """ 90 | Splits ``input`` on ``delimiter``, stripping each resulting string 91 | and returning a list of non-empty strings. 92 | """ 93 | if not input: 94 | return [] 95 | 96 | words = [w.strip() for w in input.split(delimiter)] 97 | return [w for w in words if w] 98 | 99 | def edit_string_for_tags(tags): 100 | """ 101 | Given list of ``Tag`` instances, creates a string representation of 102 | the list suitable for editing by the user, such that submitting the 103 | given string representation back without changing it will give the 104 | same list of tags. 105 | 106 | Tag names which contain commas will be double quoted. 107 | 108 | If any tag name which isn't being quoted contains whitespace, the 109 | resulting string of tag names will be comma-delimited, otherwise 110 | it will be space-delimited. 111 | """ 112 | names = [] 113 | use_commas = False 114 | for tag in tags: 115 | name = tag.name 116 | if u',' in name: 117 | names.append('"%s"' % name) 118 | continue 119 | elif u' ' in name: 120 | if not use_commas: 121 | use_commas = True 122 | names.append(name) 123 | if use_commas: 124 | glue = u', ' 125 | else: 126 | glue = u' ' 127 | return glue.join(names) 128 | 129 | def get_queryset_and_model(queryset_or_model): 130 | """ 131 | Given a ``QuerySet`` or a ``Model``, returns a two-tuple of 132 | (queryset, model). 133 | 134 | If a ``Model`` is given, the ``QuerySet`` returned will be created 135 | using its default manager. 136 | """ 137 | try: 138 | return queryset_or_model, queryset_or_model.model 139 | except AttributeError: 140 | return queryset_or_model._default_manager.all(), queryset_or_model 141 | 142 | def get_tag_list(tags): 143 | """ 144 | Utility function for accepting tag input in a flexible manner. 145 | 146 | If a ``Tag`` object is given, it will be returned in a list as 147 | its single occupant. 148 | 149 | If given, the tag names in the following will be used to create a 150 | ``Tag`` ``QuerySet``: 151 | 152 | * A string, which may contain multiple tag names. 153 | * A list or tuple of strings corresponding to tag names. 154 | * A list or tuple of integers corresponding to tag ids. 155 | 156 | If given, the following will be returned as-is: 157 | 158 | * A list or tuple of ``Tag`` objects. 159 | * A ``Tag`` ``QuerySet``. 160 | 161 | """ 162 | from tagging.models import Tag 163 | if isinstance(tags, Tag): 164 | return [tags] 165 | elif isinstance(tags, QuerySet) and tags.model is Tag: 166 | return tags 167 | elif isinstance(tags, types.StringTypes): 168 | return Tag.objects.filter(name__in=parse_tag_input(tags)) 169 | elif isinstance(tags, (types.ListType, types.TupleType)): 170 | if len(tags) == 0: 171 | return tags 172 | contents = set() 173 | for item in tags: 174 | if isinstance(item, types.StringTypes): 175 | contents.add('string') 176 | elif isinstance(item, Tag): 177 | contents.add('tag') 178 | elif isinstance(item, (types.IntType, types.LongType)): 179 | contents.add('int') 180 | if len(contents) == 1: 181 | if 'string' in contents: 182 | return Tag.objects.filter(name__in=[force_unicode(tag) \ 183 | for tag in tags]) 184 | elif 'tag' in contents: 185 | return tags 186 | elif 'int' in contents: 187 | return Tag.objects.filter(id__in=tags) 188 | else: 189 | raise ValueError(_('If a list or tuple of tags is provided, they must all be tag names, Tag objects or Tag ids.')) 190 | else: 191 | raise ValueError(_('The tag input given was invalid.')) 192 | 193 | def get_tag(tag): 194 | """ 195 | Utility function for accepting single tag input in a flexible 196 | manner. 197 | 198 | If a ``Tag`` object is given it will be returned as-is; if a 199 | string or integer are given, they will be used to lookup the 200 | appropriate ``Tag``. 201 | 202 | If no matching tag can be found, ``None`` will be returned. 203 | """ 204 | from tagging.models import Tag 205 | if isinstance(tag, Tag): 206 | return tag 207 | 208 | try: 209 | if isinstance(tag, types.StringTypes): 210 | return Tag.objects.get(name=tag) 211 | elif isinstance(tag, (types.IntType, types.LongType)): 212 | return Tag.objects.get(id=tag) 213 | except Tag.DoesNotExist: 214 | pass 215 | 216 | return None 217 | 218 | # Font size distribution algorithms 219 | LOGARITHMIC, LINEAR = 1, 2 220 | 221 | def _calculate_thresholds(min_weight, max_weight, steps): 222 | delta = (max_weight - min_weight) / float(steps) 223 | return [min_weight + i * delta for i in range(1, steps + 1)] 224 | 225 | def _calculate_tag_weight(weight, max_weight, distribution): 226 | """ 227 | Logarithmic tag weight calculation is based on code from the 228 | `Tag Cloud`_ plugin for Mephisto, by Sven Fuchs. 229 | 230 | .. _`Tag Cloud`: http://www.artweb-design.de/projects/mephisto-plugin-tag-cloud 231 | """ 232 | if distribution == LINEAR or max_weight == 1: 233 | return weight 234 | elif distribution == LOGARITHMIC: 235 | return math.log(weight) * max_weight / math.log(max_weight) 236 | raise ValueError(_('Invalid distribution algorithm specified: %s.') % distribution) 237 | 238 | def calculate_cloud(tags, steps=4, distribution=LOGARITHMIC): 239 | """ 240 | Add a ``font_size`` attribute to each tag according to the 241 | frequency of its use, as indicated by its ``count`` 242 | attribute. 243 | 244 | ``steps`` defines the range of font sizes - ``font_size`` will 245 | be an integer between 1 and ``steps`` (inclusive). 246 | 247 | ``distribution`` defines the type of font size distribution 248 | algorithm which will be used - logarithmic or linear. It must be 249 | one of ``tagging.utils.LOGARITHMIC`` or ``tagging.utils.LINEAR``. 250 | """ 251 | if len(tags) > 0: 252 | counts = [tag.count for tag in tags] 253 | min_weight = float(min(counts)) 254 | max_weight = float(max(counts)) 255 | thresholds = _calculate_thresholds(min_weight, max_weight, steps) 256 | for tag in tags: 257 | font_set = False 258 | tag_weight = _calculate_tag_weight(tag.count, max_weight, distribution) 259 | for i in range(steps): 260 | if not font_set and tag_weight <= thresholds[i]: 261 | tag.font_size = i + 1 262 | font_set = True 263 | return tags 264 | -------------------------------------------------------------------------------- /tagging/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tagging related views. 3 | """ 4 | from django.http import Http404 5 | from django.utils.translation import ugettext as _ 6 | from django.views.generic.list_detail import object_list 7 | 8 | from tagging.models import Tag, TaggedItem 9 | from tagging.utils import get_tag, get_queryset_and_model 10 | 11 | def tagged_object_list(request, queryset_or_model=None, tag=None, 12 | related_tags=False, related_tag_counts=True, **kwargs): 13 | """ 14 | A thin wrapper around 15 | ``django.views.generic.list_detail.object_list`` which creates a 16 | ``QuerySet`` containing instances of the given queryset or model 17 | tagged with the given tag. 18 | 19 | In addition to the context variables set up by ``object_list``, a 20 | ``tag`` context variable will contain the ``Tag`` instance for the 21 | tag. 22 | 23 | If ``related_tags`` is ``True``, a ``related_tags`` context variable 24 | will contain tags related to the given tag for the given model. 25 | Additionally, if ``related_tag_counts`` is ``True``, each related 26 | tag will have a ``count`` attribute indicating the number of items 27 | which have it in addition to the given tag. 28 | """ 29 | if queryset_or_model is None: 30 | try: 31 | queryset_or_model = kwargs.pop('queryset_or_model') 32 | except KeyError: 33 | raise AttributeError(_('tagged_object_list must be called with a queryset or a model.')) 34 | 35 | if tag is None: 36 | try: 37 | tag = kwargs.pop('tag') 38 | except KeyError: 39 | raise AttributeError(_('tagged_object_list must be called with a tag.')) 40 | 41 | tag_instance = get_tag(tag) 42 | if tag_instance is None: 43 | raise Http404(_('No Tag found matching "%s".') % tag) 44 | queryset = TaggedItem.objects.get_by_model(queryset_or_model, tag_instance) 45 | if not kwargs.has_key('extra_context'): 46 | kwargs['extra_context'] = {} 47 | kwargs['extra_context']['tag'] = tag_instance 48 | if related_tags: 49 | kwargs['extra_context']['related_tags'] = \ 50 | Tag.objects.related_for_model(tag_instance, queryset_or_model, 51 | counts=related_tag_counts) 52 | return object_list(request, queryset, **kwargs) 53 | --------------------------------------------------------------------------------