├── tagging ├── tests │ ├── __init__.py │ ├── urls.py │ ├── utils.py │ ├── settings.py │ ├── models.py │ ├── tags.txt │ └── tests.py ├── migrations │ ├── __init__.py │ ├── 0003_adapt_max_tag_length.py │ ├── 0002_on_delete.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ └── tagging_tags.py ├── apps.py ├── admin.py ├── __init__.py ├── settings.py ├── forms.py ├── registry.py ├── generic.py ├── managers.py ├── views.py ├── fields.py ├── utils.py └── models.py ├── .coveragerc ├── MANIFEST.in ├── README.rst ├── versions.cfg ├── setup.py ├── .travis.yml ├── .github └── workflows │ └── main.yml ├── LICENSE.txt ├── .gitignore ├── CHANGELOG.txt └── docs ├── Makefile ├── conf.py └── index.rst /tagging/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for tagging. 3 | """ 4 | -------------------------------------------------------------------------------- /tagging/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Migrations for tagging. 3 | """ 4 | -------------------------------------------------------------------------------- /tagging/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Templatetags module for tagging. 3 | """ 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = tagging 3 | omit = tagging/tests/* 4 | tagging/migrations/* 5 | 6 | [report] 7 | sort = Miss 8 | show_missing = True 9 | exclude_lines = 10 | pragma: no cover 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.txt 2 | include LICENSE.txt 3 | include MANIFEST.in 4 | include README.rst 5 | include versions.cfg 6 | include buildout.cfg 7 | recursive-include docs * 8 | recursive-include tagging/tests *.txt 9 | prune docs/_build -------------------------------------------------------------------------------- /tagging/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apps for tagging. 3 | """ 4 | from django.apps import AppConfig 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | 8 | class TaggingConfig(AppConfig): 9 | """ 10 | Config for Tagging application. 11 | """ 12 | name = 'tagging' 13 | label = 'tagging' 14 | verbose_name = _('Tagging') 15 | -------------------------------------------------------------------------------- /tagging/admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Admin components for tagging. 3 | """ 4 | from django.contrib import admin 5 | 6 | from tagging.forms import TagAdminForm 7 | from tagging.models import Tag 8 | from tagging.models import TaggedItem 9 | 10 | 11 | class TagAdmin(admin.ModelAdmin): 12 | form = TagAdminForm 13 | 14 | 15 | admin.site.register(TaggedItem) 16 | admin.site.register(Tag, TagAdmin) 17 | -------------------------------------------------------------------------------- /tagging/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django-tagging 3 | """ 4 | __version__ = '0.5.1' 5 | __license__ = 'BSD License' 6 | 7 | __author__ = 'Jonathan Buchanan' 8 | __author_email__ = 'jonathan.buchanan@gmail.com' 9 | 10 | __maintainer__ = 'Fantomas42' 11 | __maintainer_email__ = 'fantomas42@gmail.com' 12 | 13 | __url__ = 'https://github.com/Fantomas42/django-tagging' 14 | 15 | default_app_config = 'tagging.apps.TaggingConfig' 16 | -------------------------------------------------------------------------------- /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 12 | # before they are saved to the database. 13 | FORCE_LOWERCASE_TAGS = getattr(settings, 'FORCE_LOWERCASE_TAGS', False) 14 | -------------------------------------------------------------------------------- /tagging/migrations/0003_adapt_max_tag_length.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('tagging', '0002_on_delete'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name='tag', 14 | name='name', 15 | field=models.CharField( 16 | unique=True, 17 | max_length=getattr(settings, 'MAX_TAG_LENGTH', 50), 18 | verbose_name='name', 19 | db_index=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /tagging/tests/urls.py: -------------------------------------------------------------------------------- 1 | """Test urls for tagging.""" 2 | from django.urls import re_path 3 | 4 | from tagging.tests.models import Article 5 | from tagging.views import TaggedObjectList 6 | 7 | 8 | class StaticTaggedObjectList(TaggedObjectList): 9 | tag = 'static' 10 | queryset = Article.objects.all() 11 | 12 | 13 | urlpatterns = [ 14 | re_path(r'^static/$', StaticTaggedObjectList.as_view()), 15 | re_path(r'^static/related/$', StaticTaggedObjectList.as_view( 16 | related_tags=True)), 17 | re_path(r'^no-tag/$', TaggedObjectList.as_view(model=Article)), 18 | re_path(r'^no-query-no-model/$', TaggedObjectList.as_view()), 19 | re_path(r'^(?P[^/]+(?u))/$', TaggedObjectList.as_view(model=Article)), 20 | ] 21 | -------------------------------------------------------------------------------- /tagging/tests/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests utils for tagging. 3 | """ 4 | from django.template import Origin 5 | from django.template.loaders.base import Loader 6 | 7 | 8 | class VoidLoader(Loader): 9 | """ 10 | Template loader which is always returning 11 | an empty template. 12 | """ 13 | is_usable = True 14 | _accepts_engine_in_init = True 15 | 16 | def get_template_sources(self, template_name): 17 | yield Origin( 18 | name='voidloader', 19 | template_name=template_name, 20 | loader=self) 21 | 22 | def get_contents(self, origin): 23 | return '' 24 | 25 | def load_template_source(self, template_name, template_dirs=None): 26 | return ('', 'voidloader:%s' % template_name) 27 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Django Tagging 3 | ============== 4 | 5 | |travis-develop| |coverage-develop| 6 | 7 | This is a generic tagging application for Django projects 8 | 9 | https://django-tagging.readthedocs.io/ 10 | 11 | Note that this application version requires Python 3.5 or later, and Django 12 | 1.11 or later. You can obtain Python from http://www.python.org/ and 13 | Django from http://www.djangoproject.com/. 14 | 15 | .. |travis-develop| image:: https://travis-ci.org/Fantomas42/django-tagging.png?branch=develop 16 | :alt: Build Status - develop branch 17 | :target: http://travis-ci.org/Fantomas42/django-tagging 18 | .. |coverage-develop| image:: https://coveralls.io/repos/Fantomas42/django-tagging/badge.png?branch=develop 19 | :alt: Coverage of the code 20 | :target: https://coveralls.io/r/Fantomas42/django-tagging 21 | -------------------------------------------------------------------------------- /tagging/migrations/0002_on_delete.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | from django.db import migrations 3 | from django.db import models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tagging', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='taggeditem', 15 | name='content_type', 16 | field=models.ForeignKey( 17 | on_delete=django.db.models.deletion.CASCADE, 18 | to='contenttypes.ContentType', 19 | verbose_name='content type'), 20 | ), 21 | migrations.AlterField( 22 | model_name='taggeditem', 23 | name='tag', 24 | field=models.ForeignKey( 25 | on_delete=django.db.models.deletion.CASCADE, 26 | related_name='items', 27 | to='tagging.Tag', 28 | verbose_name='tag'), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /tagging/tests/settings.py: -------------------------------------------------------------------------------- 1 | """Tests settings""" 2 | import os 3 | 4 | SECRET_KEY = 'secret-key' 5 | 6 | DATABASES = { 7 | 'default': { 8 | 'NAME': 'tagging.db', 9 | 'ENGINE': 'django.db.backends.sqlite3' 10 | } 11 | } 12 | 13 | DATABASE_ENGINE = os.environ.get('DATABASE_ENGINE') 14 | if DATABASE_ENGINE == 'postgres': 15 | DATABASES = { 16 | 'default': { 17 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 18 | 'NAME': 'tagging', 19 | 'USER': 'postgres', 20 | 'HOST': 'localhost' 21 | } 22 | } 23 | elif DATABASE_ENGINE == 'mysql': 24 | DATABASES = { 25 | 'default': { 26 | 'ENGINE': 'django.db.backends.mysql', 27 | 'NAME': 'zinnia', 28 | 'USER': 'root', 29 | 'HOST': 'localhost', 30 | 'TEST': { 31 | 'COLLATION': 'utf8_general_ci' 32 | } 33 | } 34 | } 35 | 36 | INSTALLED_APPS = [ 37 | 'django.contrib.auth', 38 | 'django.contrib.sessions', 39 | 'django.contrib.contenttypes', 40 | 'tagging', 41 | 'tagging.tests', 42 | ] 43 | -------------------------------------------------------------------------------- /versions.cfg: -------------------------------------------------------------------------------- 1 | [versions] 2 | asgiref = 3.2.3 3 | blessings = 1.7 4 | buildout-versions-checker = 1.10.0 5 | certifi = 2019.11.28 6 | chardet = 3.0.4 7 | configparser = 4.0.2 8 | coverage = 5.0.3 9 | django = 3.0.4 10 | entrypoints = 0.3 11 | enum34 = 1.1.9 12 | flake8 = 3.7.9 13 | futures = 3.3.0 14 | idna = 2.9 15 | mccabe = 0.6.1 16 | nose = 1.3.7 17 | nose-progressive = 1.5.2 18 | nose-sfd = 0.4 19 | packaging = 20.3 20 | pbp.recipe.noserunner = 0.2.6 21 | pycodestyle = 2.5.0 22 | pyflakes = 2.1.1 23 | pyparsing = 2.4.6 24 | python-coveralls = 2.9.3 25 | pytz = 2019.3 26 | PyYAML = 5.3 27 | requests = 2.23.0 28 | six = 1.14.0 29 | sqlparse = 0.3.1 30 | urllib3 = 1.25.8 31 | zc.recipe.egg = 2.0.7 32 | -------------------------------------------------------------------------------- /tagging/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Form components for tagging. 3 | """ 4 | from django import forms 5 | from django.utils.translation import gettext as _ 6 | 7 | from tagging import settings 8 | from tagging.models import Tag 9 | from tagging.utils import parse_tag_input 10 | 11 | 12 | class TagAdminForm(forms.ModelForm): 13 | class Meta: 14 | model = Tag 15 | fields = ('name',) 16 | 17 | def clean_name(self): 18 | value = self.cleaned_data['name'] 19 | tag_names = parse_tag_input(value) 20 | if len(tag_names) > 1: 21 | raise forms.ValidationError(_('Multiple tags were given.')) 22 | return value 23 | 24 | 25 | class TagField(forms.CharField): 26 | """ 27 | A ``CharField`` which validates that its input is a valid list of 28 | tag names. 29 | """ 30 | def clean(self, value): 31 | value = super(TagField, self).clean(value) 32 | for tag_name in parse_tag_input(value): 33 | if len(tag_name) > settings.MAX_TAG_LENGTH: 34 | raise forms.ValidationError( 35 | _('Each tag may be no more than %s characters long.') % 36 | settings.MAX_TAG_LENGTH) 37 | return value 38 | -------------------------------------------------------------------------------- /tagging/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from tagging.fields import TagField 4 | 5 | 6 | class Perch(models.Model): 7 | size = models.IntegerField() 8 | smelly = models.BooleanField(default=True) 9 | 10 | 11 | class Parrot(models.Model): 12 | state = models.CharField(max_length=50) 13 | perch = models.ForeignKey(Perch, null=True, 14 | on_delete=models.CASCADE) 15 | 16 | def __str__(self): 17 | return self.state 18 | 19 | class Meta: 20 | ordering = ['state'] 21 | 22 | 23 | class Link(models.Model): 24 | name = models.CharField(max_length=50) 25 | 26 | def __str__(self): 27 | return self.name 28 | 29 | class Meta: 30 | ordering = ['name'] 31 | 32 | 33 | class Article(models.Model): 34 | name = models.CharField(max_length=50) 35 | 36 | def __str__(self): 37 | return self.name 38 | 39 | class Meta: 40 | ordering = ['name'] 41 | 42 | 43 | class FormTest(models.Model): 44 | tags = TagField('Test', help_text='Test') 45 | 46 | 47 | class FormTestNull(models.Model): 48 | tags = TagField(null=True) 49 | 50 | 51 | class FormMultipleFieldTest(models.Model): 52 | tagging_field = TagField('Test', help_text='Test') 53 | name = models.CharField(max_length=50) 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Based entirely on Django's own ``setup.py``. 3 | """ 4 | from setuptools import find_packages 5 | from setuptools import setup 6 | 7 | import tagging 8 | 9 | setup( 10 | name='django-tagging', 11 | version=tagging.__version__, 12 | 13 | description='Generic tagging application for Django', 14 | long_description='\n'.join([open('README.rst').read(), 15 | open('CHANGELOG.txt').read()]), 16 | keywords='django, tag, tagging', 17 | 18 | author=tagging.__author__, 19 | author_email=tagging.__author_email__, 20 | maintainer=tagging.__maintainer__, 21 | maintainer_email=tagging.__maintainer_email__, 22 | url=tagging.__url__, 23 | license=tagging.__license__, 24 | 25 | packages=find_packages(), 26 | include_package_data=True, 27 | zip_safe=False, 28 | 29 | classifiers=[ 30 | 'Framework :: Django', 31 | 'Environment :: Web Environment', 32 | 'Operating System :: OS Independent', 33 | 'Development Status :: 5 - Production/Stable', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: BSD License', 36 | 'Programming Language :: Python', 37 | 'Programming Language :: Python :: 3', 38 | 'Topic :: Utilities', 39 | 'Topic :: Software Development :: Libraries :: Python Modules'] 40 | ) 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.7 4 | - 3.8 5 | - 3.9 6 | services: 7 | - postgresql 8 | - mysql 9 | env: 10 | - DJANGO=1.11 DATABASE_ENGINE=sqlite 11 | - DJANGO=1.11 DATABASE_ENGINE=postgres 12 | - DJANGO=1.11 DATABASE_ENGINE=mysql 13 | - DJANGO=2.2 DATABASE_ENGINE=sqlite 14 | - DJANGO=2.2 DATABASE_ENGINE=postgres 15 | - DJANGO=2.2 DATABASE_ENGINE=mysql 16 | - DJANGO=3.0 DATABASE_ENGINE=sqlite 17 | - DJANGO=3.0 DATABASE_ENGINE=postgres 18 | - DJANGO=3.0 DATABASE_ENGINE=mysql 19 | - DJANGO=4.0 DATABASE_ENGINE=sqlite 20 | - DJANGO=4.0 DATABASE_ENGINE=postgres 21 | - DJANGO=4.0 DATABASE_ENGINE=mysql 22 | - DJANGO=5.0 DATABASE_ENGINE=sqlite 23 | - DJANGO=5.0 DATABASE_ENGINE=postgres 24 | - DJANGO=5.0 DATABASE_ENGINE=mysql 25 | 26 | install: 27 | - pip install -U setuptools zc.buildout 28 | - buildout versions:django=$DJANGO 29 | - sh -c "if [ '$DATABASE_ENGINE' = 'postgres' ]; 30 | then 31 | pip install psycopg2; 32 | psql -c 'create database tagging;' -U postgres; 33 | fi" 34 | - sh -c "if [ '$DATABASE_ENGINE' = 'mysql' ]; 35 | then 36 | pip install mysqlclient; 37 | mysql -e 'create database tagging CHARACTER SET utf8 COLLATE utf8_general_ci;'; 38 | mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql; 39 | fi" 40 | before_script: 41 | - ./bin/flake8 tagging 42 | script: 43 | - ./bin/test-and-cover 44 | after_success: 45 | - ./bin/coveralls 46 | -------------------------------------------------------------------------------- /tagging/registry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Registery for tagging. 3 | """ 4 | from tagging.managers import ModelTaggedItemManager 5 | from tagging.managers import TagDescriptor 6 | 7 | registry = [] 8 | 9 | 10 | class AlreadyRegistered(Exception): 11 | """ 12 | An attempt was made to register a model more than once. 13 | """ 14 | pass 15 | 16 | 17 | def register(model, tag_descriptor_attr='tags', 18 | tagged_item_manager_attr='tagged'): 19 | """ 20 | Sets the given model class up for working with tags. 21 | """ 22 | if model in registry: 23 | raise AlreadyRegistered( 24 | "The model '%s' has already been registered." % 25 | model._meta.object_name) 26 | if hasattr(model, tag_descriptor_attr): 27 | raise AttributeError( 28 | "'%s' already has an attribute '%s'. You must " 29 | "provide a custom tag_descriptor_attr to register." % ( 30 | model._meta.object_name, 31 | tag_descriptor_attr, 32 | ) 33 | ) 34 | if hasattr(model, tagged_item_manager_attr): 35 | raise AttributeError( 36 | "'%s' already has an attribute '%s'. You must " 37 | "provide a custom tagged_item_manager_attr to register." % ( 38 | model._meta.object_name, 39 | tagged_item_manager_attr, 40 | ) 41 | ) 42 | 43 | # Add tag descriptor 44 | setattr(model, tag_descriptor_attr, TagDescriptor()) 45 | 46 | # Add custom manager 47 | ModelTaggedItemManager().contribute_to_class( 48 | model, tagged_item_manager_attr) 49 | 50 | # Finally register in registry 51 | registry.append(model) 52 | -------------------------------------------------------------------------------- /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/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.db import models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('contenttypes', '0001_initial'), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='Tag', 14 | fields=[ 15 | ('id', models.AutoField( 16 | verbose_name='ID', serialize=False, 17 | auto_created=True, primary_key=True)), 18 | ('name', models.CharField( 19 | unique=True, max_length=50, 20 | verbose_name='name', db_index=True)), 21 | ], 22 | options={ 23 | 'ordering': ('name',), 24 | 'verbose_name': 'tag', 25 | 'verbose_name_plural': 'tags', 26 | }, 27 | ), 28 | migrations.CreateModel( 29 | name='TaggedItem', 30 | fields=[ 31 | ('id', models.AutoField( 32 | verbose_name='ID', serialize=False, 33 | auto_created=True, primary_key=True)), 34 | ('object_id', models.PositiveIntegerField( 35 | verbose_name='object id', db_index=True)), 36 | ('content_type', models.ForeignKey( 37 | verbose_name='content type', 38 | on_delete=models.SET_NULL, 39 | to='contenttypes.ContentType')), 40 | ('tag', models.ForeignKey( 41 | related_name='items', verbose_name='tag', 42 | on_delete=models.SET_NULL, 43 | to='tagging.Tag')), 44 | ], 45 | options={ 46 | 'verbose_name': 'tagged item', 47 | 'verbose_name_plural': 'tagged items', 48 | }, 49 | ), 50 | migrations.AlterUniqueTogether( 51 | name='taggeditem', 52 | unique_together=set([('tag', 'content_type', 'object_id')]), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /tagging/generic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generic components for tagging. 3 | """ 4 | from django.contrib.contenttypes.models import ContentType 5 | 6 | 7 | def fetch_content_objects(tagged_items, select_related_for=None): 8 | """ 9 | Retrieves ``ContentType`` and content objects for the given list of 10 | ``TaggedItems``, grouping the retrieval of content objects by model 11 | type to reduce the number of queries executed. 12 | 13 | This results in ``number_of_content_types + 1`` queries rather than 14 | the ``number_of_tagged_items * 2`` queries you'd get by iterating 15 | over the list and accessing each item's ``object`` attribute. 16 | 17 | A ``select_related_for`` argument can be used to specify a list of 18 | of model names (corresponding to the ``model`` field of a 19 | ``ContentType``) for which ``select_related`` should be used when 20 | retrieving model instances. 21 | """ 22 | if select_related_for is None: 23 | select_related_for = [] 24 | 25 | # Group content object pks by their content type pks 26 | objects = {} 27 | for item in tagged_items: 28 | objects.setdefault(item.content_type_id, []).append(item.object_id) 29 | 30 | # Retrieve content types and content objects in bulk 31 | content_types = ContentType._default_manager.in_bulk(objects.keys()) 32 | for content_type_pk, object_pks in objects.iteritems(): 33 | model = content_types[content_type_pk].model_class() 34 | if content_types[content_type_pk].model in select_related_for: 35 | objects[content_type_pk] = model._default_manager.select_related( 36 | ).in_bulk(object_pks) 37 | else: 38 | objects[content_type_pk] = model._default_manager.in_bulk( 39 | object_pks) 40 | 41 | # Set content types and content objects in the appropriate cache 42 | # attributes, so accessing the 'content_type' and 'object' 43 | # attributes on each tagged item won't result in further database 44 | # hits. 45 | for item in tagged_items: 46 | item._object_cache = objects[item.content_type_id][item.object_id] 47 | item._content_type_cache = content_types[item.content_type_id] 48 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" # Run on all branches 7 | pull_request: 8 | branches: 9 | - "**" # Run on all branches 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | services: 16 | postgres: 17 | image: postgres:latest 18 | ports: 19 | - 5432:5432 20 | env: 21 | POSTGRES_USER: postgres 22 | POSTGRES_DB: postgres 23 | POSTGRES_PASSWORD: postgres 24 | options: >- 25 | --health-cmd="pg_isready -U postgres" 26 | --health-interval=10s 27 | --health-timeout=5s 28 | --health-retries=5 29 | mysql: 30 | image: mysql:latest 31 | ports: 32 | - 3306:3306 33 | env: 34 | MYSQL_ROOT_PASSWORD: root 35 | options: >- 36 | --health-cmd="mysqladmin ping" 37 | --health-interval=10s 38 | --health-timeout=5s 39 | --health-retries=5 40 | 41 | strategy: 42 | matrix: 43 | python-version: [3.7, 3.8, 3.9] 44 | django: ['1.11', '2.2', '3.0', '4.0', '5.0'] 45 | database-engine: ['sqlite', 'postgres', 'mysql'] 46 | 47 | steps: 48 | - name: Set up Python ${{ matrix.python-version }} 49 | uses: actions/setup-python@v2 50 | with: 51 | python-version: ${{ matrix.python-version }} 52 | 53 | - name: Install dependencies 54 | run: | 55 | pip install -U setuptools zc.buildout 56 | buildout versions:django=${{ matrix.django }} 57 | if [ "${{ matrix.database-engine }}" = "postgres" ]; then 58 | pip install psycopg2; 59 | psql -c 'create database tagging;' -U postgres; 60 | elif [ "${{ matrix.database-engine }}" = "mysql" ]; then 61 | pip install mysqlclient; 62 | mysql -e 'create database tagging CHARACTER SET utf8 COLLATE utf8_general_ci;'; 63 | mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql; 64 | fi 65 | 66 | - name: Run flake8 67 | run: ./bin/flake8 tagging 68 | 69 | - name: Run tests 70 | run: ./bin/test-and-cover 71 | 72 | - name: Post coverage to Coveralls 73 | if: success() 74 | run: ./bin/coveralls 75 | -------------------------------------------------------------------------------- /tagging/managers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom managers for tagging. 3 | """ 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | 7 | from tagging.models import Tag 8 | from tagging.models import TaggedItem 9 | 10 | 11 | class ModelTagManager(models.Manager): 12 | """ 13 | A manager for retrieving tags for a particular model. 14 | """ 15 | def get_queryset(self): 16 | ctype = ContentType.objects.get_for_model(self.model) 17 | return Tag.objects.filter( 18 | items__content_type__pk=ctype.pk).distinct() 19 | 20 | def cloud(self, *args, **kwargs): 21 | return Tag.objects.cloud_for_model(self.model, *args, **kwargs) 22 | 23 | def related(self, tags, *args, **kwargs): 24 | return Tag.objects.related_for_model(tags, self.model, *args, **kwargs) 25 | 26 | def usage(self, *args, **kwargs): 27 | return Tag.objects.usage_for_model(self.model, *args, **kwargs) 28 | 29 | 30 | class ModelTaggedItemManager(models.Manager): 31 | """ 32 | A manager for retrieving model instances based on their tags. 33 | """ 34 | def related_to(self, obj, queryset=None, num=None): 35 | if queryset is None: 36 | return TaggedItem.objects.get_related(obj, self.model, num=num) 37 | else: 38 | return TaggedItem.objects.get_related(obj, queryset, num=num) 39 | 40 | def with_all(self, tags, queryset=None): 41 | if queryset is None: 42 | return TaggedItem.objects.get_by_model(self.model, tags) 43 | else: 44 | return TaggedItem.objects.get_by_model(queryset, tags) 45 | 46 | def with_any(self, tags, queryset=None): 47 | if queryset is None: 48 | return TaggedItem.objects.get_union_by_model(self.model, tags) 49 | else: 50 | return TaggedItem.objects.get_union_by_model(queryset, tags) 51 | 52 | 53 | class TagDescriptor(object): 54 | """ 55 | A descriptor which provides access to a ``ModelTagManager`` for 56 | model classes and simple retrieval, updating and deletion of tags 57 | for model instances. 58 | """ 59 | def __get__(self, instance, owner): 60 | if not instance: 61 | tag_manager = ModelTagManager() 62 | tag_manager.model = owner 63 | return tag_manager 64 | else: 65 | return Tag.objects.get_for_object(instance) 66 | 67 | def __set__(self, instance, value): 68 | Tag.objects.update_tags(instance, value) 69 | 70 | def __delete__(self, instance): 71 | Tag.objects.update_tags(instance, None) 72 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Django Tagging 2 | -------------- 3 | 4 | Copyright (c) 2007-2015, Jonathan Buchanan, Julien Fache 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. 56 | -------------------------------------------------------------------------------- /tagging/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tagging related views. 3 | """ 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.http import Http404 6 | from django.utils.translation import gettext as _ 7 | from django.views.generic.list import ListView 8 | 9 | from tagging.models import Tag 10 | from tagging.models import TaggedItem 11 | from tagging.utils import get_queryset_and_model 12 | from tagging.utils import get_tag 13 | 14 | 15 | class TaggedObjectList(ListView): 16 | """ 17 | A thin wrapper around 18 | ``django.views.generic.list.ListView`` which creates a 19 | ``QuerySet`` containing instances of the given queryset or model 20 | tagged with the given tag. 21 | 22 | In addition to the context variables set up by ``object_list``, a 23 | ``tag`` context variable will contain the ``Tag`` instance for the 24 | tag. 25 | 26 | If ``related_tags`` is ``True``, a ``related_tags`` context variable 27 | will contain tags related to the given tag for the given model. 28 | Additionally, if ``related_tag_counts`` is ``True``, each related 29 | tag will have a ``count`` attribute indicating the number of items 30 | which have it in addition to the given tag. 31 | """ 32 | tag = None 33 | related_tags = False 34 | related_tag_counts = True 35 | 36 | def get_tag(self): 37 | if self.tag is None: 38 | try: 39 | self.tag = self.kwargs.pop('tag') 40 | except KeyError: 41 | raise AttributeError( 42 | _('TaggedObjectList must be called with a tag.')) 43 | 44 | tag_instance = get_tag(self.tag) 45 | if tag_instance is None: 46 | raise Http404(_('No Tag found matching "%s".') % self.tag) 47 | 48 | return tag_instance 49 | 50 | def get_queryset_or_model(self): 51 | if self.queryset is not None: 52 | return self.queryset 53 | elif self.model is not None: 54 | return self.model 55 | else: 56 | raise ImproperlyConfigured( 57 | "%(cls)s is missing a QuerySet. Define " 58 | "%(cls)s.model, %(cls)s.queryset, or override " 59 | "%(cls)s.get_queryset_or_model()." % { 60 | 'cls': self.__class__.__name__ 61 | } 62 | ) 63 | 64 | def get_queryset(self): 65 | self.queryset_or_model = self.get_queryset_or_model() 66 | self.tag_instance = self.get_tag() 67 | return TaggedItem.objects.get_by_model( 68 | self.queryset_or_model, self.tag_instance) 69 | 70 | def get_context_data(self, **kwargs): 71 | context = super(TaggedObjectList, self).get_context_data(**kwargs) 72 | context['tag'] = self.tag_instance 73 | 74 | if self.related_tags: 75 | queryset, model = get_queryset_and_model(self.queryset_or_model) 76 | context['related_tags'] = Tag.objects.related_for_model( 77 | self.tag_instance, model, counts=self.related_tag_counts) 78 | return context 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /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 gettext_lazy as _ 7 | 8 | from tagging import settings 9 | from tagging.forms import TagField as TagFormField 10 | from tagging.models import Tag 11 | from tagging.utils import edit_string_for_tags 12 | 13 | 14 | class TagField(CharField): 15 | """ 16 | A "special" character field that actually works as a relationship to tags 17 | "under the hood". This exposes a space-separated string of tags, but does 18 | the splitting/reordering/etc. under the hood. 19 | """ 20 | def __init__(self, *args, **kwargs): 21 | kwargs['max_length'] = kwargs.get('max_length', 255) 22 | kwargs['blank'] = kwargs.get('blank', True) 23 | super(TagField, self).__init__(*args, **kwargs) 24 | 25 | def contribute_to_class(self, cls, name): 26 | super(TagField, self).contribute_to_class(cls, name) 27 | 28 | # Make this object the descriptor for field access. 29 | setattr(cls, self.name, self) 30 | 31 | # Save tags back to the database post-save 32 | signals.post_save.connect(self._save, cls, True) 33 | 34 | def __get__(self, instance, owner=None): 35 | """ 36 | Tag getter. Returns an instance's tags if accessed on an instance, and 37 | all of a model's tags if called on a class. That is, this model:: 38 | 39 | class Link(models.Model): 40 | ... 41 | tags = TagField() 42 | 43 | Lets you do both of these:: 44 | 45 | >>> l = Link.objects.get(...) 46 | >>> l.tags 47 | 'tag1 tag2 tag3' 48 | 49 | >>> Link.tags 50 | 'tag1 tag2 tag3 tag4' 51 | 52 | """ 53 | # Handle access on the model (i.e. Link.tags) 54 | if instance is None: 55 | return edit_string_for_tags(Tag.objects.usage_for_model(owner)) 56 | 57 | tags = self._get_instance_tag_cache(instance) 58 | if tags is None: 59 | if instance.pk is None: 60 | self._set_instance_tag_cache(instance, '') 61 | else: 62 | self._set_instance_tag_cache( 63 | instance, edit_string_for_tags( 64 | Tag.objects.get_for_object(instance))) 65 | return self._get_instance_tag_cache(instance) 66 | 67 | def __set__(self, instance, value): 68 | """ 69 | Set an object's tags. 70 | """ 71 | if instance is None: 72 | raise AttributeError( 73 | _('%s can only be set on instances.') % self.name) 74 | if settings.FORCE_LOWERCASE_TAGS and value is not None: 75 | value = value.lower() 76 | self._set_instance_tag_cache(instance, value) 77 | 78 | def _save(self, **kwargs): # signal, sender, instance): 79 | """ 80 | Save tags back to the database 81 | """ 82 | tags = self._get_instance_tag_cache(kwargs['instance']) 83 | if tags is not None: 84 | Tag.objects.update_tags(kwargs['instance'], tags) 85 | 86 | def __delete__(self, instance): 87 | """ 88 | Clear all of an object's tags. 89 | """ 90 | self._set_instance_tag_cache(instance, '') 91 | 92 | def _get_instance_tag_cache(self, instance): 93 | """ 94 | Helper: get an instance's tag cache. 95 | """ 96 | return getattr(instance, '_%s_cache' % self.attname, None) 97 | 98 | def _set_instance_tag_cache(self, instance, tags): 99 | """ 100 | Helper: set an instance's tag cache. 101 | """ 102 | # The next instruction does nothing particular, 103 | # but needed to by-pass the deferred fields system 104 | # when saving an instance, which check the keys present 105 | # in instance.__dict__. 106 | # The issue is introducted in Django 1.10 107 | instance.__dict__[self.attname] = tags 108 | setattr(instance, '_%s_cache' % self.attname, tags) 109 | 110 | def get_internal_type(self): 111 | return 'CharField' 112 | 113 | def formfield(self, **kwargs): 114 | defaults = {'form_class': TagFormField} 115 | defaults.update(kwargs) 116 | return super(TagField, self).formfield(**defaults) 117 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | ======================== 2 | Django Tagging Changelog 3 | ======================== 4 | 5 | Version 0.5.1, 6th April 2024: 6 | ------------------------------ 7 | 8 | * Add Django 4.2 support 9 | 10 | Version 0.5.0, 6th March 2020: 11 | ------------------------------ 12 | 13 | * Drop support for Python 2. 14 | * Compatiblity fix for Django 2.2 and Django 3.0. 15 | 16 | Version 0.4.6, 14th October 2017: 17 | --------------------------------- 18 | 19 | * Fix IntegrityError while saving inconsistent tags 20 | * Update tag name length to use MAX_TAG_LENGTH setting 21 | 22 | Version 0.4.5, 6th September 2016: 23 | ---------------------------------- 24 | 25 | * Fix on the previous compatiblity fix. 26 | 27 | Version 0.4.4, 5th September 2016: 28 | ---------------------------------- 29 | 30 | * Compatiblity fix for Django 1.10 31 | 32 | Version 0.4.3, 3rd May 2016: 33 | ---------------------------- 34 | 35 | * Add missing migration for ``on_delete`` 36 | 37 | Version 0.4.2, 2nd May 2016: 38 | ---------------------------- 39 | 40 | * Fix tag weight 41 | * Reduce warnings for recent versions of Django 42 | 43 | Version 0.4.1, 15th January 2016: 44 | --------------------------------- 45 | 46 | * Typo fixes 47 | * Support apps 48 | 49 | Version 0.4, 15th June 2015: 50 | ---------------------------- 51 | 52 | * Modernization of the package 53 | 54 | Version 0.3.6, 13th May 2015: 55 | ----------------------------- 56 | 57 | * Corrected initial migration 58 | 59 | Version 0.3.5, 13th May 2015: 60 | ----------------------------- 61 | 62 | * Added support for Django 1.8 63 | * Using migrations to fix syncdb 64 | * Rename get_query_set to get_queryset 65 | * Import GenericForeignKey from the new location 66 | 67 | Version 0.3.4, 7th November 2014: 68 | --------------------------------- 69 | 70 | * Fix unicode errors in admin 71 | 72 | Version 0.3.3, 15th October 2014: 73 | --------------------------------- 74 | 75 | * Added support for Django 1.7 76 | 77 | Version 0.3.2, 18th February 2014: 78 | ---------------------------------- 79 | 80 | * Added support for Django 1.4 and 1.5 81 | * Added support for Python 2.6 to 3.3 82 | * Added tox to test and coverage 83 | 84 | Version 0.3.1, 22nd January 2010: 85 | --------------------------------- 86 | 87 | * Fixed Django 1.2 support (did not add anything new) 88 | * Fixed #95 - tagging.register won't stomp on model attributes 89 | 90 | Version 0.3.0, 22nd August 2009: 91 | -------------------------------- 92 | 93 | * Fixes for Django 1.0 compatibility. 94 | 95 | * Added a ``tagging.generic`` module for working with list of objects 96 | which have generic relations, containing a ``fetch_content_objects`` 97 | function for retrieving content objects for a list of ``TaggedItem``s 98 | using ``number_of_content_types + 1`` queries rather than the 99 | ``number_of_tagged_items * 2`` queries you'd get by iterating over the 100 | list and accessing each item's ``object`` attribute. 101 | 102 | * Added a ``usage`` method to ``ModelTagManager``. 103 | 104 | * ``TaggedItemManager``'s methods now accept a ``QuerySet`` or a 105 | ``Model`` class. If a ``QuerySet`` is given, it will be used as the 106 | basis for the ``QuerySet``s the methods return, so can be used to 107 | restrict results to a subset of a model's instances. The 108 | `tagged_object_list`` generic view and ModelTaggedItemManager`` 109 | manager have been updated accordingly. 110 | 111 | * Removed ``tagging\tests\runtests.py``, as tests can be run with 112 | ``django-admin.py test --settings=tagging.tests.settings``. 113 | 114 | * A ``tagging.TagDescriptor`` is now added to models when registered. 115 | This returns a ``tagging.managers.ModelTagManager`` when accessed on a 116 | model class, and provide access to and control over tags when used on 117 | an instance. 118 | 119 | * Added ``tagging.register`` to register models with the tagging app. 120 | Initially, a ``tagging.managers.ModelTaggedItemManager`` is added for 121 | convenient access to tagged items. 122 | 123 | * Moved ``TagManager`` and ``TaggedItemManager`` to ``models.py`` - gets 124 | rid of some import related silliness, as ``TagManager`` needs access 125 | to ``TaggedItem``. 126 | 127 | Version 0.2.1, 16th Jan 2008: 128 | ----------------------------- 129 | 130 | * Fixed a bug with space-delimited tag input handling - duplicates 131 | weren't being removed and the list of tag names wasn't sorted. 132 | 133 | Version 0.2, 12th Jan 2008: 134 | --------------------------- 135 | 136 | Packaged from revision 122 in Subversion; download at 137 | http://django-tagging.googlecode.com/files/tagging-0.2.zip 138 | 139 | * Added a ``tag_cloud_for_model`` template tag. 140 | 141 | * Added a ``MAX_TAG_LENGTH`` setting. 142 | 143 | * Multi-word tags are here - simple space-delimited input still works. 144 | Double quotes and/or commas are used to delineate multi- word tags. 145 | As far as valid tag contents - anything goes, at least initially. 146 | 147 | * BACKWARDS-INCOMPATIBLE CHANGE - ``django.utils.get_tag_name_list`` and 148 | related regular expressions have been removed in favour of a new tag 149 | input parsing function, ``django.utils.parse_tag_input``. 150 | 151 | * BACKWARDS-INCOMPATIBLE CHANGE - ``Tag`` and ``TaggedItem`` no longer 152 | declare an explicit ``db_table``. If you can't rename your tables, 153 | you'll have to put these back in manually. 154 | 155 | * Fixed a bug in calculation of logarithmic tag clouds - ``font_size`` 156 | attributes were not being set in some cases when the least used tag in 157 | the cloud had been used more than once. 158 | 159 | * For consistency of return type, ``TaggedItemManager.get_by_model`` now 160 | returns an empty ``QuerySet`` instead of an empty ``list`` if 161 | non-existent tags were given. 162 | 163 | * Fixed a bug caused by ``cloud_for_model`` not passing its 164 | ``distribution`` argument to ``calculate_cloud``. 165 | 166 | * Added ``TaggedItemManager.get_union_by_model`` for looking up items 167 | tagged with any one of a list of tags. 168 | 169 | * Added ``TagManager.add_tag`` for adding a single extra tag to an 170 | object. 171 | 172 | * Tag names can now be forced to lowercase before they are saved to the 173 | database by adding the appropriate ``FORCE_LOWERCASE_TAGS`` setting to 174 | your project's settings module. This feature defaults to being off. 175 | 176 | * Fixed a bug where passing non-existent tag names to 177 | ``TaggedItemManager.get_by_model`` caused database errors with some 178 | backends. 179 | 180 | * Added ``tagged_object_list`` generic view for displaying paginated 181 | lists of objects for a given model which have a given tag, and 182 | optionally related tags for that model. 183 | 184 | 185 | Version 0.1, 30th May 2007: 186 | --------------------------- 187 | 188 | Packaged from revision 79 in Subversion; download at 189 | http://django-tagging.googlecode.com/files/tagging-0.1.zip 190 | 191 | * First packaged version using distutils. 192 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-tagging.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-tagging.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-tagging" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-tagging" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /tagging/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tagging utilities - from user tag input parsing to tag cloud 3 | calculation. 4 | """ 5 | import math 6 | 7 | from django.db.models.query import QuerySet 8 | from django.utils.encoding import force_str 9 | from django.utils.translation import gettext as _ 10 | 11 | # Font size distribution algorithms 12 | LOGARITHMIC, LINEAR = 1, 2 13 | 14 | 15 | def parse_tag_input(input): 16 | """ 17 | Parses tag input, with multiple word input being activated and 18 | delineated by commas and double quotes. Quotes take precedence, so 19 | they may contain commas. 20 | 21 | Returns a sorted list of unique tag names. 22 | """ 23 | if not input: 24 | return [] 25 | 26 | input = force_str(input) 27 | 28 | # Special case - if there are no commas or double quotes in the 29 | # input, we don't *do* a recall... I mean, we know we only need to 30 | # split on spaces. 31 | if ',' not in input and '"' not in input: 32 | words = list(set(split_strip(input, ' '))) 33 | words.sort() 34 | return words 35 | 36 | words = [] 37 | buffer = [] 38 | # Defer splitting of non-quoted sections until we know if there are 39 | # any unquoted commas. 40 | to_be_split = [] 41 | saw_loose_comma = False 42 | open_quote = False 43 | i = iter(input) 44 | try: 45 | while 1: 46 | c = next(i) 47 | if c == '"': 48 | if buffer: 49 | to_be_split.append(''.join(buffer)) 50 | buffer = [] 51 | # Find the matching quote 52 | open_quote = True 53 | c = next(i) 54 | while c != '"': 55 | buffer.append(c) 56 | c = next(i) 57 | if buffer: 58 | word = ''.join(buffer).strip() 59 | if word: 60 | words.append(word) 61 | buffer = [] 62 | open_quote = False 63 | else: 64 | if not saw_loose_comma and c == ',': 65 | saw_loose_comma = True 66 | buffer.append(c) 67 | except StopIteration: 68 | # If we were parsing an open quote which was never closed treat 69 | # the buffer as unquoted. 70 | if buffer: 71 | if open_quote and ',' in buffer: 72 | saw_loose_comma = True 73 | to_be_split.append(''.join(buffer)) 74 | if to_be_split: 75 | if saw_loose_comma: 76 | delimiter = ',' 77 | else: 78 | delimiter = ' ' 79 | for chunk in to_be_split: 80 | words.extend(split_strip(chunk, delimiter)) 81 | words = list(set(words)) 82 | words.sort() 83 | return words 84 | 85 | 86 | def split_strip(input, delimiter=','): 87 | """ 88 | Splits ``input`` on ``delimiter``, stripping each resulting string 89 | and returning a list of non-empty strings. 90 | """ 91 | words = [w.strip() for w in input.split(delimiter)] 92 | return [w for w in words if w] 93 | 94 | 95 | def edit_string_for_tags(tags): 96 | """ 97 | Given list of ``Tag`` instances, creates a string representation of 98 | the list suitable for editing by the user, such that submitting the 99 | given string representation back without changing it will give the 100 | same list of tags. 101 | 102 | Tag names which contain commas will be double quoted. 103 | 104 | If any tag name which isn't being quoted contains whitespace, the 105 | resulting string of tag names will be comma-delimited, otherwise 106 | it will be space-delimited. 107 | """ 108 | names = [] 109 | use_commas = False 110 | for tag in tags: 111 | name = tag.name 112 | if ',' in name: 113 | names.append('"%s"' % name) 114 | continue 115 | elif ' ' in name: 116 | if not use_commas: 117 | use_commas = True 118 | names.append(name) 119 | if use_commas: 120 | glue = ', ' 121 | else: 122 | glue = ' ' 123 | result = glue.join(names) 124 | 125 | # If we only had one name, and it had spaces, 126 | # we need to enclose it in quotes. 127 | # Otherwise, it's interpreted as two tags. 128 | if len(names) == 1 and use_commas: 129 | result = '"' + result + '"' 130 | 131 | return result 132 | 133 | 134 | def get_queryset_and_model(queryset_or_model): 135 | """ 136 | Given a ``QuerySet`` or a ``Model``, returns a two-tuple of 137 | (queryset, model). 138 | 139 | If a ``Model`` is given, the ``QuerySet`` returned will be created 140 | using its default manager. 141 | """ 142 | try: 143 | return queryset_or_model, queryset_or_model.model 144 | except AttributeError: 145 | return queryset_or_model._default_manager.all(), queryset_or_model 146 | 147 | 148 | def get_tag_list(tags): 149 | """ 150 | Utility function for accepting tag input in a flexible manner. 151 | 152 | If a ``Tag`` object is given, it will be returned in a list as 153 | its single occupant. 154 | 155 | If given, the tag names in the following will be used to create a 156 | ``Tag`` ``QuerySet``: 157 | 158 | * A string, which may contain multiple tag names. 159 | * A list or tuple of strings corresponding to tag names. 160 | * A list or tuple of integers corresponding to tag ids. 161 | 162 | If given, the following will be returned as-is: 163 | 164 | * A list or tuple of ``Tag`` objects. 165 | * A ``Tag`` ``QuerySet``. 166 | 167 | """ 168 | from tagging.models import Tag 169 | if isinstance(tags, Tag): 170 | return [tags] 171 | elif isinstance(tags, QuerySet) and tags.model is Tag: 172 | return tags 173 | elif isinstance(tags, str): 174 | return Tag.objects.filter(name__in=parse_tag_input(tags)) 175 | elif isinstance(tags, (list, tuple)): 176 | if len(tags) == 0: 177 | return tags 178 | contents = set() 179 | for item in tags: 180 | if isinstance(item, str): 181 | contents.add('string') 182 | elif isinstance(item, Tag): 183 | contents.add('tag') 184 | elif isinstance(item, int): 185 | contents.add('int') 186 | if len(contents) == 1: 187 | if 'string' in contents: 188 | return Tag.objects.filter(name__in=[force_str(tag) 189 | for tag in tags]) 190 | elif 'tag' in contents: 191 | return tags 192 | elif 'int' in contents: 193 | return Tag.objects.filter(id__in=tags) 194 | else: 195 | raise ValueError( 196 | _('If a list or tuple of tags is provided, ' 197 | 'they must all be tag names, Tag objects or Tag ids.')) 198 | else: 199 | raise ValueError(_('The tag input given was invalid.')) 200 | 201 | 202 | def get_tag(tag): 203 | """ 204 | Utility function for accepting single tag input in a flexible 205 | manner. 206 | 207 | If a ``Tag`` object is given it will be returned as-is; if a 208 | string or integer are given, they will be used to lookup the 209 | appropriate ``Tag``. 210 | 211 | If no matching tag can be found, ``None`` will be returned. 212 | """ 213 | from tagging.models import Tag 214 | if isinstance(tag, Tag): 215 | return tag 216 | 217 | try: 218 | if isinstance(tag, str): 219 | return Tag.objects.get(name=tag) 220 | elif isinstance(tag, int): 221 | return Tag.objects.get(id=tag) 222 | except Tag.DoesNotExist: 223 | pass 224 | 225 | return None 226 | 227 | 228 | def _calculate_thresholds(min_weight, max_weight, steps): 229 | delta = (max_weight - min_weight) / float(steps) 230 | return [min_weight + i * delta for i in range(1, steps + 1)] 231 | 232 | 233 | def _calculate_tag_weight(weight, max_weight, distribution): 234 | """ 235 | Logarithmic tag weight calculation is based on code from the 236 | *Tag Cloud* plugin for Mephisto, by Sven Fuchs. 237 | 238 | http://www.artweb-design.de/projects/mephisto-plugin-tag-cloud 239 | """ 240 | if distribution == LINEAR or max_weight == 1: 241 | return weight 242 | elif distribution == LOGARITHMIC: 243 | return min( 244 | math.log(weight) * max_weight / math.log(max_weight), 245 | max_weight) 246 | raise ValueError( 247 | _('Invalid distribution algorithm specified: %s.') % distribution) 248 | 249 | 250 | def calculate_cloud(tags, steps=4, distribution=LOGARITHMIC): 251 | """ 252 | Add a ``font_size`` attribute to each tag according to the 253 | frequency of its use, as indicated by its ``count`` 254 | attribute. 255 | 256 | ``steps`` defines the range of font sizes - ``font_size`` will 257 | be an integer between 1 and ``steps`` (inclusive). 258 | 259 | ``distribution`` defines the type of font size distribution 260 | algorithm which will be used - logarithmic or linear. It must be 261 | one of ``tagging.utils.LOGARITHMIC`` or ``tagging.utils.LINEAR``. 262 | """ 263 | if len(tags) > 0: 264 | counts = [tag.count for tag in tags] 265 | min_weight = float(min(counts)) 266 | max_weight = float(max(counts)) 267 | thresholds = _calculate_thresholds(min_weight, max_weight, steps) 268 | for tag in tags: 269 | font_set = False 270 | tag_weight = _calculate_tag_weight( 271 | tag.count, max_weight, distribution) 272 | for i in range(steps): 273 | if not font_set and tag_weight <= thresholds[i]: 274 | tag.font_size = i + 1 275 | font_set = True 276 | return tags 277 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-tagging documentation build configuration file, created by 4 | # sphinx-quickstart on Thu May 14 19:31:27 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | import os 15 | import re 16 | import sys 17 | 18 | from datetime import date 19 | HERE = os.path.abspath(os.path.dirname(__file__)) 20 | 21 | sys.path.append(HERE) 22 | sys.path.append(os.path.join(HERE, '..')) 23 | 24 | import tagging 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.intersphinx', 37 | ] 38 | 39 | intersphinx_mapping = { 40 | 'django': ('https://django.readthedocs.io/en/latest/', None), 41 | } 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = ['_templates'] 45 | 46 | # The suffix(es) of source filenames. 47 | # You can specify multiple suffix as a list of string: 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = '.rst' 50 | 51 | # The encoding of source files. 52 | #source_encoding = 'utf-8-sig' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # General information about the project. 58 | project = u'Django Tagging' 59 | copyright = '%s, %s' % (date.today().year, tagging.__maintainer__) 60 | author = tagging.__author__ 61 | 62 | # The version info for the project you're documenting, acts as replacement for 63 | # |version| and |release|, also used in various other places throughout the 64 | # built documents. 65 | # 66 | # The full version, including alpha/beta/rc tags. 67 | release = tagging.__version__ 68 | # The short X.Y version. 69 | version = re.match(r'\d+\.\d+(?:\.\d+)?', release).group() 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | # 74 | # This is also used if you do content translation via gettext catalogs. 75 | # Usually you set "language" from the command line for these cases. 76 | language = None 77 | 78 | # There are two options for replacing |today|: either, you set today to some 79 | # non-false value, then it is used: 80 | #today = '' 81 | # Else, today_fmt is used as the format for a strftime call. 82 | #today_fmt = '%B %d, %Y' 83 | 84 | # List of patterns, relative to source directory, that match files and 85 | # directories to ignore when looking for source files. 86 | exclude_patterns = ['_build'] 87 | 88 | # The reST default role (used for this markup: `text`) to use for all 89 | # documents. 90 | #default_role = None 91 | 92 | # If true, '()' will be appended to :func: etc. cross-reference text. 93 | #add_function_parentheses = True 94 | 95 | # If true, the current module name will be prepended to all description 96 | # unit titles (such as .. function::). 97 | #add_module_names = True 98 | 99 | # If true, sectionauthor and moduleauthor directives will be shown in the 100 | # output. They are ignored by default. 101 | #show_authors = False 102 | 103 | # The name of the Pygments (syntax highlighting) style to use. 104 | pygments_style = 'sphinx' 105 | 106 | # A list of ignored prefixes for module index sorting. 107 | #modindex_common_prefix = [] 108 | 109 | # If true, keep warnings as "system message" paragraphs in the built documents. 110 | #keep_warnings = False 111 | 112 | # If true, `todo` and `todoList` produce output, else they produce nothing. 113 | todo_include_todos = False 114 | 115 | 116 | # -- Options for HTML output ---------------------------------------------- 117 | 118 | # The theme to use for HTML and HTML Help pages. See the documentation for 119 | # a list of builtin themes. 120 | html_theme = 'default' 121 | 122 | # Theme options are theme-specific and customize the look and feel of a theme 123 | # further. For a list of options available for each theme, see the 124 | # documentation. 125 | #html_theme_options = {} 126 | 127 | # Add any paths that contain custom themes here, relative to this directory. 128 | #html_theme_path = [] 129 | 130 | # The name for this set of Sphinx documents. If None, it defaults to 131 | # " v documentation". 132 | #html_title = None 133 | 134 | # A shorter title for the navigation bar. Default is the same as html_title. 135 | #html_short_title = None 136 | 137 | # The name of an image file (relative to this directory) to place at the top 138 | # of the sidebar. 139 | #html_logo = None 140 | 141 | # The name of an image file (within the static path) to use as favicon of the 142 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 143 | # pixels large. 144 | #html_favicon = None 145 | 146 | # Add any paths that contain custom static files (such as style sheets) here, 147 | # relative to this directory. They are copied after the builtin static files, 148 | # so a file named "default.css" will overwrite the builtin "default.css". 149 | html_static_path = ['_static'] 150 | 151 | # Add any extra paths that contain custom files (such as robots.txt or 152 | # .htaccess) here, relative to this directory. These files are copied 153 | # directly to the root of the documentation. 154 | #html_extra_path = [] 155 | 156 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 157 | # using the given strftime format. 158 | #html_last_updated_fmt = '%b %d, %Y' 159 | 160 | # If true, SmartyPants will be used to convert quotes and dashes to 161 | # typographically correct entities. 162 | #html_use_smartypants = True 163 | 164 | # Custom sidebar templates, maps document names to template names. 165 | #html_sidebars = {} 166 | 167 | # Additional templates that should be rendered to pages, maps page names to 168 | # template names. 169 | #html_additional_pages = {} 170 | 171 | # If false, no module index is generated. 172 | #html_domain_indices = True 173 | 174 | # If false, no index is generated. 175 | #html_use_index = True 176 | 177 | # If true, the index is split into individual pages for each letter. 178 | #html_split_index = False 179 | 180 | # If true, links to the reST sources are added to the pages. 181 | #html_show_sourcelink = True 182 | 183 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 184 | #html_show_sphinx = True 185 | 186 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 187 | #html_show_copyright = True 188 | 189 | # If true, an OpenSearch description file will be output, and all pages will 190 | # contain a tag referring to it. The value of this option must be the 191 | # base URL from which the finished HTML is served. 192 | #html_use_opensearch = '' 193 | 194 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 195 | #html_file_suffix = None 196 | 197 | # Language to be used for generating the HTML full-text search index. 198 | # Sphinx supports the following languages: 199 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 200 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 201 | #html_search_language = 'en' 202 | 203 | # A dictionary with options for the search language support, empty by default. 204 | # Now only 'ja' uses this config value 205 | #html_search_options = {'type': 'default'} 206 | 207 | # The name of a javascript file (relative to the configuration directory) that 208 | # implements a search results scorer. If empty, the default will be used. 209 | #html_search_scorer = 'scorer.js' 210 | 211 | # Output file base name for HTML help builder. 212 | htmlhelp_basename = 'django-taggingdoc' 213 | 214 | # -- Options for LaTeX output --------------------------------------------- 215 | 216 | latex_elements = { 217 | # The paper size ('letterpaper' or 'a4paper'). 218 | #'papersize': 'letterpaper', 219 | 220 | # The font size ('10pt', '11pt' or '12pt'). 221 | #'pointsize': '10pt', 222 | 223 | # Additional stuff for the LaTeX preamble. 224 | #'preamble': '', 225 | 226 | # Latex figure (float) alignment 227 | #'figure_align': 'htbp', 228 | } 229 | 230 | # Grouping the document tree into LaTeX files. List of tuples 231 | # (source start file, target name, title, 232 | # author, documentclass [howto, manual, or own class]). 233 | latex_documents = [ 234 | (master_doc, 'django-tagging.tex', u'django-tagging Documentation', 235 | u'Fantomas42', 'manual'), 236 | ] 237 | 238 | # The name of an image file (relative to this directory) to place at the top of 239 | # the title page. 240 | #latex_logo = None 241 | 242 | # For "manual" documents, if this is true, then toplevel headings are parts, 243 | # not chapters. 244 | #latex_use_parts = False 245 | 246 | # If true, show page references after internal links. 247 | #latex_show_pagerefs = False 248 | 249 | # If true, show URL addresses after external links. 250 | #latex_show_urls = False 251 | 252 | # Documents to append as an appendix to all manuals. 253 | #latex_appendices = [] 254 | 255 | # If false, no module index is generated. 256 | #latex_domain_indices = True 257 | 258 | 259 | # -- Options for manual page output --------------------------------------- 260 | 261 | # One entry per manual page. List of tuples 262 | # (source start file, name, description, authors, manual section). 263 | man_pages = [ 264 | (master_doc, 'django-tagging', u'django-tagging Documentation', 265 | [author], 1) 266 | ] 267 | 268 | # If true, show URL addresses after external links. 269 | #man_show_urls = False 270 | 271 | 272 | # -- Options for Texinfo output ------------------------------------------- 273 | 274 | # Grouping the document tree into Texinfo files. List of tuples 275 | # (source start file, target name, title, author, 276 | # dir menu entry, description, category) 277 | texinfo_documents = [ 278 | (master_doc, 'django-tagging', u'django-tagging Documentation', 279 | author, 'django-tagging', 'One line description of project.', 280 | 'Miscellaneous'), 281 | ] 282 | 283 | # Documents to append as an appendix to all manuals. 284 | #texinfo_appendices = [] 285 | 286 | # If false, no module index is generated. 287 | #texinfo_domain_indices = True 288 | 289 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 290 | #texinfo_show_urls = 'footnote' 291 | 292 | # If true, do not generate a @detailmenu in the "Top" node's menu. 293 | #texinfo_no_detailmenu = False 294 | -------------------------------------------------------------------------------- /tagging/templatetags/tagging_tags.py: -------------------------------------------------------------------------------- 1 | """ 2 | Templatetags for tagging. 3 | """ 4 | from django.apps.registry import apps 5 | from django.template import Library 6 | from django.template import Node 7 | from django.template import TemplateSyntaxError 8 | from django.template import Variable 9 | from django.utils.translation import gettext as _ 10 | 11 | from tagging.models import Tag 12 | from tagging.models import TaggedItem 13 | from tagging.utils import LINEAR 14 | from tagging.utils import LOGARITHMIC 15 | 16 | 17 | register = Library() 18 | 19 | 20 | class TagsForModelNode(Node): 21 | def __init__(self, model, context_var, counts): 22 | self.model = model 23 | self.context_var = context_var 24 | self.counts = counts 25 | 26 | def render(self, context): 27 | model = apps.get_model(*self.model.split('.')) 28 | if model is None: 29 | raise TemplateSyntaxError( 30 | _('tags_for_model tag was given an invalid model: %s') % 31 | self.model) 32 | context[self.context_var] = Tag.objects.usage_for_model( 33 | model, counts=self.counts) 34 | return '' 35 | 36 | 37 | class TagCloudForModelNode(Node): 38 | def __init__(self, model, context_var, **kwargs): 39 | self.model = model 40 | self.context_var = context_var 41 | self.kwargs = kwargs 42 | 43 | def render(self, context): 44 | model = apps.get_model(*self.model.split('.')) 45 | if model is None: 46 | raise TemplateSyntaxError( 47 | _('tag_cloud_for_model tag was given an invalid model: %s') % 48 | self.model) 49 | context[self.context_var] = Tag.objects.cloud_for_model( 50 | model, **self.kwargs) 51 | return '' 52 | 53 | 54 | class TagsForObjectNode(Node): 55 | def __init__(self, obj, context_var): 56 | self.obj = Variable(obj) 57 | self.context_var = context_var 58 | 59 | def render(self, context): 60 | context[self.context_var] = \ 61 | Tag.objects.get_for_object(self.obj.resolve(context)) 62 | return '' 63 | 64 | 65 | class TaggedObjectsNode(Node): 66 | def __init__(self, tag, model, context_var): 67 | self.tag = Variable(tag) 68 | self.context_var = context_var 69 | self.model = model 70 | 71 | def render(self, context): 72 | model = apps.get_model(*self.model.split('.')) 73 | if model is None: 74 | raise TemplateSyntaxError( 75 | _('tagged_objects tag was given an invalid model: %s') % 76 | self.model) 77 | context[self.context_var] = TaggedItem.objects.get_by_model( 78 | model, self.tag.resolve(context)) 79 | return '' 80 | 81 | 82 | def do_tags_for_model(parser, token): 83 | """ 84 | Retrieves a list of ``Tag`` objects associated with a given model 85 | and stores them in a context variable. 86 | 87 | Usage:: 88 | 89 | {% tags_for_model [model] as [varname] %} 90 | 91 | The model is specified in ``[appname].[modelname]`` format. 92 | 93 | Extended usage:: 94 | 95 | {% tags_for_model [model] as [varname] with counts %} 96 | 97 | If specified - by providing extra ``with counts`` arguments - adds 98 | a ``count`` attribute to each tag containing the number of 99 | instances of the given model which have been tagged with it. 100 | 101 | Examples:: 102 | 103 | {% tags_for_model products.Widget as widget_tags %} 104 | {% tags_for_model products.Widget as widget_tags with counts %} 105 | 106 | """ 107 | bits = token.contents.split() 108 | len_bits = len(bits) 109 | if len_bits not in (4, 6): 110 | raise TemplateSyntaxError( 111 | _('%s tag requires either three or five arguments') % bits[0]) 112 | if bits[2] != 'as': 113 | raise TemplateSyntaxError( 114 | _("second argument to %s tag must be 'as'") % bits[0]) 115 | if len_bits == 6: 116 | if bits[4] != 'with': 117 | raise TemplateSyntaxError( 118 | _("if given, fourth argument to %s tag must be 'with'") % 119 | bits[0]) 120 | if bits[5] != 'counts': 121 | raise TemplateSyntaxError( 122 | _("if given, fifth argument to %s tag must be 'counts'") % 123 | bits[0]) 124 | if len_bits == 4: 125 | return TagsForModelNode(bits[1], bits[3], counts=False) 126 | else: 127 | return TagsForModelNode(bits[1], bits[3], counts=True) 128 | 129 | 130 | def do_tag_cloud_for_model(parser, token): 131 | """ 132 | Retrieves a list of ``Tag`` objects for a given model, with tag 133 | cloud attributes set, and stores them in a context variable. 134 | 135 | Usage:: 136 | 137 | {% tag_cloud_for_model [model] as [varname] %} 138 | 139 | The model is specified in ``[appname].[modelname]`` format. 140 | 141 | Extended usage:: 142 | 143 | {% tag_cloud_for_model [model] as [varname] with [options] %} 144 | 145 | Extra options can be provided after an optional ``with`` argument, 146 | with each option being specified in ``[name]=[value]`` format. Valid 147 | extra options are: 148 | 149 | ``steps`` 150 | Integer. Defines the range of font sizes. 151 | 152 | ``min_count`` 153 | Integer. Defines the minimum number of times a tag must have 154 | been used to appear in the cloud. 155 | 156 | ``distribution`` 157 | One of ``linear`` or ``log``. Defines the font-size 158 | distribution algorithm to use when generating the tag cloud. 159 | 160 | Examples:: 161 | 162 | {% tag_cloud_for_model products.Widget as widget_tags %} 163 | {% tag_cloud_for_model products.Widget as widget_tags 164 | with steps=9 min_count=3 distribution=log %} 165 | 166 | """ 167 | bits = token.contents.split() 168 | len_bits = len(bits) 169 | if len_bits != 4 and len_bits not in range(6, 9): 170 | raise TemplateSyntaxError( 171 | _('%s tag requires either three or between five ' 172 | 'and seven arguments') % bits[0]) 173 | if bits[2] != 'as': 174 | raise TemplateSyntaxError( 175 | _("second argument to %s tag must be 'as'") % bits[0]) 176 | kwargs = {} 177 | if len_bits > 5: 178 | if bits[4] != 'with': 179 | raise TemplateSyntaxError( 180 | _("if given, fourth argument to %s tag must be 'with'") % 181 | bits[0]) 182 | for i in range(5, len_bits): 183 | try: 184 | name, value = bits[i].split('=') 185 | if name == 'steps' or name == 'min_count': 186 | try: 187 | kwargs[str(name)] = int(value) 188 | except ValueError: 189 | raise TemplateSyntaxError( 190 | _("%(tag)s tag's '%(option)s' option was not " 191 | "a valid integer: '%(value)s'") % { 192 | 'tag': bits[0], 193 | 'option': name, 194 | 'value': value, 195 | }) 196 | elif name == 'distribution': 197 | if value in ['linear', 'log']: 198 | kwargs[str(name)] = {'linear': LINEAR, 199 | 'log': LOGARITHMIC}[value] 200 | else: 201 | raise TemplateSyntaxError( 202 | _("%(tag)s tag's '%(option)s' option was not " 203 | "a valid choice: '%(value)s'") % { 204 | 'tag': bits[0], 205 | 'option': name, 206 | 'value': value, 207 | }) 208 | else: 209 | raise TemplateSyntaxError( 210 | _("%(tag)s tag was given an " 211 | "invalid option: '%(option)s'") % { 212 | 'tag': bits[0], 213 | 'option': name, 214 | }) 215 | except ValueError: 216 | raise TemplateSyntaxError( 217 | _("%(tag)s tag was given a badly " 218 | "formatted option: '%(option)s'") % { 219 | 'tag': bits[0], 220 | 'option': bits[i], 221 | }) 222 | return TagCloudForModelNode(bits[1], bits[3], **kwargs) 223 | 224 | 225 | def do_tags_for_object(parser, token): 226 | """ 227 | Retrieves a list of ``Tag`` objects associated with an object and 228 | stores them in a context variable. 229 | 230 | Usage:: 231 | 232 | {% tags_for_object [object] as [varname] %} 233 | 234 | Example:: 235 | 236 | {% tags_for_object foo_object as tag_list %} 237 | """ 238 | bits = token.contents.split() 239 | if len(bits) != 4: 240 | raise TemplateSyntaxError( 241 | _('%s tag requires exactly three arguments') % bits[0]) 242 | if bits[2] != 'as': 243 | raise TemplateSyntaxError( 244 | _("second argument to %s tag must be 'as'") % bits[0]) 245 | return TagsForObjectNode(bits[1], bits[3]) 246 | 247 | 248 | def do_tagged_objects(parser, token): 249 | """ 250 | Retrieves a list of instances of a given model which are tagged with 251 | a given ``Tag`` and stores them in a context variable. 252 | 253 | Usage:: 254 | 255 | {% tagged_objects [tag] in [model] as [varname] %} 256 | 257 | The model is specified in ``[appname].[modelname]`` format. 258 | 259 | The tag must be an instance of a ``Tag``, not the name of a tag. 260 | 261 | Example:: 262 | 263 | {% tagged_objects comedy_tag in tv.Show as comedies %} 264 | 265 | """ 266 | bits = token.contents.split() 267 | if len(bits) != 6: 268 | raise TemplateSyntaxError( 269 | _('%s tag requires exactly five arguments') % bits[0]) 270 | if bits[2] != 'in': 271 | raise TemplateSyntaxError( 272 | _("second argument to %s tag must be 'in'") % bits[0]) 273 | if bits[4] != 'as': 274 | raise TemplateSyntaxError( 275 | _("fourth argument to %s tag must be 'as'") % bits[0]) 276 | return TaggedObjectsNode(bits[1], bits[3], bits[5]) 277 | 278 | 279 | register.tag('tags_for_model', do_tags_for_model) 280 | register.tag('tag_cloud_for_model', do_tag_cloud_for_model) 281 | register.tag('tags_for_object', do_tags_for_object) 282 | register.tag('tagged_objects', do_tagged_objects) 283 | -------------------------------------------------------------------------------- /tagging/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Models and managers for tagging. 3 | """ 4 | from django.contrib.contenttypes.fields import GenericForeignKey 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import connection 7 | from django.db import models 8 | from django.db.models.query_utils import Q 9 | from django.utils.encoding import smart_str 10 | from django.utils.translation import gettext_lazy as _ 11 | 12 | from tagging import settings 13 | from tagging.utils import LOGARITHMIC 14 | from tagging.utils import calculate_cloud 15 | from tagging.utils import get_queryset_and_model 16 | from tagging.utils import get_tag_list 17 | from tagging.utils import parse_tag_input 18 | 19 | 20 | qn = connection.ops.quote_name 21 | 22 | 23 | ############ 24 | # Managers # 25 | ############ 26 | 27 | class TagManager(models.Manager): 28 | 29 | def update_tags(self, obj, tag_names): 30 | """ 31 | Update tags associated with an object. 32 | """ 33 | ctype = ContentType.objects.get_for_model(obj) 34 | current_tags = list(self.filter(items__content_type__pk=ctype.pk, 35 | items__object_id=obj.pk)) 36 | updated_tag_names = parse_tag_input(tag_names) 37 | if settings.FORCE_LOWERCASE_TAGS: 38 | updated_tag_names = [t.lower() for t in updated_tag_names] 39 | 40 | # Remove tags which no longer apply 41 | tags_for_removal = [tag for tag in current_tags 42 | if tag.name not in updated_tag_names] 43 | if len(tags_for_removal): 44 | TaggedItem._default_manager.filter( 45 | content_type__pk=ctype.pk, 46 | object_id=obj.pk, 47 | tag__in=tags_for_removal).delete() 48 | # Add new tags 49 | current_tag_names = [tag.name for tag in current_tags] 50 | for tag_name in updated_tag_names: 51 | if tag_name not in current_tag_names: 52 | tag, created = self.get_or_create(name=tag_name) 53 | TaggedItem._default_manager.get_or_create( 54 | content_type_id=ctype.pk, 55 | object_id=obj.pk, 56 | tag=tag, 57 | ) 58 | 59 | def add_tag(self, obj, tag_name): 60 | """ 61 | Associates the given object with a tag. 62 | """ 63 | tag_names = parse_tag_input(tag_name) 64 | if not len(tag_names): 65 | raise AttributeError( 66 | _('No tags were given: "%s".') % tag_name) 67 | if len(tag_names) > 1: 68 | raise AttributeError( 69 | _('Multiple tags were given: "%s".') % tag_name) 70 | tag_name = tag_names[0] 71 | if settings.FORCE_LOWERCASE_TAGS: 72 | tag_name = tag_name.lower() 73 | tag, created = self.get_or_create(name=tag_name) 74 | ctype = ContentType.objects.get_for_model(obj) 75 | TaggedItem._default_manager.get_or_create( 76 | tag=tag, content_type=ctype, object_id=obj.pk) 77 | 78 | def get_for_object(self, obj): 79 | """ 80 | Create a queryset matching all tags associated with the given 81 | object. 82 | """ 83 | ctype = ContentType.objects.get_for_model(obj) 84 | return self.filter(items__content_type__pk=ctype.pk, 85 | items__object_id=obj.pk) 86 | 87 | def _get_usage(self, model, counts=False, min_count=None, 88 | extra_joins=None, extra_criteria=None, params=None): 89 | """ 90 | Perform the custom SQL query for ``usage_for_model`` and 91 | ``usage_for_queryset``. 92 | """ 93 | if min_count is not None: 94 | counts = True 95 | 96 | model_table = qn(model._meta.db_table) 97 | model_pk = '%s.%s' % (model_table, qn(model._meta.pk.column)) 98 | query = """ 99 | SELECT DISTINCT %(tag)s.id, %(tag)s.name%(count_sql)s 100 | FROM 101 | %(tag)s 102 | INNER JOIN %(tagged_item)s 103 | ON %(tag)s.id = %(tagged_item)s.tag_id 104 | INNER JOIN %(model)s 105 | ON %(tagged_item)s.object_id = %(model_pk)s 106 | %%s 107 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 108 | %%s 109 | GROUP BY %(tag)s.id, %(tag)s.name 110 | %%s 111 | ORDER BY %(tag)s.name ASC""" % { 112 | 'tag': qn(self.model._meta.db_table), 113 | 'count_sql': counts and (', COUNT(%s)' % model_pk) or '', 114 | 'tagged_item': qn(TaggedItem._meta.db_table), 115 | 'model': model_table, 116 | 'model_pk': model_pk, 117 | 'content_type_id': ContentType.objects.get_for_model(model).pk, 118 | } 119 | 120 | min_count_sql = '' 121 | if min_count is not None: 122 | min_count_sql = 'HAVING COUNT(%s) >= %%s' % model_pk 123 | params.append(min_count) 124 | 125 | cursor = connection.cursor() 126 | cursor.execute(query % (extra_joins, extra_criteria, min_count_sql), 127 | params) 128 | tags = [] 129 | for row in cursor.fetchall(): 130 | t = self.model(*row[:2]) 131 | if counts: 132 | t.count = row[2] 133 | tags.append(t) 134 | return tags 135 | 136 | def usage_for_model(self, model, counts=False, min_count=None, 137 | filters=None): 138 | """ 139 | Obtain a list of tags associated with instances of the given 140 | Model class. 141 | 142 | If ``counts`` is True, a ``count`` attribute will be added to 143 | each tag, indicating how many times it has been used against 144 | the Model class in question. 145 | 146 | If ``min_count`` is given, only tags which have a ``count`` 147 | greater than or equal to ``min_count`` will be returned. 148 | Passing a value for ``min_count`` implies ``counts=True``. 149 | 150 | To limit the tags (and counts, if specified) returned to those 151 | used by a subset of the Model's instances, pass a dictionary 152 | of field lookups to be applied to the given Model as the 153 | ``filters`` argument. 154 | """ 155 | if filters is None: 156 | filters = {} 157 | 158 | queryset = model._default_manager.filter() 159 | for k, v in filters.items(): 160 | # Add support for both Django 4 and inferior versions 161 | queryset.query.add_q(Q((k, v))) 162 | usage = self.usage_for_queryset(queryset, counts, min_count) 163 | 164 | return usage 165 | 166 | def usage_for_queryset(self, queryset, counts=False, min_count=None): 167 | """ 168 | Obtain a list of tags associated with instances of a model 169 | contained in the given queryset. 170 | 171 | If ``counts`` is True, a ``count`` attribute will be added to 172 | each tag, indicating how many times it has been used against 173 | the Model class in question. 174 | 175 | If ``min_count`` is given, only tags which have a ``count`` 176 | greater than or equal to ``min_count`` will be returned. 177 | Passing a value for ``min_count`` implies ``counts=True``. 178 | """ 179 | compiler = queryset.query.get_compiler(using=queryset.db) 180 | where, params = compiler.compile(queryset.query.where) 181 | extra_joins = ' '.join(compiler.get_from_clause()[0][1:]) 182 | 183 | if where: 184 | extra_criteria = 'AND %s' % where 185 | else: 186 | extra_criteria = '' 187 | return self._get_usage(queryset.model, counts, min_count, 188 | extra_joins, extra_criteria, params) 189 | 190 | def related_for_model(self, tags, model, counts=False, min_count=None): 191 | """ 192 | Obtain a list of tags related to a given list of tags - that 193 | is, other tags used by items which have all the given tags. 194 | 195 | If ``counts`` is True, a ``count`` attribute will be added to 196 | each tag, indicating the number of items which have it in 197 | addition to the given list of tags. 198 | 199 | If ``min_count`` is given, only tags which have a ``count`` 200 | greater than or equal to ``min_count`` will be returned. 201 | Passing a value for ``min_count`` implies ``counts=True``. 202 | """ 203 | if min_count is not None: 204 | counts = True 205 | 206 | tags = get_tag_list(tags) 207 | tag_count = len(tags) 208 | tagged_item_table = qn(TaggedItem._meta.db_table) 209 | query = """ 210 | SELECT %(tag)s.id, %(tag)s.name%(count_sql)s 211 | FROM %(tagged_item)s INNER JOIN %(tag)s ON 212 | %(tagged_item)s.tag_id = %(tag)s.id 213 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 214 | AND %(tagged_item)s.object_id IN 215 | ( 216 | SELECT %(tagged_item)s.object_id 217 | FROM %(tagged_item)s, %(tag)s 218 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 219 | AND %(tag)s.id = %(tagged_item)s.tag_id 220 | AND %(tag)s.id IN (%(tag_id_placeholders)s) 221 | GROUP BY %(tagged_item)s.object_id 222 | HAVING COUNT(%(tagged_item)s.object_id) = %(tag_count)s 223 | ) 224 | AND %(tag)s.id NOT IN (%(tag_id_placeholders)s) 225 | GROUP BY %(tag)s.id, %(tag)s.name 226 | %(min_count_sql)s 227 | ORDER BY %(tag)s.name ASC""" % { 228 | 'tag': qn(self.model._meta.db_table), 229 | 'count_sql': counts and ', COUNT(%s.object_id)' % 230 | tagged_item_table or '', 231 | 'tagged_item': tagged_item_table, 232 | 'content_type_id': ContentType.objects.get_for_model(model).pk, 233 | 'tag_id_placeholders': ','.join(['%s'] * tag_count), 234 | 'tag_count': tag_count, 235 | 'min_count_sql': min_count is not None and ( 236 | 'HAVING COUNT(%s.object_id) >= %%s' % tagged_item_table) or '', 237 | } 238 | 239 | params = [tag.pk for tag in tags] * 2 240 | if min_count is not None: 241 | params.append(min_count) 242 | 243 | cursor = connection.cursor() 244 | cursor.execute(query, params) 245 | related = [] 246 | for row in cursor.fetchall(): 247 | tag = self.model(*row[:2]) 248 | if counts is True: 249 | tag.count = row[2] 250 | related.append(tag) 251 | return related 252 | 253 | def cloud_for_model(self, model, steps=4, distribution=LOGARITHMIC, 254 | filters=None, min_count=None): 255 | """ 256 | Obtain a list of tags associated with instances of the given 257 | Model, giving each tag a ``count`` attribute indicating how 258 | many times it has been used and a ``font_size`` attribute for 259 | use in displaying a tag cloud. 260 | 261 | ``steps`` defines the range of font sizes - ``font_size`` will 262 | be an integer between 1 and ``steps`` (inclusive). 263 | 264 | ``distribution`` defines the type of font size distribution 265 | algorithm which will be used - logarithmic or linear. It must 266 | be either ``tagging.utils.LOGARITHMIC`` or 267 | ``tagging.utils.LINEAR``. 268 | 269 | To limit the tags displayed in the cloud to those associated 270 | with a subset of the Model's instances, pass a dictionary of 271 | field lookups to be applied to the given Model as the 272 | ``filters`` argument. 273 | 274 | To limit the tags displayed in the cloud to those with a 275 | ``count`` greater than or equal to ``min_count``, pass a value 276 | for the ``min_count`` argument. 277 | """ 278 | tags = list(self.usage_for_model(model, counts=True, filters=filters, 279 | min_count=min_count)) 280 | return calculate_cloud(tags, steps, distribution) 281 | 282 | 283 | class TaggedItemManager(models.Manager): 284 | """ 285 | FIXME There's currently no way to get the ``GROUP BY`` and ``HAVING`` 286 | SQL clauses required by many of this manager's methods into 287 | Django's ORM. 288 | 289 | For now, we manually execute a query to retrieve the PKs of 290 | objects we're interested in, then use the ORM's ``__in`` 291 | lookup to return a ``QuerySet``. 292 | 293 | Now that the queryset-refactor branch is in the trunk, this can be 294 | tidied up significantly. 295 | """ 296 | 297 | def get_by_model(self, queryset_or_model, tags): 298 | """ 299 | Create a ``QuerySet`` containing instances of the specified 300 | model associated with a given tag or list of tags. 301 | """ 302 | tags = get_tag_list(tags) 303 | tag_count = len(tags) 304 | if tag_count == 0: 305 | # No existing tags were given 306 | queryset, model = get_queryset_and_model(queryset_or_model) 307 | return model._default_manager.none() 308 | elif tag_count == 1: 309 | # Optimisation for single tag - fall through to the simpler 310 | # query below. 311 | tag = tags[0] 312 | else: 313 | return self.get_intersection_by_model(queryset_or_model, tags) 314 | 315 | queryset, model = get_queryset_and_model(queryset_or_model) 316 | content_type = ContentType.objects.get_for_model(model) 317 | opts = self.model._meta 318 | tagged_item_table = qn(opts.db_table) 319 | return queryset.extra( 320 | tables=[opts.db_table], 321 | where=[ 322 | '%s.content_type_id = %%s' % tagged_item_table, 323 | '%s.tag_id = %%s' % tagged_item_table, 324 | '%s.%s = %s.object_id' % (qn(model._meta.db_table), 325 | qn(model._meta.pk.column), 326 | tagged_item_table) 327 | ], 328 | params=[content_type.pk, tag.pk], 329 | ) 330 | 331 | def get_intersection_by_model(self, queryset_or_model, tags): 332 | """ 333 | Create a ``QuerySet`` containing instances of the specified 334 | model associated with *all* of the given list of tags. 335 | """ 336 | tags = get_tag_list(tags) 337 | tag_count = len(tags) 338 | queryset, model = get_queryset_and_model(queryset_or_model) 339 | 340 | if not tag_count: 341 | return model._default_manager.none() 342 | 343 | model_table = qn(model._meta.db_table) 344 | # This query selects the ids of all objects which have all the 345 | # given tags. 346 | query = """ 347 | SELECT %(model_pk)s 348 | FROM %(model)s, %(tagged_item)s 349 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 350 | AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s) 351 | AND %(model_pk)s = %(tagged_item)s.object_id 352 | GROUP BY %(model_pk)s 353 | HAVING COUNT(%(model_pk)s) = %(tag_count)s""" % { 354 | 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), 355 | 'model': model_table, 356 | 'tagged_item': qn(self.model._meta.db_table), 357 | 'content_type_id': ContentType.objects.get_for_model(model).pk, 358 | 'tag_id_placeholders': ','.join(['%s'] * tag_count), 359 | 'tag_count': tag_count, 360 | } 361 | 362 | cursor = connection.cursor() 363 | cursor.execute(query, [tag.pk for tag in tags]) 364 | object_ids = [row[0] for row in cursor.fetchall()] 365 | if len(object_ids) > 0: 366 | return queryset.filter(pk__in=object_ids) 367 | else: 368 | return model._default_manager.none() 369 | 370 | def get_union_by_model(self, queryset_or_model, tags): 371 | """ 372 | Create a ``QuerySet`` containing instances of the specified 373 | model associated with *any* of the given list of tags. 374 | """ 375 | tags = get_tag_list(tags) 376 | tag_count = len(tags) 377 | queryset, model = get_queryset_and_model(queryset_or_model) 378 | 379 | if not tag_count: 380 | return model._default_manager.none() 381 | 382 | model_table = qn(model._meta.db_table) 383 | # This query selects the ids of all objects which have any of 384 | # the given tags. 385 | query = """ 386 | SELECT %(model_pk)s 387 | FROM %(model)s, %(tagged_item)s 388 | WHERE %(tagged_item)s.content_type_id = %(content_type_id)s 389 | AND %(tagged_item)s.tag_id IN (%(tag_id_placeholders)s) 390 | AND %(model_pk)s = %(tagged_item)s.object_id 391 | GROUP BY %(model_pk)s""" % { 392 | 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), 393 | 'model': model_table, 394 | 'tagged_item': qn(self.model._meta.db_table), 395 | 'content_type_id': ContentType.objects.get_for_model(model).pk, 396 | 'tag_id_placeholders': ','.join(['%s'] * tag_count), 397 | } 398 | 399 | cursor = connection.cursor() 400 | cursor.execute(query, [tag.pk for tag in tags]) 401 | object_ids = [row[0] for row in cursor.fetchall()] 402 | if len(object_ids) > 0: 403 | return queryset.filter(pk__in=object_ids) 404 | else: 405 | return model._default_manager.none() 406 | 407 | def get_related(self, obj, queryset_or_model, num=None): 408 | """ 409 | Retrieve a list of instances of the specified model which share 410 | tags with the model instance ``obj``, ordered by the number of 411 | shared tags in descending order. 412 | 413 | If ``num`` is given, a maximum of ``num`` instances will be 414 | returned. 415 | """ 416 | queryset, model = get_queryset_and_model(queryset_or_model) 417 | model_table = qn(model._meta.db_table) 418 | content_type = ContentType.objects.get_for_model(obj) 419 | related_content_type = ContentType.objects.get_for_model(model) 420 | query = """ 421 | SELECT %(model_pk)s, COUNT(related_tagged_item.object_id) AS %(count)s 422 | FROM %(model)s, %(tagged_item)s, %(tag)s, 423 | %(tagged_item)s related_tagged_item 424 | WHERE %(tagged_item)s.object_id = %%s 425 | AND %(tagged_item)s.content_type_id = %(content_type_id)s 426 | AND %(tag)s.id = %(tagged_item)s.tag_id 427 | AND related_tagged_item.content_type_id = %(related_content_type_id)s 428 | AND related_tagged_item.tag_id = %(tagged_item)s.tag_id 429 | AND %(model_pk)s = related_tagged_item.object_id""" 430 | if content_type.pk == related_content_type.pk: 431 | # Exclude the given instance itself if determining related 432 | # instances for the same model. 433 | query += """ 434 | AND related_tagged_item.object_id != %(tagged_item)s.object_id""" 435 | query += """ 436 | GROUP BY %(model_pk)s 437 | ORDER BY %(count)s DESC 438 | %(limit_offset)s""" 439 | tagging_table = qn(self.model._meta.get_field( 440 | 'tag').remote_field.model._meta.db_table) 441 | query = query % { 442 | 'model_pk': '%s.%s' % (model_table, qn(model._meta.pk.column)), 443 | 'count': qn('count'), 444 | 'model': model_table, 445 | 'tagged_item': qn(self.model._meta.db_table), 446 | 'tag': tagging_table, 447 | 'content_type_id': content_type.pk, 448 | 'related_content_type_id': related_content_type.pk, 449 | # Hardcoding this for now just to get tests working again - this 450 | # should now be handled by the query object. 451 | 'limit_offset': num is not None and 'LIMIT %s' or '', 452 | } 453 | 454 | cursor = connection.cursor() 455 | params = [obj.pk] 456 | if num is not None: 457 | params.append(num) 458 | cursor.execute(query, params) 459 | object_ids = [row[0] for row in cursor.fetchall()] 460 | if len(object_ids) > 0: 461 | # Use in_bulk here instead of an id__in lookup, 462 | # because id__in would clobber the ordering. 463 | object_dict = queryset.in_bulk(object_ids) 464 | return [object_dict[object_id] for object_id in object_ids 465 | if object_id in object_dict] 466 | else: 467 | return [] 468 | 469 | 470 | ########## 471 | # Models # 472 | ########## 473 | 474 | class Tag(models.Model): 475 | """ 476 | A tag. 477 | """ 478 | name = models.CharField( 479 | _('name'), max_length=settings.MAX_TAG_LENGTH, 480 | unique=True, db_index=True) 481 | 482 | objects = TagManager() 483 | 484 | class Meta: 485 | ordering = ('name',) 486 | verbose_name = _('tag') 487 | verbose_name_plural = _('tags') 488 | 489 | def __str__(self): 490 | return self.name 491 | 492 | 493 | class TaggedItem(models.Model): 494 | """ 495 | Holds the relationship between a tag and the item being tagged. 496 | """ 497 | tag = models.ForeignKey( 498 | Tag, 499 | verbose_name=_('tag'), 500 | related_name='items', 501 | on_delete=models.CASCADE) 502 | 503 | content_type = models.ForeignKey( 504 | ContentType, 505 | verbose_name=_('content type'), 506 | on_delete=models.CASCADE) 507 | 508 | object_id = models.PositiveIntegerField( 509 | _('object id'), 510 | db_index=True) 511 | 512 | object = GenericForeignKey( 513 | 'content_type', 'object_id') 514 | 515 | objects = TaggedItemManager() 516 | 517 | class Meta: 518 | # Enforce unique tag association per object 519 | unique_together = (('tag', 'content_type', 'object_id'),) 520 | verbose_name = _('tagged item') 521 | verbose_name_plural = _('tagged items') 522 | 523 | def __str__(self): 524 | return '%s [%s]' % (smart_str(self.object), smart_str(self.tag)) 525 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 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 | :local: 13 | :depth: 3 14 | 15 | Installation 16 | ============ 17 | 18 | Installing an official release 19 | ------------------------------ 20 | 21 | Official releases are made available from 22 | https://pypi.python.org/pypi/django-tagging/ 23 | 24 | Source distribution 25 | ~~~~~~~~~~~~~~~~~~~ 26 | 27 | Download the a 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 | More easily with :program:`pip`:: 35 | 36 | $ pip install django-tagging 37 | 38 | Installing the development version 39 | ---------------------------------- 40 | 41 | Alternatively, if you'd like to update Django Tagging occasionally to pick 42 | up the latest bug fixes and enhancements before they make it into an 43 | official release, clone the git repository instead. The following 44 | command will clone the development branch to ``django-tagging`` directory:: 45 | 46 | git clone git@github.com:Fantomas42/django-tagging.git 47 | 48 | Add the resulting folder to your `PYTHONPATH`_ or symlink (`junction`_, 49 | if you're on Windows) the ``tagging`` directory inside it into a 50 | directory which is on your PYTHONPATH, such as your Python 51 | installation's ``site-packages`` directory. 52 | 53 | You can verify that the application is available on your PYTHONPATH by 54 | opening a Python interpreter and entering the following commands:: 55 | 56 | >>> import tagging 57 | >>> tagging.__version__ 58 | 0.4.dev0 59 | 60 | When you want to update your copy of the Django Tagging source code, run 61 | the command ``git pull`` from within the ``django-tagging`` directory. 62 | 63 | .. caution:: 64 | 65 | The development version may contain bugs which are not present in the 66 | release version and introduce backwards-incompatible changes. 67 | 68 | If you're tracking git, keep an eye on the `CHANGELOG`_ 69 | before you update your copy of the source code. 70 | 71 | .. _`PYTHONPATH`: http://www.python.org/doc/2.5.2/tut/node8.html#SECTION008120000000000000000 72 | .. _`junction`: http://www.microsoft.com/technet/sysinternals/FileAndDisk/Junction.mspx 73 | .. _`CHANGELOG`: https://github.com/Fantomas42/django-tagging/blob/develop/CHANGELOG.txt 74 | 75 | Using Django Tagging in your applications 76 | ----------------------------------------- 77 | 78 | Once you've installed Django Tagging and want to use it in your Django 79 | applications, do the following: 80 | 81 | 1. Put ``'tagging'`` in your ``INSTALLED_APPS`` setting. 82 | 2. Run the command ``manage.py migrate``. 83 | 84 | The ``migrate`` command creates the necessary database tables and 85 | creates permission objects for all installed apps that need them. 86 | 87 | That's it! 88 | 89 | Settings 90 | ======== 91 | 92 | Some of the application's behaviour can be configured by adding the 93 | appropriate settings to your project's settings file. 94 | 95 | The following settings are available: 96 | 97 | FORCE_LOWERCASE_TAGS 98 | -------------------- 99 | 100 | Default: ``False`` 101 | 102 | A boolean that turns on/off forcing of all tag names to lowercase before 103 | they are saved to the database. 104 | 105 | MAX_TAG_LENGTH 106 | -------------- 107 | 108 | Default: ``50`` 109 | 110 | An integer which specifies the maximum length which any tag is allowed 111 | to have. This is used for validation in the ``django.contrib.admin`` 112 | application and in any forms automatically generated using ``ModelForm``. 113 | 114 | 115 | Registering your models 116 | ======================= 117 | 118 | Your Django models can be registered with the tagging application to 119 | access some additional tagging-related features. 120 | 121 | .. note:: 122 | 123 | You don't *have* to register your models in order to use them with 124 | the tagging application - many of the features added by registration 125 | are just convenience wrappers around the tagging API provided by the 126 | ``Tag`` and ``TaggedItem`` models and their managers, as documented 127 | further below. 128 | 129 | The ``register`` function 130 | ------------------------- 131 | 132 | To register a model, import the ``tagging.registry`` module and call its 133 | ``register`` function, like so:: 134 | 135 | from django.db import models 136 | 137 | from tagging.registry import register 138 | 139 | class Widget(models.Model): 140 | name = models.CharField(max_length=50) 141 | 142 | register(Widget) 143 | 144 | The following argument is required: 145 | 146 | ``model`` 147 | The model class to be registered. 148 | 149 | An exception will be raised if you attempt to register the same class 150 | more than once. 151 | 152 | The following arguments are optional, with some recommended defaults - 153 | take care to specify different attribute names if the defaults clash 154 | with your model class' definition: 155 | 156 | ``tag_descriptor_attr`` 157 | The name of an attribute in the model class which will hold a tag 158 | descriptor for the model. Default: ``'tags'`` 159 | 160 | See `TagDescriptor`_ below for details about the use of this 161 | descriptor. 162 | 163 | ``tagged_item_manager_attr`` 164 | The name of an attribute in the model class which will hold a custom 165 | manager for accessing tagged items for the model. Default: 166 | ``'tagged'``. 167 | 168 | See `ModelTaggedItemManager`_ below for details about the use of this 169 | manager. 170 | 171 | ``TagDescriptor`` 172 | ----------------- 173 | 174 | When accessed through the model class itself, this descriptor will return 175 | a ``ModelTagManager`` for the model. See `ModelTagManager`_ below for 176 | more details about its use. 177 | 178 | When accessed through a model instance, this descriptor provides a handy 179 | means of retrieving, updating and deleting the instance's tags. For 180 | example:: 181 | 182 | >>> widget = Widget.objects.create(name='Testing descriptor') 183 | >>> widget.tags 184 | [] 185 | >>> widget.tags = 'toast, melted cheese, butter' 186 | >>> widget.tags 187 | [, , ] 188 | >>> del widget.tags 189 | >>> widget.tags 190 | [] 191 | 192 | ``ModelTagManager`` 193 | ------------------- 194 | 195 | A manager for retrieving tags used by a particular model. 196 | 197 | Defines the following methods: 198 | 199 | * ``get_queryset()`` -- as this method is redefined, any ``QuerySets`` 200 | created by this model will be initially restricted to contain the 201 | distinct tags used by all the model's instances. 202 | 203 | * ``cloud(*args, **kwargs)`` -- creates a list of tags used by the 204 | model's instances, with ``count`` and ``font_size`` attributes set for 205 | use in displaying a tag cloud. 206 | 207 | See the documentation on ``Tag``'s manager's `cloud_for_model method`_ 208 | for information on additional arguments which can be given. 209 | 210 | * ``related(self, tags, *args, **kwargs)`` -- creates a list of tags 211 | used by the model's instances, which are also used by all instance 212 | which have the given ``tags``. 213 | 214 | See the documentation on ``Tag``'s manager's 215 | `related_for_model method`_ for information on additional arguments 216 | which can be given. 217 | 218 | * ``usage(self, *args, **kwargs))`` -- creates a list of tags used by 219 | the model's instances, with optional usages counts, restriction based 220 | on usage counts and restriction of the model instances from which 221 | usage and counts are determined. 222 | 223 | See the documentation on ``Tag``'s manager's `usage_for_model method`_ 224 | for information on additional arguments which can be given. 225 | 226 | Example usage:: 227 | 228 | # Create a ``QuerySet`` of tags used by Widget instances 229 | Widget.tags.all() 230 | 231 | # Retrieve a list of tags used by Widget instances with usage counts 232 | Widget.tags.usage(counts=True) 233 | 234 | # Retrieve tags used by instances of WIdget which are also tagged with 235 | # 'cheese' and 'toast' 236 | Widget.tags.related(['cheese', 'toast'], counts=True, min_count=3) 237 | 238 | ``ModelTaggedItemManager`` 239 | -------------------------- 240 | 241 | A manager for retrieving model instance for a particular model, based on 242 | their tags. 243 | 244 | * ``related_to(obj, queryset=None, num=None)`` -- creates a list 245 | of model instances which are related to ``obj``, based on its tags. If 246 | a ``queryset`` argument is provided, it will be used to restrict the 247 | resulting list of model instances. 248 | 249 | If ``num`` is given, a maximum of ``num`` instances will be returned. 250 | 251 | * ``with_all(tags, queryset=None)`` -- creates a ``QuerySet`` containing 252 | model instances which are tagged with *all* the given tags. If a 253 | ``queryset`` argument is provided, it will be used as the basis for 254 | the resulting ``QuerySet``. 255 | 256 | * ``with_any(tags, queryset=None)`` -- creates a ``QuerySet`` containing model 257 | instances which are tagged with *any* the given tags. If a ``queryset`` 258 | argument is provided, it will be used as the basis for the resulting 259 | ``QuerySet``. 260 | 261 | 262 | Tags 263 | ==== 264 | 265 | Tags are represented by the ``Tag`` model, which lives in the 266 | ``tagging.models`` module. 267 | 268 | API reference 269 | ------------- 270 | 271 | Fields 272 | ~~~~~~ 273 | 274 | ``Tag`` objects have the following fields: 275 | 276 | * ``name`` -- The name of the tag. This is a unique value. 277 | 278 | Manager functions 279 | ~~~~~~~~~~~~~~~~~ 280 | 281 | The ``Tag`` model has a custom manager which has the following helper 282 | methods: 283 | 284 | * ``update_tags(obj, tag_names)`` -- updates tags associated with an 285 | object. 286 | 287 | ``tag_names`` is a string containing tag names with which ``obj`` 288 | should be tagged. 289 | 290 | If ``tag_names`` is ``None`` or ``''``, the object's tags will be 291 | cleared. 292 | 293 | * ``add_tag(obj, tag_name)`` -- associates a tag with an an object. 294 | 295 | ``tag_name`` is a string containing a tag name with which ``obj`` 296 | should be tagged. 297 | 298 | * ``get_for_object(obj)`` -- returns a ``QuerySet`` containing all 299 | ``Tag`` objects associated with ``obj``. 300 | 301 | .. _`usage_for_model method`: 302 | 303 | * ``usage_for_model(model, counts=False, min_count=None, filters=None)`` 304 | -- returns a list of ``Tag`` objects associated with instances of 305 | ``model``. 306 | 307 | If ``counts`` is ``True``, a ``count`` attribute will be added to each 308 | tag, indicating how many times it has been associated with instances 309 | of ``model``. 310 | 311 | If ``min_count`` is given, only tags which have a ``count`` greater 312 | than or equal to ``min_count`` will be returned. Passing a value for 313 | ``min_count`` implies ``counts=True``. 314 | 315 | To limit the tags (and counts, if specified) returned to those used by 316 | a subset of the model's instances, pass a dictionary of field lookups 317 | to be applied to ``model`` as the ``filters`` argument. 318 | 319 | .. _`related_for_model method`: 320 | 321 | * ``related_for_model(tags, Model, counts=False, min_count=None)`` 322 | -- returns a list of tags related to a given list of tags - that is, 323 | other tags used by items which have all the given tags. 324 | 325 | If ``counts`` is ``True``, a ``count`` attribute will be added to each 326 | tag, indicating the number of items which have it in addition to the 327 | given list of tags. 328 | 329 | If ``min_count`` is given, only tags which have a ``count`` greater 330 | than or equal to ``min_count`` will be returned. Passing a value for 331 | ``min_count`` implies ``counts=True``. 332 | 333 | .. _`cloud_for_model method`: 334 | 335 | * ``cloud_for_model(Model, steps=4, distribution=LOGARITHMIC, 336 | filters=None, min_count=None)`` -- returns a list of the distinct 337 | ``Tag`` objects associated with instances of ``Model``, each having a 338 | ``count`` attribute as above and an additional ``font_size`` 339 | attribute, for use in creation of a tag cloud (a type of weighted 340 | list). 341 | 342 | ``steps`` defines the number of font sizes available - ``font_size`` 343 | may be an integer between ``1`` and ``steps``, inclusive. 344 | 345 | ``distribution`` defines the type of font size distribution algorithm 346 | which will be used - logarithmic or linear. It must be either 347 | ``tagging.utils.LOGARITHMIC`` or ``tagging.utils.LINEAR``. 348 | 349 | To limit the tags displayed in the cloud to those associated with a 350 | subset of the Model's instances, pass a dictionary of field lookups to 351 | be applied to the given Model as the ``filters`` argument. 352 | 353 | To limit the tags displayed in the cloud to those with a ``count`` 354 | greater than or equal to ``min_count``, pass a value for the 355 | ``min_count`` argument. 356 | 357 | * ``usage_for_queryset(queryset, counts=False, min_count=None)`` -- 358 | Obtains a list of tags associated with instances of a model contained 359 | in the given queryset. 360 | 361 | If ``counts`` is True, a ``count`` attribute will be added to each tag, 362 | indicating how many times it has been used against the Model class in 363 | question. 364 | 365 | If ``min_count`` is given, only tags which have a ``count`` greater 366 | than or equal to ``min_count`` will be returned. 367 | 368 | Passing a value for ``min_count`` implies ``counts=True``. 369 | 370 | Basic usage 371 | ----------- 372 | 373 | Tagging objects and retrieving an object's tags 374 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 375 | 376 | Objects may be tagged using the ``update_tags`` helper function:: 377 | 378 | >>> from shop.apps.products.models import Widget 379 | >>> from tagging.models import Tag 380 | >>> widget = Widget.objects.get(pk=1) 381 | >>> Tag.objects.update_tags(widget, 'house thing') 382 | 383 | Retrieve tags for an object using the ``get_for_object`` helper 384 | function:: 385 | 386 | >>> Tag.objects.get_for_object(widget) 387 | [, ] 388 | 389 | Tags are created, associated and unassociated accordingly when you use 390 | ``update_tags`` and ``add_tag``:: 391 | 392 | >>> Tag.objects.update_tags(widget, 'house monkey') 393 | >>> Tag.objects.get_for_object(widget) 394 | [, ] 395 | >>> Tag.objects.add_tag(widget, 'tiles') 396 | >>> Tag.objects.get_for_object(widget) 397 | [, , ] 398 | 399 | Clear an object's tags by passing ``None`` or ``''`` to 400 | ``update_tags``:: 401 | 402 | >>> Tag.objects.update_tags(widget, None) 403 | >>> Tag.objects.get_for_object(widget) 404 | [] 405 | 406 | Retrieving tags used by a particular model 407 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 408 | 409 | To retrieve all tags used for a particular model, use the 410 | ``get_for_model`` helper function:: 411 | 412 | >>> widget1 = Widget.objects.get(pk=1) 413 | >>> Tag.objects.update_tags(widget1, 'house thing') 414 | >>> widget2 = Widget.objects.get(pk=2) 415 | >>> Tag.objects.update_tags(widget2, 'cheese toast house') 416 | >>> Tag.objects.usage_for_model(Widget) 417 | [, , , ] 418 | 419 | To get a count of how many times each tag was used for a particular 420 | model, pass in ``True`` for the ``counts`` argument:: 421 | 422 | >>> tags = Tag.objects.usage_for_model(Widget, counts=True) 423 | >>> [(tag.name, tag.count) for tag in tags] 424 | [('cheese', 1), ('house', 2), ('thing', 1), ('toast', 1)] 425 | 426 | To get counts and limit the tags returned to those with counts above a 427 | certain size, pass in a ``min_count`` argument:: 428 | 429 | >>> tags = Tag.objects.usage_for_model(Widget, min_count=2) 430 | >>> [(tag.name, tag.count) for tag in tags] 431 | [('house', 2)] 432 | 433 | You can also specify a dictionary of `field lookups`_ to be used to 434 | restrict the tags and counts returned based on a subset of the 435 | model's instances. For example, the following would retrieve all tags 436 | used on Widgets created by a user named Alan which have a size 437 | greater than 99:: 438 | 439 | >>> Tag.objects.usage_for_model(Widget, filters=dict(size__gt=99, user__username='Alan')) 440 | 441 | .. _`field lookups`: http://docs.djangoproject.com/en/dev/topics/db/queries/#field-lookups 442 | 443 | The ``usage_for_queryset`` method allows you to pass a pre-filtered 444 | queryset to be used when determining tag usage:: 445 | 446 | >>> Tag.objects.usage_for_queryset(Widget.objects.filter(size__gt=99, user__username='Alan')) 447 | 448 | Tag input 449 | --------- 450 | 451 | Tag input from users is treated as follows: 452 | 453 | * If the input doesn't contain any commas or double quotes, it is simply 454 | treated as a space-delimited list of tag names. 455 | 456 | * If the input does contain either of these characters, we parse the 457 | input like so: 458 | 459 | * Groups of characters which appear between double quotes take 460 | precedence as multi-word tags (so double quoted tag names may 461 | contain commas). An unclosed double quote will be ignored. 462 | 463 | * For the remaining input, if there are any unquoted commas in the 464 | input, the remainder will be treated as comma-delimited. Otherwise, 465 | it will be treated as space-delimited. 466 | 467 | Examples: 468 | 469 | ====================== ======================================= ================================================ 470 | Tag input string Resulting tags Notes 471 | ====================== ======================================= ================================================ 472 | apple ball cat [``apple``], [``ball``], [``cat``] No commas, so space delimited 473 | apple, ball cat [``apple``], [``ball cat``] Comma present, so comma delimited 474 | "apple, ball" cat dog [``apple, ball``], [``cat``], [``dog``] All commas are quoted, so space delimited 475 | "apple, ball", cat dog [``apple, ball``], [``cat dog``] Contains an unquoted comma, so comma delimited 476 | apple "ball cat" dog [``apple``], [``ball cat``], [``dog``] No commas, so space delimited 477 | "apple" "ball dog [``apple``], [``ball``], [``dog``] Unclosed double quote is ignored 478 | ====================== ======================================= ================================================ 479 | 480 | 481 | Tagged items 482 | ============ 483 | 484 | The relationship between a ``Tag`` and an object is represented by 485 | the ``TaggedItem`` model, which lives in the ``tagging.models`` 486 | module. 487 | 488 | API reference 489 | ------------- 490 | 491 | Fields 492 | ~~~~~~ 493 | 494 | ``TaggedItem`` objects have the following fields: 495 | 496 | * ``tag`` -- The ``Tag`` an object is associated with. 497 | * ``content_type`` -- The ``ContentType`` of the associated model 498 | instance. 499 | * ``object_id`` -- The id of the associated object. 500 | * ``object`` -- The associated object itself, accessible via the 501 | Generic Relations API. 502 | 503 | Manager functions 504 | ~~~~~~~~~~~~~~~~~ 505 | 506 | The ``TaggedItem`` model has a custom manager which has the following 507 | helper methods, which accept either a ``QuerySet`` or a ``Model`` 508 | class as one of their arguments. To restrict the objects which are 509 | returned, pass in a filtered ``QuerySet`` for this argument: 510 | 511 | * ``get_by_model(queryset_or_model, tag)`` -- creates a ``QuerySet`` 512 | containing instances of the specififed model which are tagged with 513 | the given tag or tags. 514 | 515 | * ``get_intersection_by_model(queryset_or_model, tags)`` -- creates a 516 | ``QuerySet`` containing instances of the specified model which are 517 | tagged with every tag in a list of tags. 518 | 519 | ``get_by_model`` will call this function behind the scenes when you 520 | pass it a list, so you can use ``get_by_model`` instead of calling 521 | this method directly. 522 | 523 | * ``get_union_by_model(queryset_or_model, tags)`` -- creates a 524 | ``QuerySet`` containing instances of the specified model which are 525 | tagged with any tag in a list of tags. 526 | 527 | .. _`get_related method`: 528 | 529 | * ``get_related(obj, queryset_or_model, num=None)`` - returns a list of 530 | instances of the specified model which share tags with the model 531 | instance ``obj``, ordered by the number of shared tags in descending 532 | order. 533 | 534 | If ``num`` is given, a maximum of ``num`` instances will be returned. 535 | 536 | Basic usage 537 | ----------- 538 | 539 | Retrieving tagged objects 540 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 541 | 542 | Objects may be retrieved based on their tags using the ``get_by_model`` 543 | manager method:: 544 | 545 | >>> from shop.apps.products.models import Widget 546 | >>> from tagging.models import Tag 547 | >>> house_tag = Tag.objects.get(name='house') 548 | >>> TaggedItem.objects.get_by_model(Widget, house_tag) 549 | [, ] 550 | 551 | Passing a list of tags to ``get_by_model`` returns an intersection of 552 | objects which have those tags, i.e. tag1 AND tag2 ... AND tagN:: 553 | 554 | >>> thing_tag = Tag.objects.get(name='thing') 555 | >>> TaggedItem.objects.get_by_model(Widget, [house_tag, thing_tag]) 556 | [] 557 | 558 | Functions which take tags are flexible when it comes to tag input:: 559 | 560 | >>> TaggedItem.objects.get_by_model(Widget, Tag.objects.filter(name__in=['house', 'thing'])) 561 | [] 562 | >>> TaggedItem.objects.get_by_model(Widget, 'house thing') 563 | [] 564 | >>> TaggedItem.objects.get_by_model(Widget, ['house', 'thing']) 565 | [] 566 | 567 | Restricting objects returned 568 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 569 | 570 | Pass in a ``QuerySet`` to restrict the objects returned:: 571 | 572 | # Retrieve all Widgets which have a price less than 50, tagged with 'house' 573 | TaggedItem.objects.get_by_model(Widget.objects.filter(price__lt=50), 'house') 574 | 575 | # Retrieve all Widgets which have a name starting with 'a', tagged with any 576 | # of 'house', 'garden' or 'water'. 577 | TaggedItem.objects.get_union_by_model(Widget.objects.filter(name__startswith='a'), 578 | ['house', 'garden', 'water']) 579 | 580 | 581 | Utilities 582 | ========= 583 | 584 | Tag-related utility functions are defined in the ``tagging.utils`` 585 | module: 586 | 587 | ``parse_tag_input(input)`` 588 | -------------------------- 589 | 590 | Parses tag input, with multiple word input being activated and 591 | delineated by commas and double quotes. Quotes take precedence, so they 592 | may contain commas. 593 | 594 | Returns a sorted list of unique tag names. 595 | 596 | See `tag input`_ for more details. 597 | 598 | ``edit_string_for_tags(tags)`` 599 | ------------------------------ 600 | Given list of ``Tag`` instances, creates a string representation of the 601 | list suitable for editing by the user, such that submitting the given 602 | string representation back without changing it will give the same list 603 | of tags. 604 | 605 | Tag names which contain commas will be double quoted. 606 | 607 | If any tag name which isn't being quoted contains whitespace, the 608 | resulting string of tag names will be comma-delimited, otherwise it will 609 | be space-delimited. 610 | 611 | ``get_tag_list(tags)`` 612 | ---------------------- 613 | 614 | Utility function for accepting tag input in a flexible manner. 615 | 616 | If a ``Tag`` object is given, it will be returned in a list as its 617 | single occupant. 618 | 619 | If given, the tag names in the following will be used to create a 620 | ``Tag`` ``QuerySet``: 621 | 622 | * A string, which may contain multiple tag names. 623 | * A list or tuple of strings corresponding to tag names. 624 | * A list or tuple of integers corresponding to tag ids. 625 | 626 | If given, the following will be returned as-is: 627 | 628 | * A list or tuple of ``Tag`` objects. 629 | * A ``Tag`` ``QuerySet``. 630 | 631 | ``calculate_cloud(tags, steps=4, distribution=tagging.utils.LOGARITHMIC)`` 632 | -------------------------------------------------------------------------- 633 | 634 | Add a ``font_size`` attribute to each tag according to the frequency of 635 | its use, as indicated by its ``count`` attribute. 636 | 637 | ``steps`` defines the range of font sizes - ``font_size`` will be an 638 | integer between 1 and ``steps`` (inclusive). 639 | 640 | ``distribution`` defines the type of font size distribution algorithm 641 | which will be used - logarithmic or linear. It must be one of 642 | ``tagging.utils.LOGARITHMIC`` or ``tagging.utils.LINEAR``. 643 | 644 | 645 | Model Fields 646 | ============ 647 | 648 | The ``tagging.fields`` module contains fields which make it easy to 649 | integrate tagging into your models and into the 650 | ``django.contrib.admin`` application. 651 | 652 | Field types 653 | ----------- 654 | 655 | ``TagField`` 656 | ~~~~~~~~~~~~ 657 | 658 | A ``CharField`` that actually works as a relationship to tags "under 659 | the hood". 660 | 661 | Using this example model:: 662 | 663 | class Link(models.Model): 664 | ... 665 | tags = TagField() 666 | 667 | Setting tags:: 668 | 669 | >>> l = Link.objects.get(...) 670 | >>> l.tags = 'tag1 tag2 tag3' 671 | 672 | Getting tags for an instance:: 673 | 674 | >>> l.tags 675 | 'tag1 tag2 tag3' 676 | 677 | Getting tags for a model - i.e. all tags used by all instances of the 678 | model:: 679 | 680 | >>> Link.tags 681 | 'tag1 tag2 tag3 tag4 tag5' 682 | 683 | This field will also validate that it has been given a valid list of 684 | tag names, separated by a single comma, a single space or a comma 685 | followed by a space. 686 | 687 | 688 | Form fields 689 | =========== 690 | 691 | The ``tagging.forms`` module contains a ``Field`` for use with 692 | Django's `forms library`_ which takes care of validating tag name 693 | input when used in your forms. 694 | 695 | .. _`forms library`: http://docs.djangoproject.com/en/dev/topics/forms/ 696 | 697 | Field types 698 | ----------- 699 | 700 | ``TagField`` 701 | ~~~~~~~~~~~~ 702 | 703 | A form ``Field`` which is displayed as a single-line text input, which 704 | validates that the input it receives is a valid list of tag names. 705 | 706 | When you generate a form for one of your models automatically, using 707 | the ``ModelForm`` class, any ``tagging.fields.TagField`` fields in your 708 | model will automatically be represented by a ``tagging.forms.TagField`` 709 | in the generated form. 710 | 711 | 712 | Generic views 713 | ============= 714 | 715 | The ``tagging.views`` module contains views to handle simple cases of 716 | common display logic related to tagging. 717 | 718 | ``tagging.views.TaggedObjectList`` 719 | ---------------------------------- 720 | 721 | **Description:** 722 | 723 | A view that displays a list of objects for a given model which have a 724 | given tag. This is a thin wrapper around the 725 | ``django.views.generic.list.ListView`` view, which takes a 726 | model and a tag as its arguments (in addition to the other optional 727 | arguments supported by ``ListView``), building the appropriate 728 | ``QuerySet`` for you instead of expecting one to be passed in. 729 | 730 | **Required arguments:** 731 | 732 | * ``tag``: The tag which objects of the given model must have in 733 | order to be listed. 734 | 735 | **Optional arguments:** 736 | 737 | Please refer to the `ListView documentation`_ for additional optional 738 | arguments which may be given. 739 | 740 | * ``related_tags``: If ``True``, a ``related_tags`` context variable 741 | will also contain tags related to the given tag for the given 742 | model. 743 | 744 | * ``related_tag_counts``: If ``True`` and ``related_tags`` is 745 | ``True``, each related tag will have a ``count`` attribute 746 | indicating the number of items which have it in addition to the 747 | given tag. 748 | 749 | **Template context:** 750 | 751 | Please refer to the `ListView documentation`_ for additional 752 | template context variables which may be provided. 753 | 754 | * ``tag``: The ``Tag`` instance for the given tag. 755 | 756 | .. _`ListView documentation`: https://docs.djangoproject.com/en/1.8/ref/class-based-views/generic-display/#listview 757 | 758 | Example usage 759 | ~~~~~~~~~~~~~ 760 | 761 | The following sample URLconf demonstrates using this generic view to 762 | list items of a particular model class which have a given tag:: 763 | 764 | from django.conf.urls.defaults import * 765 | 766 | from tagging.views import TaggedObjectList 767 | 768 | from shop.apps.products.models import Widget 769 | 770 | urlpatterns = patterns('', 771 | url(r'^widgets/tag/(?P[^/]+(?u))/$', 772 | TaggedObjectList.as_view(model=Widget, paginate_by=10, allow_empty=True), 773 | name='widget_tag_detail'), 774 | ) 775 | 776 | The following sample view demonstrates wrapping this generic view to 777 | perform filtering of the objects which are listed:: 778 | 779 | from myapp.models import People 780 | 781 | from tagging.views import TaggedObjectList 782 | 783 | class TaggedPeopleFilteredList(TaggedObjectList): 784 | queryset = People.objects.filter(country__code=country_code) 785 | 786 | Template tags 787 | ============= 788 | 789 | The ``tagging.templatetags.tagging_tags`` module defines a number of 790 | template tags which may be used to work with tags. 791 | 792 | Tag reference 793 | ------------- 794 | 795 | tags_for_model 796 | ~~~~~~~~~~~~~~ 797 | 798 | Retrieves a list of ``Tag`` objects associated with a given model and 799 | stores them in a context variable. 800 | 801 | Usage:: 802 | 803 | {% tags_for_model [model] as [varname] %} 804 | 805 | The model is specified in ``[appname].[modelname]`` format. 806 | 807 | Extended usage:: 808 | 809 | {% tags_for_model [model] as [varname] with counts %} 810 | 811 | If specified - by providing extra ``with counts`` arguments - adds a 812 | ``count`` attribute to each tag containing the number of instances of 813 | the given model which have been tagged with it. 814 | 815 | Examples:: 816 | 817 | {% tags_for_model products.Widget as widget_tags %} 818 | {% tags_for_model products.Widget as widget_tags with counts %} 819 | 820 | tag_cloud_for_model 821 | ~~~~~~~~~~~~~~~~~~~ 822 | 823 | Retrieves a list of ``Tag`` objects for a given model, with tag cloud 824 | attributes set, and stores them in a context variable. 825 | 826 | Usage:: 827 | 828 | {% tag_cloud_for_model [model] as [varname] %} 829 | 830 | The model is specified in ``[appname].[modelname]`` format. 831 | 832 | Extended usage:: 833 | 834 | {% tag_cloud_for_model [model] as [varname] with [options] %} 835 | 836 | Extra options can be provided after an optional ``with`` argument, with 837 | each option being specified in ``[name]=[value]`` format. Valid extra 838 | options are: 839 | 840 | ``steps`` 841 | Integer. Defines the range of font sizes. 842 | 843 | ``min_count`` 844 | Integer. Defines the minimum number of times a tag must have 845 | been used to appear in the cloud. 846 | 847 | ``distribution`` 848 | One of ``linear`` or ``log``. Defines the font-size 849 | distribution algorithm to use when generating the tag cloud. 850 | 851 | Examples:: 852 | 853 | {% tag_cloud_for_model products.Widget as widget_tags %} 854 | {% tag_cloud_for_model products.Widget as widget_tags with steps=9 min_count=3 distribution=log %} 855 | 856 | tags_for_object 857 | ~~~~~~~~~~~~~~~ 858 | 859 | Retrieves a list of ``Tag`` objects associated with an object and stores 860 | them in a context variable. 861 | 862 | Usage:: 863 | 864 | {% tags_for_object [object] as [varname] %} 865 | 866 | Example:: 867 | 868 | {% tags_for_object foo_object as tag_list %} 869 | 870 | tagged_objects 871 | ~~~~~~~~~~~~~~ 872 | 873 | Retrieves a list of instances of a given model which are tagged with a 874 | given ``Tag`` and stores them in a context variable. 875 | 876 | Usage:: 877 | 878 | {% tagged_objects [tag] in [model] as [varname] %} 879 | 880 | The model is specified in ``[appname].[modelname]`` format. 881 | 882 | The tag must be an instance of a ``Tag``, not the name of a tag. 883 | 884 | Example:: 885 | 886 | {% tagged_objects comedy_tag in tv.Show as comedies %} 887 | 888 | -------------------------------------------------------------------------------- /tagging/tests/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django import forms 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.db.models import Q 6 | from django.test import TestCase 7 | from django.test.utils import override_settings 8 | 9 | from tagging import settings 10 | from tagging.forms import TagAdminForm 11 | from tagging.forms import TagField 12 | from tagging.models import Tag 13 | from tagging.models import TaggedItem 14 | from tagging.tests.models import Article 15 | from tagging.tests.models import FormMultipleFieldTest 16 | from tagging.tests.models import FormTest 17 | from tagging.tests.models import FormTestNull 18 | from tagging.tests.models import Link 19 | from tagging.tests.models import Parrot 20 | from tagging.tests.models import Perch 21 | from tagging.utils import LINEAR 22 | from tagging.utils import LOGARITHMIC 23 | from tagging.utils import _calculate_tag_weight 24 | from tagging.utils import calculate_cloud 25 | from tagging.utils import edit_string_for_tags 26 | from tagging.utils import get_tag 27 | from tagging.utils import get_tag_list 28 | from tagging.utils import parse_tag_input 29 | 30 | ############# 31 | # Utilities # 32 | ############# 33 | 34 | 35 | class TestParseTagInput(TestCase): 36 | def test_with_simple_space_delimited_tags(self): 37 | """ Test with simple space-delimited tags. """ 38 | 39 | self.assertEqual(parse_tag_input('one'), ['one']) 40 | self.assertEqual(parse_tag_input('one two'), ['one', 'two']) 41 | self.assertEqual(parse_tag_input('one one two two'), ['one', 'two']) 42 | self.assertEqual(parse_tag_input('one two three'), 43 | ['one', 'three', 'two']) 44 | 45 | def test_with_comma_delimited_multiple_words(self): 46 | """ Test with comma-delimited multiple words. 47 | An unquoted comma in the input will trigger this. """ 48 | 49 | self.assertEqual(parse_tag_input(',one'), ['one']) 50 | self.assertEqual(parse_tag_input(',one two'), ['one two']) 51 | self.assertEqual(parse_tag_input(',one two three'), ['one two three']) 52 | self.assertEqual(parse_tag_input('a-one, a-two and a-three'), 53 | ['a-one', 'a-two and a-three']) 54 | 55 | def test_with_double_quoted_multiple_words(self): 56 | """ Test with double-quoted multiple words. 57 | A completed quote will trigger this. Unclosed quotes are ignored. 58 | """ 59 | 60 | self.assertEqual(parse_tag_input('"one'), ['one']) 61 | self.assertEqual(parse_tag_input('"one two'), ['one', 'two']) 62 | self.assertEqual(parse_tag_input('"one two three'), 63 | ['one', 'three', 'two']) 64 | self.assertEqual(parse_tag_input('"one two"'), ['one two']) 65 | self.assertEqual(parse_tag_input('a-one "a-two and a-three"'), 66 | ['a-one', 'a-two and a-three']) 67 | 68 | def test_with_no_loose_commas(self): 69 | """ Test with no loose commas -- split on spaces. """ 70 | self.assertEqual(parse_tag_input('one two "thr,ee"'), 71 | ['one', 'thr,ee', 'two']) 72 | 73 | def test_with_loose_commas(self): 74 | """ Loose commas - split on commas """ 75 | self.assertEqual(parse_tag_input('"one", two three'), 76 | ['one', 'two three']) 77 | 78 | def test_tags_with_double_quotes_can_contain_commas(self): 79 | """ Double quotes can contain commas """ 80 | self.assertEqual(parse_tag_input('a-one "a-two, and a-three"'), 81 | ['a-one', 'a-two, and a-three']) 82 | self.assertEqual(parse_tag_input('"two", one, one, two, "one"'), 83 | ['one', 'two']) 84 | self.assertEqual(parse_tag_input('two", one'), 85 | ['one', 'two']) 86 | 87 | def test_with_naughty_input(self): 88 | """ Test with naughty input. """ 89 | # Bad users! Naughty users! 90 | self.assertEqual(parse_tag_input(None), []) 91 | self.assertEqual(parse_tag_input(''), []) 92 | self.assertEqual(parse_tag_input('"'), []) 93 | self.assertEqual(parse_tag_input('""'), []) 94 | self.assertEqual(parse_tag_input('"' * 7), []) 95 | self.assertEqual(parse_tag_input(',,,,,,'), []) 96 | self.assertEqual(parse_tag_input('",",",",",",","'), [',']) 97 | self.assertEqual(parse_tag_input('a-one "a-two" and "a-three'), 98 | ['a-one', 'a-three', 'a-two', 'and']) 99 | 100 | 101 | class TestNormalisedTagListInput(TestCase): 102 | def setUp(self): 103 | self.toast = Tag.objects.create(name='toast') 104 | self.cheese = Tag.objects.create(name='cheese') 105 | 106 | def test_single_tag_object_as_input(self): 107 | self.assertEqual(get_tag_list(self.cheese), [self.cheese]) 108 | 109 | def test_space_delimeted_string_as_input(self): 110 | ret = get_tag_list('cheese toast') 111 | self.assertEqual(len(ret), 2) 112 | self.assertTrue(self.cheese in ret) 113 | self.assertTrue(self.toast in ret) 114 | 115 | def test_comma_delimeted_string_as_input(self): 116 | ret = get_tag_list('cheese,toast') 117 | self.assertEqual(len(ret), 2) 118 | self.assertTrue(self.cheese in ret) 119 | self.assertTrue(self.toast in ret) 120 | 121 | def test_with_empty_list(self): 122 | self.assertEqual(get_tag_list([]), []) 123 | 124 | def test_list_of_two_strings(self): 125 | ret = get_tag_list(['cheese', 'toast']) 126 | self.assertEqual(len(ret), 2) 127 | self.assertTrue(self.cheese in ret) 128 | self.assertTrue(self.toast in ret) 129 | 130 | def test_list_of_tag_primary_keys(self): 131 | ret = get_tag_list([self.cheese.id, self.toast.id]) 132 | self.assertEqual(len(ret), 2) 133 | self.assertTrue(self.cheese in ret) 134 | self.assertTrue(self.toast in ret) 135 | 136 | def test_list_of_strings_with_strange_nontag_string(self): 137 | ret = get_tag_list(['cheese', 'toast', 'ŠĐĆŽćžšđ']) 138 | self.assertEqual(len(ret), 2) 139 | self.assertTrue(self.cheese in ret) 140 | self.assertTrue(self.toast in ret) 141 | 142 | def test_list_of_tag_instances(self): 143 | ret = get_tag_list([self.cheese, self.toast]) 144 | self.assertEqual(len(ret), 2) 145 | self.assertTrue(self.cheese in ret) 146 | self.assertTrue(self.toast in ret) 147 | 148 | def test_tuple_of_instances(self): 149 | ret = get_tag_list((self.cheese, self.toast)) 150 | self.assertEqual(len(ret), 2) 151 | self.assertTrue(self.cheese in ret) 152 | self.assertTrue(self.toast in ret) 153 | 154 | def test_with_tag_filter(self): 155 | ret = get_tag_list(Tag.objects.filter(name__in=['cheese', 'toast'])) 156 | self.assertEqual(len(ret), 2) 157 | self.assertTrue(self.cheese in ret) 158 | self.assertTrue(self.toast in ret) 159 | 160 | def test_with_invalid_input_mix_of_string_and_instance(self): 161 | try: 162 | get_tag_list(['cheese', self.toast]) 163 | except ValueError as ve: 164 | self.assertEqual( 165 | str(ve), 166 | 'If a list or tuple of tags is provided, they must all ' 167 | 'be tag names, Tag objects or Tag ids.') 168 | except Exception as e: 169 | raise self.failureException( 170 | 'the wrong type of exception was raised: type [%s] value [%]' % 171 | (str(type(e)), str(e))) 172 | else: 173 | raise self.failureException( 174 | 'a ValueError exception was supposed to be raised!') 175 | 176 | def test_with_invalid_input(self): 177 | try: 178 | get_tag_list(29) 179 | except ValueError as ve: 180 | self.assertEqual(str(ve), 'The tag input given was invalid.') 181 | except Exception as e: 182 | print('--', e) 183 | raise self.failureException( 184 | 'the wrong type of exception was raised: ' 185 | 'type [%s] value [%s]' % (str(type(e)), str(e))) 186 | else: 187 | raise self.failureException( 188 | 'a ValueError exception was supposed to be raised!') 189 | 190 | def test_with_tag_instance(self): 191 | self.assertEqual(get_tag(self.cheese), self.cheese) 192 | 193 | def test_with_string(self): 194 | self.assertEqual(get_tag('cheese'), self.cheese) 195 | 196 | def test_with_primary_key(self): 197 | self.assertEqual(get_tag(self.cheese.id), self.cheese) 198 | 199 | def test_nonexistent_tag(self): 200 | self.assertEqual(get_tag('mouse'), None) 201 | 202 | 203 | class TestCalculateCloud(TestCase): 204 | def setUp(self): 205 | self.tags = [] 206 | for line in open(os.path.join(os.path.dirname(__file__), 207 | 'tags.txt')).readlines(): 208 | name, count = line.rstrip().split() 209 | tag = Tag(name=name) 210 | tag.count = int(count) 211 | self.tags.append(tag) 212 | 213 | def test_default_distribution(self): 214 | sizes = {} 215 | for tag in calculate_cloud(self.tags, steps=5): 216 | sizes[tag.font_size] = sizes.get(tag.font_size, 0) + 1 217 | 218 | # This isn't a pre-calculated test, just making sure it's consistent 219 | self.assertEqual(sizes[1], 48) 220 | self.assertEqual(sizes[2], 30) 221 | self.assertEqual(sizes[3], 19) 222 | self.assertEqual(sizes[4], 15) 223 | self.assertEqual(sizes[5], 10) 224 | 225 | def test_linear_distribution(self): 226 | sizes = {} 227 | for tag in calculate_cloud(self.tags, steps=5, distribution=LINEAR): 228 | sizes[tag.font_size] = sizes.get(tag.font_size, 0) + 1 229 | 230 | # This isn't a pre-calculated test, just making sure it's consistent 231 | self.assertEqual(sizes[1], 97) 232 | self.assertEqual(sizes[2], 12) 233 | self.assertEqual(sizes[3], 7) 234 | self.assertEqual(sizes[4], 2) 235 | self.assertEqual(sizes[5], 4) 236 | 237 | def test_invalid_distribution(self): 238 | try: 239 | calculate_cloud(self.tags, steps=5, distribution='cheese') 240 | except ValueError as ve: 241 | self.assertEqual( 242 | str(ve), 'Invalid distribution algorithm specified: cheese.') 243 | except Exception as e: 244 | raise self.failureException( 245 | 'the wrong type of exception was raised: ' 246 | 'type [%s] value [%s]' % (str(type(e)), str(e))) 247 | else: 248 | raise self.failureException( 249 | 'a ValueError exception was supposed to be raised!') 250 | 251 | def test_calculate_tag_weight(self): 252 | self.assertEqual( 253 | _calculate_tag_weight(10, 20, LINEAR), 254 | 10) 255 | self.assertEqual( 256 | _calculate_tag_weight(10, 20, LOGARITHMIC), 257 | 15.37243573680482) 258 | 259 | def test_calculate_tag_weight_invalid_size(self): 260 | self.assertEqual( 261 | _calculate_tag_weight(10, 10, LOGARITHMIC), 262 | 10.0) 263 | self.assertEqual( 264 | _calculate_tag_weight(26, 26, LOGARITHMIC), 265 | 26.0) 266 | 267 | ########### 268 | # Tagging # 269 | ########### 270 | 271 | 272 | class TestBasicTagging(TestCase): 273 | def setUp(self): 274 | self.dead_parrot = Parrot.objects.create(state='dead') 275 | 276 | def test_update_tags(self): 277 | Tag.objects.update_tags(self.dead_parrot, 'foo,bar,"ter"') 278 | tags = Tag.objects.get_for_object(self.dead_parrot) 279 | self.assertEqual(len(tags), 3) 280 | self.assertTrue(get_tag('foo') in tags) 281 | self.assertTrue(get_tag('bar') in tags) 282 | self.assertTrue(get_tag('ter') in tags) 283 | 284 | Tag.objects.update_tags(self.dead_parrot, '"foo" bar "baz"') 285 | tags = Tag.objects.get_for_object(self.dead_parrot) 286 | self.assertEqual(len(tags), 3) 287 | self.assertTrue(get_tag('bar') in tags) 288 | self.assertTrue(get_tag('baz') in tags) 289 | self.assertTrue(get_tag('foo') in tags) 290 | 291 | def test_add_tag(self): 292 | # start off in a known, mildly interesting state 293 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 294 | tags = Tag.objects.get_for_object(self.dead_parrot) 295 | self.assertEqual(len(tags), 3) 296 | self.assertTrue(get_tag('bar') in tags) 297 | self.assertTrue(get_tag('baz') in tags) 298 | self.assertTrue(get_tag('foo') in tags) 299 | 300 | # try to add a tag that already exists 301 | Tag.objects.add_tag(self.dead_parrot, 'foo') 302 | tags = Tag.objects.get_for_object(self.dead_parrot) 303 | self.assertEqual(len(tags), 3) 304 | self.assertTrue(get_tag('bar') in tags) 305 | self.assertTrue(get_tag('baz') in tags) 306 | self.assertTrue(get_tag('foo') in tags) 307 | 308 | # now add a tag that doesn't already exist 309 | Tag.objects.add_tag(self.dead_parrot, 'zip') 310 | tags = Tag.objects.get_for_object(self.dead_parrot) 311 | self.assertEqual(len(tags), 4) 312 | self.assertTrue(get_tag('zip') in tags) 313 | self.assertTrue(get_tag('bar') in tags) 314 | self.assertTrue(get_tag('baz') in tags) 315 | self.assertTrue(get_tag('foo') in tags) 316 | 317 | def test_add_tag_invalid_input_no_tags_specified(self): 318 | # start off in a known, mildly interesting state 319 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 320 | tags = Tag.objects.get_for_object(self.dead_parrot) 321 | self.assertEqual(len(tags), 3) 322 | self.assertTrue(get_tag('bar') in tags) 323 | self.assertTrue(get_tag('baz') in tags) 324 | self.assertTrue(get_tag('foo') in tags) 325 | 326 | try: 327 | Tag.objects.add_tag(self.dead_parrot, ' ') 328 | except AttributeError as ae: 329 | self.assertEqual(str(ae), 'No tags were given: " ".') 330 | except Exception as e: 331 | raise self.failureException( 332 | 'the wrong type of exception was raised: ' 333 | 'type [%s] value [%s]' % (str(type(e)), str(e))) 334 | else: 335 | raise self.failureException( 336 | 'an AttributeError exception was supposed to be raised!') 337 | 338 | def test_add_tag_invalid_input_multiple_tags_specified(self): 339 | # start off in a known, mildly interesting state 340 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 341 | tags = Tag.objects.get_for_object(self.dead_parrot) 342 | self.assertEqual(len(tags), 3) 343 | self.assertTrue(get_tag('bar') in tags) 344 | self.assertTrue(get_tag('baz') in tags) 345 | self.assertTrue(get_tag('foo') in tags) 346 | 347 | try: 348 | Tag.objects.add_tag(self.dead_parrot, 'one two') 349 | except AttributeError as ae: 350 | self.assertEqual(str(ae), 'Multiple tags were given: "one two".') 351 | except Exception as e: 352 | raise self.failureException( 353 | 'the wrong type of exception was raised: ' 354 | 'type [%s] value [%s]' % (str(type(e)), str(e))) 355 | else: 356 | raise self.failureException( 357 | 'an AttributeError exception was supposed to be raised!') 358 | 359 | def test_update_tags_exotic_characters(self): 360 | # start off in a known, mildly interesting state 361 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 362 | tags = Tag.objects.get_for_object(self.dead_parrot) 363 | self.assertEqual(len(tags), 3) 364 | self.assertTrue(get_tag('bar') in tags) 365 | self.assertTrue(get_tag('baz') in tags) 366 | self.assertTrue(get_tag('foo') in tags) 367 | 368 | Tag.objects.update_tags(self.dead_parrot, 'ŠĐĆŽćžšđ') 369 | tags = Tag.objects.get_for_object(self.dead_parrot) 370 | self.assertEqual(len(tags), 1) 371 | self.assertEqual(tags[0].name, 'ŠĐĆŽćžšđ') 372 | 373 | Tag.objects.update_tags(self.dead_parrot, '你好') 374 | tags = Tag.objects.get_for_object(self.dead_parrot) 375 | self.assertEqual(len(tags), 1) 376 | self.assertEqual(tags[0].name, '你好') 377 | 378 | def test_unicode_tagged_object(self): 379 | self.dead_parrot.state = "dëad" 380 | self.dead_parrot.save() 381 | Tag.objects.update_tags(self.dead_parrot, 'föo') 382 | items = TaggedItem.objects.all() 383 | self.assertEqual(len(items), 1) 384 | self.assertEqual(str(items[0]), "dëad [föo]") 385 | 386 | def test_update_tags_with_none(self): 387 | # start off in a known, mildly interesting state 388 | Tag.objects.update_tags(self.dead_parrot, 'foo bar baz') 389 | tags = Tag.objects.get_for_object(self.dead_parrot) 390 | self.assertEqual(len(tags), 3) 391 | self.assertTrue(get_tag('bar') in tags) 392 | self.assertTrue(get_tag('baz') in tags) 393 | self.assertTrue(get_tag('foo') in tags) 394 | 395 | Tag.objects.update_tags(self.dead_parrot, None) 396 | tags = Tag.objects.get_for_object(self.dead_parrot) 397 | self.assertEqual(len(tags), 0) 398 | 399 | 400 | class TestModelTagField(TestCase): 401 | """ Test the 'tags' field on models. """ 402 | 403 | def test_create_with_tags_specified(self): 404 | f1 = FormTest.objects.create(tags='test3 test2 test1') 405 | tags = Tag.objects.get_for_object(f1) 406 | test1_tag = get_tag('test1') 407 | test2_tag = get_tag('test2') 408 | test3_tag = get_tag('test3') 409 | self.assertTrue(None not in (test1_tag, test2_tag, test3_tag)) 410 | self.assertEqual(len(tags), 3) 411 | self.assertTrue(test1_tag in tags) 412 | self.assertTrue(test2_tag in tags) 413 | self.assertTrue(test3_tag in tags) 414 | 415 | def test_update_via_tags_field(self): 416 | f1 = FormTest.objects.create(tags='test3 test2 test1') 417 | tags = Tag.objects.get_for_object(f1) 418 | test1_tag = get_tag('test1') 419 | test2_tag = get_tag('test2') 420 | test3_tag = get_tag('test3') 421 | self.assertTrue(None not in (test1_tag, test2_tag, test3_tag)) 422 | self.assertEqual(len(tags), 3) 423 | self.assertTrue(test1_tag in tags) 424 | self.assertTrue(test2_tag in tags) 425 | self.assertTrue(test3_tag in tags) 426 | 427 | f1.tags = 'test4' 428 | f1.save() 429 | tags = Tag.objects.get_for_object(f1) 430 | test4_tag = get_tag('test4') 431 | self.assertEqual(len(tags), 1) 432 | self.assertEqual(tags[0], test4_tag) 433 | 434 | f1.tags = '' 435 | f1.save() 436 | tags = Tag.objects.get_for_object(f1) 437 | self.assertEqual(len(tags), 0) 438 | 439 | def disabledtest_update_via_tags(self): 440 | # TODO: make this test working by reverting 441 | # https://github.com/Fantomas42/django-tagging/commit/bbc7f25ea471dd903f39e08684d84ce59081bdef 442 | f1 = FormTest.objects.create(tags='one two three') 443 | Tag.objects.get(name='three').delete() 444 | t2 = Tag.objects.get(name='two') 445 | t2.name = 'new' 446 | t2.save() 447 | f1again = FormTest.objects.get(pk=f1.pk) 448 | self.assertFalse('three' in f1again.tags) 449 | self.assertFalse('two' in f1again.tags) 450 | self.assertTrue('new' in f1again.tags) 451 | 452 | def test_creation_without_specifying_tags(self): 453 | f1 = FormTest() 454 | self.assertEqual(f1.tags, '') 455 | 456 | def test_creation_with_nullable_tags_field(self): 457 | f1 = FormTestNull() 458 | self.assertEqual(f1.tags, '') 459 | 460 | def test_fix_update_tag_field_deferred(self): 461 | """ 462 | Bug introduced in Django 1.10 463 | the TagField is considered "deferred" on Django 1.10 464 | because instance.__dict__ is not populated by the TagField 465 | instance, so it's excluded when updating a model instance. 466 | 467 | Note: this does not append if you only have one TagField 468 | in your model... 469 | """ 470 | f1 = FormMultipleFieldTest.objects.create(tagging_field='one two') 471 | self.assertEqual(f1.tagging_field, 'one two') 472 | tags = Tag.objects.get_for_object(f1) 473 | self.assertEqual(len(tags), 2) 474 | test1_tag = get_tag('one') 475 | test2_tag = get_tag('two') 476 | self.assertTrue(test1_tag in tags) 477 | self.assertTrue(test2_tag in tags) 478 | 479 | f1.tagging_field = f1.tagging_field + ' three' 480 | f1.save() 481 | self.assertEqual(f1.tagging_field, 'one two three') 482 | tags = Tag.objects.get_for_object(f1) 483 | self.assertEqual(len(tags), 3) 484 | test3_tag = get_tag('three') 485 | self.assertTrue(test3_tag in tags) 486 | 487 | f1again = FormMultipleFieldTest.objects.get(pk=f1.pk) 488 | self.assertEqual(f1again.tagging_field, 'one two three') 489 | 490 | tags = Tag.objects.get_for_object(f1again) 491 | self.assertEqual(len(tags), 3) 492 | 493 | 494 | class TestSettings(TestCase): 495 | def setUp(self): 496 | self.original_force_lower_case_tags = settings.FORCE_LOWERCASE_TAGS 497 | self.dead_parrot = Parrot.objects.create(state='dead') 498 | 499 | def tearDown(self): 500 | settings.FORCE_LOWERCASE_TAGS = self.original_force_lower_case_tags 501 | 502 | def test_force_lowercase_tags(self): 503 | """ Test forcing tags to lowercase. """ 504 | 505 | settings.FORCE_LOWERCASE_TAGS = True 506 | 507 | Tag.objects.update_tags(self.dead_parrot, 'foO bAr Ter') 508 | tags = Tag.objects.get_for_object(self.dead_parrot) 509 | self.assertEqual(len(tags), 3) 510 | foo_tag = get_tag('foo') 511 | bar_tag = get_tag('bar') 512 | ter_tag = get_tag('ter') 513 | self.assertTrue(foo_tag in tags) 514 | self.assertTrue(bar_tag in tags) 515 | self.assertTrue(ter_tag in tags) 516 | 517 | Tag.objects.update_tags(self.dead_parrot, 'foO bAr baZ') 518 | tags = Tag.objects.get_for_object(self.dead_parrot) 519 | baz_tag = get_tag('baz') 520 | self.assertEqual(len(tags), 3) 521 | self.assertTrue(bar_tag in tags) 522 | self.assertTrue(baz_tag in tags) 523 | self.assertTrue(foo_tag in tags) 524 | 525 | Tag.objects.add_tag(self.dead_parrot, 'FOO') 526 | tags = Tag.objects.get_for_object(self.dead_parrot) 527 | self.assertEqual(len(tags), 3) 528 | self.assertTrue(bar_tag in tags) 529 | self.assertTrue(baz_tag in tags) 530 | self.assertTrue(foo_tag in tags) 531 | 532 | Tag.objects.add_tag(self.dead_parrot, 'Zip') 533 | tags = Tag.objects.get_for_object(self.dead_parrot) 534 | self.assertEqual(len(tags), 4) 535 | zip_tag = get_tag('zip') 536 | self.assertTrue(bar_tag in tags) 537 | self.assertTrue(baz_tag in tags) 538 | self.assertTrue(foo_tag in tags) 539 | self.assertTrue(zip_tag in tags) 540 | 541 | f1 = FormTest.objects.create() 542 | f1.tags = 'TEST5' 543 | f1.save() 544 | tags = Tag.objects.get_for_object(f1) 545 | test5_tag = get_tag('test5') 546 | self.assertEqual(len(tags), 1) 547 | self.assertTrue(test5_tag in tags) 548 | self.assertEqual(f1.tags, 'test5') 549 | 550 | 551 | class TestTagUsageForModelBaseCase(TestCase): 552 | def test_tag_usage_for_model_empty(self): 553 | self.assertEqual(Tag.objects.usage_for_model(Parrot), []) 554 | 555 | 556 | class TestTagUsageForModel(TestCase): 557 | def setUp(self): 558 | parrot_details = ( 559 | ('pining for the fjords', 9, True, 'foo bar'), 560 | ('passed on', 6, False, 'bar baz ter'), 561 | ('no more', 4, True, 'foo ter'), 562 | ('late', 2, False, 'bar ter'), 563 | ) 564 | 565 | for state, perch_size, perch_smelly, tags in parrot_details: 566 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 567 | parrot = Parrot.objects.create(state=state, perch=perch) 568 | Tag.objects.update_tags(parrot, tags) 569 | 570 | def test_tag_usage_for_model(self): 571 | tag_usage = Tag.objects.usage_for_model(Parrot, counts=True) 572 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 573 | self.assertEqual(len(relevant_attribute_list), 4) 574 | self.assertTrue(('bar', 3) in relevant_attribute_list) 575 | self.assertTrue(('baz', 1) in relevant_attribute_list) 576 | self.assertTrue(('foo', 2) in relevant_attribute_list) 577 | self.assertTrue(('ter', 3) in relevant_attribute_list) 578 | 579 | def test_tag_usage_for_model_with_min_count(self): 580 | tag_usage = Tag.objects.usage_for_model(Parrot, min_count=2) 581 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 582 | self.assertEqual(len(relevant_attribute_list), 3) 583 | self.assertTrue(('bar', 3) in relevant_attribute_list) 584 | self.assertTrue(('foo', 2) in relevant_attribute_list) 585 | self.assertTrue(('ter', 3) in relevant_attribute_list) 586 | 587 | def test_tag_usage_with_filter_on_model_objects(self): 588 | tag_usage = Tag.objects.usage_for_model( 589 | Parrot, counts=True, filters=dict(state='no more')) 590 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 591 | self.assertEqual(len(relevant_attribute_list), 2) 592 | self.assertTrue(('foo', 1) in relevant_attribute_list) 593 | self.assertTrue(('ter', 1) in relevant_attribute_list) 594 | 595 | tag_usage = Tag.objects.usage_for_model( 596 | Parrot, counts=True, filters=dict(state__startswith='p')) 597 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 598 | self.assertEqual(len(relevant_attribute_list), 4) 599 | self.assertTrue(('bar', 2) in relevant_attribute_list) 600 | self.assertTrue(('baz', 1) in relevant_attribute_list) 601 | self.assertTrue(('foo', 1) in relevant_attribute_list) 602 | self.assertTrue(('ter', 1) in relevant_attribute_list) 603 | 604 | tag_usage = Tag.objects.usage_for_model( 605 | Parrot, counts=True, filters=dict(perch__size__gt=4)) 606 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 607 | self.assertEqual(len(relevant_attribute_list), 4) 608 | self.assertTrue(('bar', 2) in relevant_attribute_list) 609 | self.assertTrue(('baz', 1) in relevant_attribute_list) 610 | self.assertTrue(('foo', 1) in relevant_attribute_list) 611 | self.assertTrue(('ter', 1) in relevant_attribute_list) 612 | 613 | tag_usage = Tag.objects.usage_for_model( 614 | Parrot, counts=True, filters=dict(perch__smelly=True)) 615 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 616 | self.assertEqual(len(relevant_attribute_list), 3) 617 | self.assertTrue(('bar', 1) in relevant_attribute_list) 618 | self.assertTrue(('foo', 2) in relevant_attribute_list) 619 | self.assertTrue(('ter', 1) in relevant_attribute_list) 620 | 621 | tag_usage = Tag.objects.usage_for_model( 622 | Parrot, min_count=2, filters=dict(perch__smelly=True)) 623 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 624 | self.assertEqual(len(relevant_attribute_list), 1) 625 | self.assertTrue(('foo', 2) in relevant_attribute_list) 626 | 627 | tag_usage = Tag.objects.usage_for_model( 628 | Parrot, filters=dict(perch__size__gt=4)) 629 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) 630 | for tag in tag_usage] 631 | self.assertEqual(len(relevant_attribute_list), 4) 632 | self.assertTrue(('bar', False) in relevant_attribute_list) 633 | self.assertTrue(('baz', False) in relevant_attribute_list) 634 | self.assertTrue(('foo', False) in relevant_attribute_list) 635 | self.assertTrue(('ter', False) in relevant_attribute_list) 636 | 637 | tag_usage = Tag.objects.usage_for_model( 638 | Parrot, filters=dict(perch__size__gt=99)) 639 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) 640 | for tag in tag_usage] 641 | self.assertEqual(len(relevant_attribute_list), 0) 642 | 643 | 644 | class TestTagsRelatedForModel(TestCase): 645 | def setUp(self): 646 | parrot_details = ( 647 | ('pining for the fjords', 9, True, 'foo bar'), 648 | ('passed on', 6, False, 'bar baz ter'), 649 | ('no more', 4, True, 'foo ter'), 650 | ('late', 2, False, 'bar ter'), 651 | ) 652 | 653 | for state, perch_size, perch_smelly, tags in parrot_details: 654 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 655 | parrot = Parrot.objects.create(state=state, perch=perch) 656 | Tag.objects.update_tags(parrot, tags) 657 | 658 | def test_related_for_model_with_tag_query_sets_as_input(self): 659 | related_tags = Tag.objects.related_for_model( 660 | Tag.objects.filter(name__in=['bar']), Parrot, counts=True) 661 | relevant_attribute_list = [(tag.name, tag.count) 662 | for tag in related_tags] 663 | self.assertEqual(len(relevant_attribute_list), 3) 664 | self.assertTrue(('baz', 1) in relevant_attribute_list) 665 | self.assertTrue(('foo', 1) in relevant_attribute_list) 666 | self.assertTrue(('ter', 2) in relevant_attribute_list) 667 | 668 | related_tags = Tag.objects.related_for_model( 669 | Tag.objects.filter(name__in=['bar']), Parrot, min_count=2) 670 | relevant_attribute_list = [(tag.name, tag.count) 671 | for tag in related_tags] 672 | self.assertEqual(len(relevant_attribute_list), 1) 673 | self.assertTrue(('ter', 2) in relevant_attribute_list) 674 | 675 | related_tags = Tag.objects.related_for_model( 676 | Tag.objects.filter(name__in=['bar']), Parrot, counts=False) 677 | relevant_attribute_list = [tag.name for tag in related_tags] 678 | self.assertEqual(len(relevant_attribute_list), 3) 679 | self.assertTrue('baz' in relevant_attribute_list) 680 | self.assertTrue('foo' in relevant_attribute_list) 681 | self.assertTrue('ter' in relevant_attribute_list) 682 | 683 | related_tags = Tag.objects.related_for_model( 684 | Tag.objects.filter(name__in=['bar', 'ter']), Parrot, counts=True) 685 | relevant_attribute_list = [(tag.name, tag.count) 686 | for tag in related_tags] 687 | self.assertEqual(len(relevant_attribute_list), 1) 688 | self.assertTrue(('baz', 1) in relevant_attribute_list) 689 | 690 | related_tags = Tag.objects.related_for_model( 691 | Tag.objects.filter(name__in=['bar', 'ter', 'baz']), 692 | Parrot, counts=True) 693 | relevant_attribute_list = [(tag.name, tag.count) 694 | for tag in related_tags] 695 | self.assertEqual(len(relevant_attribute_list), 0) 696 | 697 | def test_related_for_model_with_tag_strings_as_input(self): 698 | # Once again, with feeling (strings) 699 | related_tags = Tag.objects.related_for_model( 700 | 'bar', Parrot, counts=True) 701 | relevant_attribute_list = [(tag.name, tag.count) 702 | for tag in related_tags] 703 | self.assertEqual(len(relevant_attribute_list), 3) 704 | self.assertTrue(('baz', 1) in relevant_attribute_list) 705 | self.assertTrue(('foo', 1) in relevant_attribute_list) 706 | self.assertTrue(('ter', 2) in relevant_attribute_list) 707 | 708 | related_tags = Tag.objects.related_for_model( 709 | 'bar', Parrot, min_count=2) 710 | relevant_attribute_list = [(tag.name, tag.count) 711 | for tag in related_tags] 712 | self.assertEqual(len(relevant_attribute_list), 1) 713 | self.assertTrue(('ter', 2) in relevant_attribute_list) 714 | 715 | related_tags = Tag.objects.related_for_model( 716 | 'bar', Parrot, counts=False) 717 | relevant_attribute_list = [tag.name for tag in related_tags] 718 | self.assertEqual(len(relevant_attribute_list), 3) 719 | self.assertTrue('baz' in relevant_attribute_list) 720 | self.assertTrue('foo' in relevant_attribute_list) 721 | self.assertTrue('ter' in relevant_attribute_list) 722 | 723 | related_tags = Tag.objects.related_for_model( 724 | ['bar', 'ter'], Parrot, counts=True) 725 | relevant_attribute_list = [(tag.name, tag.count) 726 | for tag in related_tags] 727 | self.assertEqual(len(relevant_attribute_list), 1) 728 | self.assertTrue(('baz', 1) in relevant_attribute_list) 729 | 730 | related_tags = Tag.objects.related_for_model( 731 | ['bar', 'ter', 'baz'], Parrot, counts=True) 732 | relevant_attribute_list = [(tag.name, tag.count) 733 | for tag in related_tags] 734 | self.assertEqual(len(relevant_attribute_list), 0) 735 | 736 | 737 | class TestTagCloudForModel(TestCase): 738 | def setUp(self): 739 | parrot_details = ( 740 | ('pining for the fjords', 9, True, 'foo bar'), 741 | ('passed on', 6, False, 'bar baz ter'), 742 | ('no more', 4, True, 'foo ter'), 743 | ('late', 2, False, 'bar ter'), 744 | ) 745 | 746 | for state, perch_size, perch_smelly, tags in parrot_details: 747 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 748 | parrot = Parrot.objects.create(state=state, perch=perch) 749 | Tag.objects.update_tags(parrot, tags) 750 | 751 | def test_tag_cloud_for_model(self): 752 | tag_cloud = Tag.objects.cloud_for_model(Parrot) 753 | relevant_attribute_list = [(tag.name, tag.count, tag.font_size) 754 | for tag in tag_cloud] 755 | self.assertEqual(len(relevant_attribute_list), 4) 756 | self.assertTrue(('bar', 3, 4) in relevant_attribute_list) 757 | self.assertTrue(('baz', 1, 1) in relevant_attribute_list) 758 | self.assertTrue(('foo', 2, 2) in relevant_attribute_list) 759 | self.assertTrue(('ter', 3, 4) in relevant_attribute_list) 760 | 761 | def test_tag_cloud_for_model_filters(self): 762 | tag_cloud = Tag.objects.cloud_for_model(Parrot, 763 | filters={'state': 'no more'}) 764 | relevant_attribute_list = [(tag.name, tag.count, tag.font_size) 765 | for tag in tag_cloud] 766 | self.assertEqual(len(relevant_attribute_list), 2) 767 | self.assertTrue(('foo', 1, 1) in relevant_attribute_list) 768 | self.assertTrue(('ter', 1, 1) in relevant_attribute_list) 769 | 770 | def test_tag_cloud_for_model_min_count(self): 771 | tag_cloud = Tag.objects.cloud_for_model(Parrot, min_count=2) 772 | relevant_attribute_list = [(tag.name, tag.count, tag.font_size) 773 | for tag in tag_cloud] 774 | self.assertEqual(len(relevant_attribute_list), 3) 775 | self.assertTrue(('bar', 3, 4) in relevant_attribute_list) 776 | self.assertTrue(('foo', 2, 1) in relevant_attribute_list) 777 | self.assertTrue(('ter', 3, 4) in relevant_attribute_list) 778 | 779 | 780 | class TestGetTaggedObjectsByModel(TestCase): 781 | def setUp(self): 782 | parrot_details = ( 783 | ('pining for the fjords', 9, True, 'foo bar'), 784 | ('passed on', 6, False, 'bar baz ter'), 785 | ('no more', 4, True, 'foo ter'), 786 | ('late', 2, False, 'bar ter'), 787 | ) 788 | 789 | for state, perch_size, perch_smelly, tags in parrot_details: 790 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 791 | parrot = Parrot.objects.create(state=state, perch=perch) 792 | Tag.objects.update_tags(parrot, tags) 793 | 794 | self.foo = Tag.objects.get(name='foo') 795 | self.bar = Tag.objects.get(name='bar') 796 | self.baz = Tag.objects.get(name='baz') 797 | self.ter = Tag.objects.get(name='ter') 798 | 799 | self.pining_for_the_fjords_parrot = Parrot.objects.get( 800 | state='pining for the fjords') 801 | self.passed_on_parrot = Parrot.objects.get(state='passed on') 802 | self.no_more_parrot = Parrot.objects.get(state='no more') 803 | self.late_parrot = Parrot.objects.get(state='late') 804 | 805 | def test_get_by_model_simple(self): 806 | parrots = TaggedItem.objects.get_by_model(Parrot, self.foo) 807 | self.assertEqual(len(parrots), 2) 808 | self.assertTrue(self.no_more_parrot in parrots) 809 | self.assertTrue(self.pining_for_the_fjords_parrot in parrots) 810 | 811 | parrots = TaggedItem.objects.get_by_model(Parrot, self.bar) 812 | self.assertEqual(len(parrots), 3) 813 | self.assertTrue(self.late_parrot in parrots) 814 | self.assertTrue(self.passed_on_parrot in parrots) 815 | self.assertTrue(self.pining_for_the_fjords_parrot in parrots) 816 | 817 | def test_get_by_model_intersection(self): 818 | parrots = TaggedItem.objects.get_by_model(Parrot, [self.foo, self.baz]) 819 | self.assertEqual(len(parrots), 0) 820 | 821 | parrots = TaggedItem.objects.get_by_model(Parrot, [self.foo, self.bar]) 822 | self.assertEqual(len(parrots), 1) 823 | self.assertTrue(self.pining_for_the_fjords_parrot in parrots) 824 | 825 | parrots = TaggedItem.objects.get_by_model(Parrot, [self.bar, self.ter]) 826 | self.assertEqual(len(parrots), 2) 827 | self.assertTrue(self.late_parrot in parrots) 828 | self.assertTrue(self.passed_on_parrot in parrots) 829 | 830 | # Issue 114 - Intersection with non-existant tags 831 | parrots = TaggedItem.objects.get_intersection_by_model(Parrot, []) 832 | self.assertEqual(len(parrots), 0) 833 | 834 | def test_get_by_model_with_tag_querysets_as_input(self): 835 | parrots = TaggedItem.objects.get_by_model( 836 | Parrot, Tag.objects.filter(name__in=['foo', 'baz'])) 837 | self.assertEqual(len(parrots), 0) 838 | 839 | parrots = TaggedItem.objects.get_by_model( 840 | Parrot, Tag.objects.filter(name__in=['foo', 'bar'])) 841 | self.assertEqual(len(parrots), 1) 842 | self.assertTrue(self.pining_for_the_fjords_parrot in parrots) 843 | 844 | parrots = TaggedItem.objects.get_by_model( 845 | Parrot, Tag.objects.filter(name__in=['bar', 'ter'])) 846 | self.assertEqual(len(parrots), 2) 847 | self.assertTrue(self.late_parrot in parrots) 848 | self.assertTrue(self.passed_on_parrot in parrots) 849 | 850 | def test_get_by_model_with_strings_as_input(self): 851 | parrots = TaggedItem.objects.get_by_model(Parrot, 'foo baz') 852 | self.assertEqual(len(parrots), 0) 853 | 854 | parrots = TaggedItem.objects.get_by_model(Parrot, 'foo bar') 855 | self.assertEqual(len(parrots), 1) 856 | self.assertTrue(self.pining_for_the_fjords_parrot in parrots) 857 | 858 | parrots = TaggedItem.objects.get_by_model(Parrot, 'bar ter') 859 | self.assertEqual(len(parrots), 2) 860 | self.assertTrue(self.late_parrot in parrots) 861 | self.assertTrue(self.passed_on_parrot in parrots) 862 | 863 | def test_get_by_model_with_lists_of_strings_as_input(self): 864 | parrots = TaggedItem.objects.get_by_model(Parrot, ['foo', 'baz']) 865 | self.assertEqual(len(parrots), 0) 866 | 867 | parrots = TaggedItem.objects.get_by_model(Parrot, ['foo', 'bar']) 868 | self.assertEqual(len(parrots), 1) 869 | self.assertTrue(self.pining_for_the_fjords_parrot in parrots) 870 | 871 | parrots = TaggedItem.objects.get_by_model(Parrot, ['bar', 'ter']) 872 | self.assertEqual(len(parrots), 2) 873 | self.assertTrue(self.late_parrot in parrots) 874 | self.assertTrue(self.passed_on_parrot in parrots) 875 | 876 | def test_get_by_nonexistent_tag(self): 877 | # Issue 50 - Get by non-existent tag 878 | parrots = TaggedItem.objects.get_by_model(Parrot, 'argatrons') 879 | self.assertEqual(len(parrots), 0) 880 | 881 | def test_get_union_by_model(self): 882 | parrots = TaggedItem.objects.get_union_by_model(Parrot, ['foo', 'ter']) 883 | self.assertEqual(len(parrots), 4) 884 | self.assertTrue(self.late_parrot in parrots) 885 | self.assertTrue(self.no_more_parrot in parrots) 886 | self.assertTrue(self.passed_on_parrot in parrots) 887 | self.assertTrue(self.pining_for_the_fjords_parrot in parrots) 888 | 889 | parrots = TaggedItem.objects.get_union_by_model(Parrot, ['bar', 'baz']) 890 | self.assertEqual(len(parrots), 3) 891 | self.assertTrue(self.late_parrot in parrots) 892 | self.assertTrue(self.passed_on_parrot in parrots) 893 | self.assertTrue(self.pining_for_the_fjords_parrot in parrots) 894 | 895 | # Issue 114 - Union with non-existant tags 896 | parrots = TaggedItem.objects.get_union_by_model(Parrot, []) 897 | self.assertEqual(len(parrots), 0) 898 | parrots = TaggedItem.objects.get_union_by_model(Parrot, ['albert']) 899 | self.assertEqual(len(parrots), 0) 900 | 901 | Tag.objects.create(name='titi') 902 | parrots = TaggedItem.objects.get_union_by_model(Parrot, ['titi']) 903 | self.assertEqual(len(parrots), 0) 904 | 905 | 906 | class TestGetRelatedTaggedItems(TestCase): 907 | def setUp(self): 908 | parrot_details = ( 909 | ('pining for the fjords', 9, True, 'foo bar'), 910 | ('passed on', 6, False, 'bar baz ter'), 911 | ('no more', 4, True, 'foo ter'), 912 | ('late', 2, False, 'bar ter'), 913 | ) 914 | 915 | for state, perch_size, perch_smelly, tags in parrot_details: 916 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 917 | parrot = Parrot.objects.create(state=state, perch=perch) 918 | Tag.objects.update_tags(parrot, tags) 919 | 920 | self.l1 = Link.objects.create(name='link 1') 921 | Tag.objects.update_tags(self.l1, 'tag1 tag2 tag3 tag4 tag5') 922 | self.l2 = Link.objects.create(name='link 2') 923 | Tag.objects.update_tags(self.l2, 'tag1 tag2 tag3') 924 | self.l3 = Link.objects.create(name='link 3') 925 | Tag.objects.update_tags(self.l3, 'tag1') 926 | self.l4 = Link.objects.create(name='link 4') 927 | 928 | self.a1 = Article.objects.create(name='article 1') 929 | Tag.objects.update_tags(self.a1, 'tag1 tag2 tag3 tag4') 930 | 931 | def test_get_related_objects_of_same_model(self): 932 | related_objects = TaggedItem.objects.get_related(self.l1, Link) 933 | self.assertEqual(len(related_objects), 2) 934 | self.assertTrue(self.l2 in related_objects) 935 | self.assertTrue(self.l3 in related_objects) 936 | 937 | related_objects = TaggedItem.objects.get_related(self.l4, Link) 938 | self.assertEqual(len(related_objects), 0) 939 | 940 | def test_get_related_objects_of_same_model_limited_number_of_results(self): 941 | # This fails on Oracle because it has no support for a 'LIMIT' clause. 942 | # See http://bit.ly/1AYNEsa 943 | 944 | # ask for no more than 1 result 945 | related_objects = TaggedItem.objects.get_related(self.l1, Link, num=1) 946 | self.assertEqual(len(related_objects), 1) 947 | self.assertTrue(self.l2 in related_objects) 948 | 949 | def test_get_related_objects_of_same_model_limit_related_items(self): 950 | related_objects = TaggedItem.objects.get_related( 951 | self.l1, Link.objects.exclude(name='link 3')) 952 | self.assertEqual(len(related_objects), 1) 953 | self.assertTrue(self.l2 in related_objects) 954 | 955 | def test_get_related_objects_of_different_model(self): 956 | related_objects = TaggedItem.objects.get_related(self.a1, Link) 957 | self.assertEqual(len(related_objects), 3) 958 | self.assertTrue(self.l1 in related_objects) 959 | self.assertTrue(self.l2 in related_objects) 960 | self.assertTrue(self.l3 in related_objects) 961 | 962 | Tag.objects.update_tags(self.a1, 'tag6') 963 | related_objects = TaggedItem.objects.get_related(self.a1, Link) 964 | self.assertEqual(len(related_objects), 0) 965 | 966 | 967 | class TestTagUsageForQuerySet(TestCase): 968 | def setUp(self): 969 | parrot_details = ( 970 | ('pining for the fjords', 9, True, 'foo bar'), 971 | ('passed on', 6, False, 'bar baz ter'), 972 | ('no more', 4, True, 'foo ter'), 973 | ('late', 2, False, 'bar ter'), 974 | ) 975 | 976 | for state, perch_size, perch_smelly, tags in parrot_details: 977 | perch = Perch.objects.create(size=perch_size, smelly=perch_smelly) 978 | parrot = Parrot.objects.create(state=state, perch=perch) 979 | Tag.objects.update_tags(parrot, tags) 980 | 981 | def test_tag_usage_for_queryset(self): 982 | tag_usage = Tag.objects.usage_for_queryset( 983 | Parrot.objects.filter(state='no more'), counts=True) 984 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 985 | self.assertEqual(len(relevant_attribute_list), 2) 986 | self.assertTrue(('foo', 1) in relevant_attribute_list) 987 | self.assertTrue(('ter', 1) in relevant_attribute_list) 988 | 989 | tag_usage = Tag.objects.usage_for_queryset( 990 | Parrot.objects.filter(state__startswith='p'), counts=True) 991 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 992 | self.assertEqual(len(relevant_attribute_list), 4) 993 | self.assertTrue(('bar', 2) in relevant_attribute_list) 994 | self.assertTrue(('baz', 1) in relevant_attribute_list) 995 | self.assertTrue(('foo', 1) in relevant_attribute_list) 996 | self.assertTrue(('ter', 1) in relevant_attribute_list) 997 | 998 | tag_usage = Tag.objects.usage_for_queryset( 999 | Parrot.objects.filter(perch__size__gt=4), counts=True) 1000 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 1001 | self.assertEqual(len(relevant_attribute_list), 4) 1002 | self.assertTrue(('bar', 2) in relevant_attribute_list) 1003 | self.assertTrue(('baz', 1) in relevant_attribute_list) 1004 | self.assertTrue(('foo', 1) in relevant_attribute_list) 1005 | self.assertTrue(('ter', 1) in relevant_attribute_list) 1006 | 1007 | tag_usage = Tag.objects.usage_for_queryset( 1008 | Parrot.objects.filter(perch__smelly=True), counts=True) 1009 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 1010 | self.assertEqual(len(relevant_attribute_list), 3) 1011 | self.assertTrue(('bar', 1) in relevant_attribute_list) 1012 | self.assertTrue(('foo', 2) in relevant_attribute_list) 1013 | self.assertTrue(('ter', 1) in relevant_attribute_list) 1014 | 1015 | tag_usage = Tag.objects.usage_for_queryset( 1016 | Parrot.objects.filter(perch__smelly=True), min_count=2) 1017 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 1018 | self.assertEqual(len(relevant_attribute_list), 1) 1019 | self.assertTrue(('foo', 2) in relevant_attribute_list) 1020 | 1021 | tag_usage = Tag.objects.usage_for_queryset( 1022 | Parrot.objects.filter(perch__size__gt=4)) 1023 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) 1024 | for tag in tag_usage] 1025 | self.assertEqual(len(relevant_attribute_list), 4) 1026 | self.assertTrue(('bar', False) in relevant_attribute_list) 1027 | self.assertTrue(('baz', False) in relevant_attribute_list) 1028 | self.assertTrue(('foo', False) in relevant_attribute_list) 1029 | self.assertTrue(('ter', False) in relevant_attribute_list) 1030 | 1031 | tag_usage = Tag.objects.usage_for_queryset( 1032 | Parrot.objects.filter(perch__size__gt=99)) 1033 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) 1034 | for tag in tag_usage] 1035 | self.assertEqual(len(relevant_attribute_list), 0) 1036 | 1037 | tag_usage = Tag.objects.usage_for_queryset( 1038 | Parrot.objects.filter(Q(perch__size__gt=6) | 1039 | Q(state__startswith='l')), counts=True) 1040 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 1041 | self.assertEqual(len(relevant_attribute_list), 3) 1042 | self.assertTrue(('bar', 2) in relevant_attribute_list) 1043 | self.assertTrue(('foo', 1) in relevant_attribute_list) 1044 | self.assertTrue(('ter', 1) in relevant_attribute_list) 1045 | 1046 | tag_usage = Tag.objects.usage_for_queryset( 1047 | Parrot.objects.filter(Q(perch__size__gt=6) | 1048 | Q(state__startswith='l')), min_count=2) 1049 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 1050 | self.assertEqual(len(relevant_attribute_list), 1) 1051 | self.assertTrue(('bar', 2) in relevant_attribute_list) 1052 | 1053 | tag_usage = Tag.objects.usage_for_queryset( 1054 | Parrot.objects.filter(Q(perch__size__gt=6) | 1055 | Q(state__startswith='l'))) 1056 | relevant_attribute_list = [(tag.name, hasattr(tag, 'counts')) 1057 | for tag in tag_usage] 1058 | self.assertEqual(len(relevant_attribute_list), 3) 1059 | self.assertTrue(('bar', False) in relevant_attribute_list) 1060 | self.assertTrue(('foo', False) in relevant_attribute_list) 1061 | self.assertTrue(('ter', False) in relevant_attribute_list) 1062 | 1063 | tag_usage = Tag.objects.usage_for_queryset( 1064 | Parrot.objects.exclude(state='passed on'), counts=True) 1065 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 1066 | self.assertEqual(len(relevant_attribute_list), 3) 1067 | self.assertTrue(('bar', 2) in relevant_attribute_list) 1068 | self.assertTrue(('foo', 2) in relevant_attribute_list) 1069 | self.assertTrue(('ter', 2) in relevant_attribute_list) 1070 | 1071 | tag_usage = Tag.objects.usage_for_queryset( 1072 | Parrot.objects.exclude(state__startswith='p'), min_count=2) 1073 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 1074 | self.assertEqual(len(relevant_attribute_list), 1) 1075 | self.assertTrue(('ter', 2) in relevant_attribute_list) 1076 | 1077 | tag_usage = Tag.objects.usage_for_queryset( 1078 | Parrot.objects.exclude(Q(perch__size__gt=6) | 1079 | Q(perch__smelly=False)), counts=True) 1080 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 1081 | self.assertEqual(len(relevant_attribute_list), 2) 1082 | self.assertTrue(('foo', 1) in relevant_attribute_list) 1083 | self.assertTrue(('ter', 1) in relevant_attribute_list) 1084 | 1085 | tag_usage = Tag.objects.usage_for_queryset( 1086 | Parrot.objects.exclude(perch__smelly=True).filter( 1087 | state__startswith='l'), counts=True) 1088 | relevant_attribute_list = [(tag.name, tag.count) for tag in tag_usage] 1089 | self.assertEqual(len(relevant_attribute_list), 2) 1090 | self.assertTrue(('bar', 1) in relevant_attribute_list) 1091 | self.assertTrue(('ter', 1) in relevant_attribute_list) 1092 | 1093 | 1094 | ################ 1095 | # Model Fields # 1096 | ################ 1097 | 1098 | class TestTagFieldInForms(TestCase): 1099 | def test_tag_field_in_modelform(self): 1100 | # Ensure that automatically created forms use TagField 1101 | class TestForm(forms.ModelForm): 1102 | class Meta: 1103 | model = FormTest 1104 | fields = forms.ALL_FIELDS 1105 | 1106 | form = TestForm() 1107 | self.assertEqual(form.fields['tags'].__class__.__name__, 'TagField') 1108 | 1109 | def test_recreation_of_tag_list_string_representations(self): 1110 | plain = Tag.objects.create(name='plain') 1111 | spaces = Tag.objects.create(name='spa ces') 1112 | comma = Tag.objects.create(name='com,ma') 1113 | self.assertEqual(edit_string_for_tags([plain]), 'plain') 1114 | self.assertEqual(edit_string_for_tags([spaces]), '"spa ces"') 1115 | self.assertEqual(edit_string_for_tags([plain, spaces]), 1116 | 'plain, spa ces') 1117 | self.assertEqual(edit_string_for_tags([plain, spaces, comma]), 1118 | 'plain, spa ces, "com,ma"') 1119 | self.assertEqual(edit_string_for_tags([plain, comma]), 1120 | 'plain "com,ma"') 1121 | self.assertEqual(edit_string_for_tags([comma, spaces]), 1122 | '"com,ma", spa ces') 1123 | 1124 | def test_tag_d_validation(self): 1125 | t = TagField(required=False) 1126 | self.assertEqual(t.clean(''), '') 1127 | self.assertEqual(t.clean('foo'), 'foo') 1128 | self.assertEqual(t.clean('foo bar baz'), 'foo bar baz') 1129 | self.assertEqual(t.clean('foo,bar,baz'), 'foo,bar,baz') 1130 | self.assertEqual(t.clean('foo, bar, baz'), 'foo, bar, baz') 1131 | self.assertEqual( 1132 | t.clean('foo qwertyuiopasdfghjklzxcvbnm' 1133 | 'qwertyuiopasdfghjklzxcvb bar'), 1134 | 'foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvb bar') 1135 | self.assertRaises( 1136 | forms.ValidationError, t.clean, 1137 | 'foo qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbn bar') 1138 | 1139 | def test_tag_get_from_model(self): 1140 | FormTest.objects.create(tags='test3 test2 test1') 1141 | FormTest.objects.create(tags='toto titi') 1142 | self.assertEqual(FormTest.tags, 'test1 test2 test3 titi toto') 1143 | 1144 | 1145 | ######### 1146 | # Forms # 1147 | ######### 1148 | 1149 | 1150 | class TestTagAdminForm(TestCase): 1151 | 1152 | def test_clean_name(self): 1153 | datas = {'name': 'tag'} 1154 | form = TagAdminForm(datas) 1155 | self.assertTrue(form.is_valid()) 1156 | 1157 | def test_clean_name_multi(self): 1158 | datas = {'name': 'tag error'} 1159 | form = TagAdminForm(datas) 1160 | self.assertFalse(form.is_valid()) 1161 | 1162 | def test_clean_name_too_long(self): 1163 | datas = {'name': 't' * (settings.MAX_TAG_LENGTH + 1)} 1164 | form = TagAdminForm(datas) 1165 | self.assertFalse(form.is_valid()) 1166 | 1167 | ######### 1168 | # Views # 1169 | ######### 1170 | 1171 | 1172 | @override_settings( 1173 | ROOT_URLCONF='tagging.tests.urls', 1174 | TEMPLATES=[ 1175 | { 1176 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 1177 | 'OPTIONS': { 1178 | 'loaders': ('tagging.tests.utils.VoidLoader',) 1179 | } 1180 | } 1181 | ] 1182 | ) 1183 | class TestTaggedObjectList(TestCase): 1184 | 1185 | def setUp(self): 1186 | self.a1 = Article.objects.create(name='article 1') 1187 | self.a2 = Article.objects.create(name='article 2') 1188 | Tag.objects.update_tags(self.a1, 'static tag test') 1189 | Tag.objects.update_tags(self.a2, 'static test') 1190 | 1191 | def get_view(self, url, queries=1, code=200, 1192 | expected_items=1, 1193 | friendly_context='article_list', 1194 | template='tests/article_list.html'): 1195 | with self.assertNumQueries(queries): 1196 | response = self.client.get(url) 1197 | self.assertEqual(response.status_code, code) 1198 | 1199 | if code == 200: 1200 | self.assertTrue(isinstance(response.context['tag'], Tag)) 1201 | self.assertEqual(len(response.context['object_list']), 1202 | expected_items) 1203 | self.assertEqual(response.context['object_list'], 1204 | response.context[friendly_context]) 1205 | self.assertTemplateUsed(response, template) 1206 | return response 1207 | 1208 | def test_view_static(self): 1209 | self.get_view('/static/', expected_items=2) 1210 | 1211 | def test_view_dynamic(self): 1212 | self.get_view('/tag/', expected_items=1) 1213 | 1214 | def test_view_related(self): 1215 | response = self.get_view('/static/related/', 1216 | queries=2, expected_items=2) 1217 | self.assertEqual(len(response.context['related_tags']), 2) 1218 | 1219 | def test_view_no_queryset_no_model(self): 1220 | self.assertRaises(ImproperlyConfigured, self.get_view, 1221 | '/no-query-no-model/') 1222 | 1223 | def test_view_no_tag(self): 1224 | self.assertRaises(AttributeError, self.get_view, '/no-tag/') 1225 | 1226 | def test_view_404(self): 1227 | self.get_view('/unavailable/', code=404) 1228 | --------------------------------------------------------------------------------