Commit 31cbafbb by Alex Gaynor

Merge branch 'api-refactor'

parents f3657783 17ebe7b3
......@@ -56,7 +56,7 @@ Django ORM API. For example if you had a ``Food`` model, whose
``TaggableManager`` was named ``tags``, you could find all the delicious fruit
like so::
>>> Food.objects.filter(tags__in=["delicious"])
>>> Food.objects.filter(tags__name__in=["delicious"])
[<Food: apple>, <Food: pear>, <Food: plum>]
......@@ -64,7 +64,10 @@ If you're filtering on multiple tags, it's very common to get duplicate
results, because of the way relational databases work. Often you'll want to
make use of the ``distinct()`` method on ``QuerySets``::
>>> Food.objects.filter(tags__in=["delicious", "red"])
>>> Food.objects.filter(tags__name__in=["delicious", "red"])
[<Food: apple>, <Food: apple>]
>>> Food.objects.filter(tags__in=["delicious", "red"]).distinct()
>>> Food.objects.filter(tags__name__in=["delicious", "red"]).distinct()
[<Food: apple>]
You can also filter by the slug on tags. If you're using a custom ``Tag``
model you can use this API to filter on any fields it has.
......@@ -10,6 +10,10 @@ Unreleased.
* Added an index on the ``object_id`` field of ``TaggedItem``.
* When displaying tags always join them with commas, never spaces.
* The docs are now available `online <http://django-taggit.readthedocs.org/>`_.
* Custom ``Tag`` models are now allowed.
* *Backwards incompatible* Filtering on tags is no longer
``filter(tags__in=["foo"])``, it is written
``filter(tags__name__in=["foo"])``.
0.8.0
~~~~~
......
Using a Custom Tag or Through Model
===================================
By default ``django-taggit`` uses a "through model" with a
``GenericForeignKey`` on it, that has another ``ForeignKey`` to an included
``Tag`` model. However, there are some cases where this isn't desirable, for
example if you want the speed and referential guarantees of a real
``ForeignKey``, if you have a model with a non-integer primary key, or if you
want to store additional data about a tag, such as whether it is official. In
these cases ``django-taggit`` makes it easy to substitute your own through
model, or ``Tag`` model.
Your intermediary model must be a subclass of
``taggit.models.TaggedItemBase`` with a foreign key to your content
model named ``content_object``. Pass this intermediary model as the
``through`` argument to ``TaggableManager``::
from django.db import models
from taggit.managers import TaggableManager
from taggit.models import TaggedItemBase
class TaggedFood(TaggedItemBase):
content_object = models.ForeignKey('Food')
class Food(models.Model):
# ... fields here
tags = TaggableManager(through=TaggedFood)
Once this is done, the API works the same as for GFK-tagged models.
To change the behavior in other ways there are a number of other classes you
can subclass to obtain different behavior:
========================= ===========================================================
Class name Behavior
========================= ===========================================================
``TaggedItemBase`` Allows custom ``ForeignKeys`` to models.
``GenericTaggedItemBase`` Allows custom ``Tag`` models.
``ItemBase`` Allows custom ``Tag`` models and ``ForeignKeys`` to models.
========================= ===========================================================
When providing a custom ``Tag`` model it should be a ``ForeignKey`` to your tag model named ``"tag"``.
Using a Custom Through Model
============================
By default ``django-taggit`` uses a "through model" with a
``GenericForeignKey`` on it. However, there are some cases where this
isn't desirable, for example if you want the speed and referential
guarantees of a real ``ForeignKey``, or if you have a model with a
non-integer primary key. In these cases ``django-taggit`` makes it
easy to substitute your own through model.
Youe intermediary model must be a subclass of
``taggit.models.TaggedItemBase`` with a foreign key to your content
model named ``content_object``. Pass this intermediary model as the
``through`` argument to ``TaggableManager``::
from django.db import models
from taggit.managers import TaggableManager
from taggit.models import TaggedItemBase
class TaggedFood(TaggedItemBase):
content_object = models.ForeignKey('Food')
class Food(models.Model):
# ... fields here
tags = TaggableManager(through=TaggedFood)
Once this is done, the API works the same as for GFK-tagged models.
......@@ -13,7 +13,7 @@ for known issues with older versions of Django), and Python 2.4-2.X.
getting_started
forms
api
custom_through
custom_tagging
issues
changelog
......
......@@ -24,7 +24,7 @@ def runtests(*test_args):
test_args = ['tests', 'suggest']
parent = dirname(abspath(__file__))
sys.path.insert(0, parent)
failures = run_tests(test_args, verbosity=1, interactive=True)
failures = run_tests(test_args, verbosity=1, interactive=True, failfast=True)
sys.exit(failures)
......
......@@ -9,7 +9,7 @@ from taggit.models import Tag
class SuggestCase(TestCase):
def test_simple_suggest(self):
ku_tag = Tag.objects.create(name='ku')
ku_keyword1 = TagKeyword.objects.create(
TagKeyword.objects.create(
tag=ku_tag,
keyword='kansas university'
)
......@@ -31,7 +31,7 @@ class SuggestCase(TestCase):
def test_bad_regex(self):
ku_tag = Tag.objects.create(name='ku')
ku_keyword1 = TagKeyword.objects.create(
TagKeyword.objects.create(
tag=ku_tag,
keyword='kansas university'
)
......
import re
from django.conf import settings
from taggit.contrib.suggest.models import TagKeyword, TagRegex
from taggit.models import Tag
......
import django
from django.contrib.contenttypes.generic import GenericForeignKey
from django.contrib.contenttypes.generic import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models.fields.related import ManyToManyRel
from django.db.models.related import RelatedObject
from django.db.models.query_utils import QueryWrapper
from django.utils.translation import ugettext_lazy as _
from taggit.forms import TagField
from taggit.models import Tag, TaggedItem
from taggit.models import Tag, TaggedItem, GenericTaggedItemBase
from taggit.utils import require_instance_manager
......@@ -39,9 +37,9 @@ class TaggableRel(ManyToManyRel):
class TaggableManager(object):
def __init__(self, verbose_name=_("Tags"), through=None):
self.use_gfk = through is None
self.use_gfk = through is None or issubclass(through, GenericTaggedItemBase)
self.through = through or TaggedItem
self.rel = TaggableRel(to=self.through)
self.rel = TaggableRel(to=self.through._meta.get_field("tag").rel.to)
self.verbose_name = verbose_name
self.editable = True
self.unique = False
......@@ -67,46 +65,18 @@ class TaggableManager(object):
self.model = cls
cls._meta.add_field(self)
setattr(cls, name, self)
if self.use_gfk:
tagged_items = GenericRelation(self.through)
tagged_items.contribute_to_class(cls, "tagged_items")
def save_form_data(self, instance, value):
getattr(instance, self.name).set(*value)
def get_prep_lookup(self, lookup_type, value):
if lookup_type not in ["in", "isnull"]:
# Users really shouldn't do "isnull" lookups, again: the ORM can,
# you can't.
raise ValueError("You can't do lookups other than \"in\" on Tags: "
"__%s=%s" % (lookup_type, value))
if hasattr(value, 'prepare'):
return value.prepare()
if hasattr(value, '_prepare'):
return value._prepare()
if lookup_type == "in":
if all(isinstance(v, Tag) for v in value):
value = self.through.objects.filter(tag__in=value)
elif all(isinstance(v, basestring) for v in value):
value = self.through.objects.filter(tag__name__in=value)
elif all(isinstance(v, (int, long)) for v in value):
# This one is really ackward, just don't do it. The ORM does
# it for deletes, but no one else gets to.
return value
else:
# Fucking flip-floppers.
raise ValueError("You can't combine Tag objects and strings. '%s' "
"was provided." % value)
if hasattr(models.Field, "get_prep_lookup"):
return models.Field().get_prep_lookup(lookup_type, value)
return models.Field().get_db_prep_lookup(lookup_type, value)
return models.Field().get_prep_lookup(lookup_type, value)
if django.VERSION < (1, 2):
get_db_prep_lookup = get_prep_lookup
else:
def get_db_prep_lookup(self, lookup_type, value, connection, prepared=False):
if not prepared:
return self.get_prep_lookup(lookup_type, value)
return models.Field().get_db_prep_lookup(lookup_type, value, connection=connection, prepared=True)
def get_db_prep_lookup(self, *args, **kwargs):
return models.Field().get_db_prep_lookup(*args, **kwargs)
def formfield(self, form_class=TagField, **kwargs):
defaults = {
......@@ -122,12 +92,10 @@ class TaggableManager(object):
return self.through.objects.none()
def related_query_name(self):
return self.model._meta.object_name.lower()
return self.model._meta.module_name
def m2m_reverse_name(self):
if self.use_gfk:
return "id"
return self.through._meta.pk.column
return self.through._meta.get_field_by_name("tag")[0].column
def m2m_column_name(self):
if self.use_gfk:
......@@ -143,7 +111,7 @@ class TaggableManager(object):
def extra_filters(self, pieces, pos, negate):
if negate or not self.use_gfk:
return []
prefix = "__".join(pieces[:pos+1])
prefix = "__".join(["tagged_items"] + pieces[:pos-2])
cts = map(ContentType.objects.get_for_model, _get_subclasses(self.model))
if len(cts) == 1:
return [("%s__content_type" % prefix, cts[0])]
......@@ -163,8 +131,8 @@ class _TaggableManager(models.Manager):
@require_instance_manager
def add(self, *tags):
for tag in tags:
if not isinstance(tag, Tag):
tag, _ = Tag.objects.get_or_create(name=tag)
if not isinstance(tag, self.through.tag_model()):
tag, _ = self.through.tag_model().objects.get_or_create(name=tag)
self.through.objects.get_or_create(tag=tag, **self._lookup_kwargs())
@require_instance_manager
......
......@@ -6,7 +6,7 @@ from django.template.defaultfilters import slugify
from django.utils.translation import ugettext_lazy as _, ugettext
class Tag(models.Model):
class TagBase(models.Model):
name = models.CharField(verbose_name=_('Name'), max_length=100)
slug = models.SlugField(verbose_name=_('Slug'), unique=True, max_length=100)
......@@ -14,8 +14,7 @@ class Tag(models.Model):
return self.name
class Meta:
verbose_name = _("Tag")
verbose_name_plural = _("Tags")
abstract = True
def save(self, *args, **kwargs):
if not self.pk and not self.slug:
......@@ -35,7 +34,7 @@ class Tag(models.Model):
while True:
try:
sid = transaction.savepoint(**trans_kwargs)
res = super(Tag, self).save(*args, **kwargs)
res = super(TagBase, self).save(*args, **kwargs)
transaction.savepoint_commit(sid, **trans_kwargs)
return res
except IntegrityError:
......@@ -43,15 +42,16 @@ class Tag(models.Model):
i += 1
self.slug = "%s_%d" % (slug, i)
else:
return super(Tag, self).save(*args, **kwargs)
return super(TagBase, self).save(*args, **kwargs)
class Tag(TagBase):
class Meta:
verbose_name = _("Tag")
verbose_name_plural = _("Tags")
class TaggedItemBase(models.Model):
if django.VERSION < (1, 2):
tag = models.ForeignKey(Tag, related_name="%(class)s_items")
else:
tag = models.ForeignKey(Tag, related_name="%(app_label)s_%(class)s_items")
class ItemBase(models.Model):
def __unicode__(self):
return ugettext("%(object)s tagged with %(tag)s") % {
"object": self.content_object,
......@@ -62,6 +62,10 @@ class TaggedItemBase(models.Model):
abstract = True
@classmethod
def tag_model(cls):
return cls._meta.get_field_by_name("tag")[0].rel.to
@classmethod
def tag_relname(cls):
return cls._meta.get_field_by_name('tag')[0].rel.related_name
......@@ -71,27 +75,46 @@ class TaggedItemBase(models.Model):
'content_object': instance
}
class TaggedItemBase(ItemBase):
if django.VERSION < (1, 2):
tag = models.ForeignKey(Tag, related_name="%(class)s_items")
else:
tag = models.ForeignKey(Tag, related_name="%(app_label)s_%(class)s_items")
class Meta:
abstract = True
@classmethod
def tags_for(cls, model, instance=None):
if instance is not None:
return Tag.objects.filter(**{
return cls.tag_model().objects.filter(**{
'%s__content_object' % cls.tag_relname(): instance
})
return Tag.objects.filter(**{
return cls.tag_model().objects.filter(**{
'%s__content_object__isnull' % cls.tag_relname(): False
}).distinct()
class TaggedItem(TaggedItemBase):
class GenericTaggedItemBase(ItemBase):
object_id = models.IntegerField(verbose_name=_('Object id'), db_index=True)
content_type = models.ForeignKey(ContentType, verbose_name=_('Content type'),
related_name="tagged_items")
if django.VERSION < (1, 2):
content_type = models.ForeignKey(
ContentType,
verbose_name=_('Content type'),
related_name="%(class)s_tagged_items"
)
else:
content_type = models.ForeignKey(
ContentType,
verbose_name=_('Content type'),
related_name="%(app_label)s_%(class)s_tagged_items"
)
content_object = GenericForeignKey()
class Meta:
verbose_name = _("Tagged Item")
verbose_name_plural = _("Tagged Items")
abstract=True
@classmethod
def lookup_kwargs(cls, instance):
return {
......@@ -102,12 +125,16 @@ class TaggedItem(TaggedItemBase):
@classmethod
def tags_for(cls, model, instance=None):
ct = ContentType.objects.get_for_model(model)
kwargs = {
"%s__content_type" % cls.tag_relname(): ct
}
if instance is not None:
return Tag.objects.filter(**{
'%s__object_id' % cls.tag_relname(): instance.pk,
'%s__content_type' % cls.tag_relname(): ct
})
return Tag.objects.filter(**{
'%s__content_type' % cls.tag_relname(): ct
}).distinct()
kwargs["%s__object_id" % cls.tag_relname()] = instance.pk
return cls.tag_model().objects.filter(**kwargs).distinct()
class TaggedItem(GenericTaggedItemBase, TaggedItemBase):
class Meta:
verbose_name = _("Tagged Item")
verbose_name_plural = _("Tagged Items")
from django import forms
from taggit.tests.models import Food, DirectFood, CustomPKFood
from taggit.tests.models import Food, DirectFood, CustomPKFood, OfficialFood
class FoodForm(forms.ModelForm):
......@@ -14,3 +14,7 @@ class DirectFoodForm(forms.ModelForm):
class CustomPKFoodForm(forms.ModelForm):
class Meta:
model = CustomPKFood
class OfficialFoodForm(forms.ModelForm):
class Meta:
model = OfficialFood
from django.db import models
from taggit.managers import TaggableManager
from taggit.models import TaggedItemBase
from taggit.models import TaggedItemBase, GenericTaggedItemBase, TagBase
class Food(models.Model):
name = models.CharField(max_length=50)
......@@ -19,23 +20,23 @@ class Pet(models.Model):
def __unicode__(self):
return self.name
class HousePet(Pet):
trained = models.BooleanField()
# test direct-tagging with custom through model
# Test direct-tagging with custom through model
class TaggedFood(TaggedItemBase):
content_object = models.ForeignKey('DirectFood')
class TaggedPet(TaggedItemBase):
content_object = models.ForeignKey('DirectPet')
class DirectFood(models.Model):
name = models.CharField(max_length=50)
tags = TaggableManager(through=TaggedFood)
class TaggedPet(TaggedItemBase):
content_object = models.ForeignKey('DirectPet')
class DirectPet(models.Model):
name = models.CharField(max_length=50)
......@@ -43,24 +44,27 @@ class DirectPet(models.Model):
def __unicode__(self):
return self.name
class DirectHousePet(DirectPet):
trained = models.BooleanField()
# test custom through model to model with custom PK
# Test custom through model to model with custom PK
class TaggedCustomPKFood(TaggedItemBase):
content_object = models.ForeignKey('CustomPKFood')
class TaggedCustomPKPet(TaggedItemBase):
content_object = models.ForeignKey('CustomPKPet')
class CustomPKFood(models.Model):
name = models.CharField(max_length=50, primary_key=True)
tags = TaggableManager(through=TaggedCustomPKFood)
class TaggedCustomPKPet(TaggedItemBase):
content_object = models.ForeignKey('CustomPKPet')
def __unicode__(self):
return self.name
class CustomPKPet(models.Model):
name = models.CharField(max_length=50, primary_key=True)
......@@ -71,3 +75,31 @@ class CustomPKPet(models.Model):
class CustomPKHousePet(CustomPKPet):
trained = models.BooleanField()
# Test custom through model to a custom tag model
class OfficialTag(TagBase):
official = models.BooleanField()
class OfficialThroughModel(GenericTaggedItemBase):
tag = models.ForeignKey(OfficialTag, related_name="tagged_items")
class OfficialFood(models.Model):
name = models.CharField(max_length=50)
tags = TaggableManager(through=OfficialThroughModel)
def __unicode__(self):
return self.name
class OfficialPet(models.Model):
name = models.CharField(max_length=50)
tags = TaggableManager(through=OfficialThroughModel)
def __unicode__(self):
return self.name
class OfficialHousePet(OfficialPet):
trained = models.BooleanField()
......@@ -3,10 +3,12 @@ from unittest import TestCase as UnitTestCase
from django.test import TestCase, TransactionTestCase
from taggit.models import Tag, TaggedItem
from taggit.tests.forms import FoodForm, DirectFoodForm, CustomPKFoodForm
from taggit.tests.forms import (FoodForm, DirectFoodForm, CustomPKFoodForm,
OfficialFoodForm)
from taggit.tests.models import (Food, Pet, HousePet, DirectFood, DirectPet,
DirectHousePet, TaggedPet, CustomPKFood, CustomPKPet, CustomPKHousePet,
TaggedCustomPKPet)
TaggedCustomPKPet, OfficialFood, OfficialPet, OfficialHousePet,
OfficialThroughModel, OfficialTag)
from taggit.utils import parse_tags, edit_string_for_tags
......@@ -27,23 +29,39 @@ class BaseTaggingTransactionTestCase(TransactionTestCase, BaseTaggingTest):
class TagModelTestCase(BaseTaggingTransactionTestCase):
food_model = Food
tag_model = Tag
def test_unique_slug(self):
apple = self.food_model.objects.create(name="apple")
apple.tags.add("Red", "red")
def test_update(self):
special = self.tag_model.objects.create(name="special")
special.save()
def test_add(self):
apple = self.food_model.objects.create(name="apple")
yummy = self.tag_model.objects.create(name="yummy")
apple.tags.add(yummy)
class TagModelDirectTestCase(TagModelTestCase):
food_model = DirectFood
tag_model = Tag
class TagModelCustomPKTestCase(TagModelTestCase):
food_model = CustomPKFood
tag_model = Tag
class TagModelOfficialTestCase(TagModelTestCase):
food_model = OfficialFood
tag_model = OfficialTag
class TaggableManagerTestCase(BaseTaggingTestCase):
food_model = Food
pet_model = Pet
housepet_model = HousePet
taggeditem_model = TaggedItem
tag_model = Tag
def test_add_tag(self):
apple = self.food_model.objects.create(name="apple")
......@@ -72,7 +90,7 @@ class TaggableManagerTestCase(BaseTaggingTestCase):
apple.tags.remove('green')
self.assert_tags_equal(apple.tags.all(), ['red'])
self.assert_tags_equal(self.food_model.tags.all(), ['green', 'red'])
tag = Tag.objects.create(name="delicious")
tag = self.tag_model.objects.create(name="delicious")
apple.tags.add(tag)
self.assert_tags_equal(apple.tags.all(), ["red", "delicious"])
......@@ -108,13 +126,13 @@ class TaggableManagerTestCase(BaseTaggingTestCase):
apple.tags.add("red", "green")
pear = self.food_model.objects.create(name="pear")
pear.tags.add("green")
self.assertEqual(
list(self.food_model.objects.filter(tags__in=["red"])),
list(self.food_model.objects.filter(tags__name__in=["red"])),
[apple]
)
self.assertEqual(
list(self.food_model.objects.filter(tags__in=["green"])),
list(self.food_model.objects.filter(tags__name__in=["green"])),
[apple, pear]
)
......@@ -123,18 +141,18 @@ class TaggableManagerTestCase(BaseTaggingTestCase):
dog = self.pet_model.objects.create(name="dog")
dog.tags.add("woof", "red")
self.assertEqual(
list(self.food_model.objects.filter(tags__in=["red"]).distinct()),
list(self.food_model.objects.filter(tags__name__in=["red"]).distinct()),
[apple]
)
tag = Tag.objects.get(name="woof")
self.assertEqual(list(self.pet_model.objects.filter(tags__in=[tag])), [dog])
tag = self.tag_model.objects.get(name="woof")
self.assertEqual(list(self.pet_model.objects.filter(tags__name__in=[tag])), [dog])
cat = self.housepet_model.objects.create(name="cat", trained=True)
cat.tags.add("fuzzy")
self.assertEqual(
map(lambda o: o.pk, self.pet_model.objects.filter(tags__in=["fuzzy"])),
map(lambda o: o.pk, self.pet_model.objects.filter(tags__name__in=["fuzzy"])),
[kitty.pk, cat.pk]
)
......@@ -148,7 +166,7 @@ class TaggableManagerTestCase(BaseTaggingTestCase):
guava = self.food_model.objects.create(name="guava")
self.assertEqual(
map(lambda o: o.pk, self.food_model.objects.exclude(tags__in=["red"])),
map(lambda o: o.pk, self.food_model.objects.exclude(tags__name__in=["red"])),
[pear.pk, guava.pk],
)
......@@ -177,9 +195,11 @@ class TaggableManagerTestCase(BaseTaggingTestCase):
spike = self.pet_model.objects.create(name='Spike')
spot.tags.add('scary')
spike.tags.add('fluffy')
lookup_kwargs = {'%s__name' % (self.pet_model._meta.object_name.lower()): 'Spot'}
lookup_kwargs = {
'%s__name' % self.pet_model._meta.module_name: 'Spot'
}
self.assert_tags_equal(
[i.tag for i in self.taggeditem_model.objects.filter(**lookup_kwargs)],
self.tag_model.objects.filter(**lookup_kwargs),
['scary']
)
......@@ -211,6 +231,28 @@ class TaggableManagerCustomPKTestCase(TaggableManagerTestCase):
# tell if the instance is saved or not
pass
class TaggableManagerOfficialTestCase(TaggableManagerTestCase):
food_model = OfficialFood
pet_model = OfficialPet
housepet_model = OfficialHousePet
taggeditem_model = OfficialThroughModel
tag_model = OfficialTag
def test_extra_fields(self):
self.tag_model.objects.create(name="red")
self.tag_model.objects.create(name="delicious", official=True)
apple = self.food_model.objects.create(name="apple")
apple.tags.add("delicious", "red")
pear = self.food_model.objects.create(name="Pear")
pear.tags.add("delicious")
self.assertEqual(
map(lambda o: o.pk, self.food_model.objects.filter(tags__official=False)),
[apple.pk],
)
class TaggableFormTestCase(BaseTaggingTestCase):
form_class = FoodForm
food_model = Food
......@@ -254,6 +296,10 @@ class TaggableFormCustomPKTestCase(TaggableFormTestCase):
form_class = CustomPKFoodForm
food_model = CustomPKFood
class TaggableFormOfficialTestCase(TaggableFormTestCase):
form_class = OfficialFoodForm
food_model = OfficialFood
class TagStringParseTestCase(UnitTestCase):
"""
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment