Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

708 - Add Natural Key Support to Tag model. #905

Merged
merged 10 commits into from
Jul 25, 2024
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Changelog
We believe that this should not cause a noticable performance change, and the number of queries involved should not change.
* Add Django 5.0 support (no code changes were needed, but now we test this release).
* Add Python 3.12 support
* Add support for dumpdata/loaddata using natural keys

5.0.1 (2023-10-26)
~~~~~~~~~~~~~~~~~~
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ tagging to your project easy and fun.
forms
admin
serializers
testing
api
faq
custom_tagging
Expand Down
14 changes: 14 additions & 0 deletions docs/testing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Testing
=======

Natural Key Support
-------------------
We have added `natural key support <https://docs.djangoproject.com/en/5.0/topics/serialization/#natural-keys>`_ to the Tag model in the Django taggit library. This allows you to identify objects by human-readable identifiers rather than by their database ID::

python manage.py dumpdata taggit.Tag --natural-foreign --natural-primary > tags.json

python manage.py loaddata tags.json

By default tags use the name field as the natural key.

You can customize this in your own custom tag model by setting the ``natural_key_fields`` property on your model the required fields.
29 changes: 27 additions & 2 deletions taggit/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,25 @@ def unidecode(tag):
return tag


class TagBase(models.Model):
class NaturalKeyManager(models.Manager):
def get_by_natural_key(self, *args):
if len(args) != len(self.model.natural_key_fields):
raise ValueError(
"Number of arguments does not match number of natural key fields."
)
lookup_kwargs = dict(zip(self.model.natural_key_fields, args))
return self.get(**lookup_kwargs)


class NaturalKeyModel(models.Model):
def natural_key(self):
return [getattr(self, field) for field in self.natural_key_fields]

class Meta:
abstract = True


class TagBase(NaturalKeyModel):
name = models.CharField(
verbose_name=pgettext_lazy("A tag name", "name"), unique=True, max_length=100
)
Expand All @@ -26,6 +44,9 @@ class TagBase(models.Model):
allow_unicode=True,
)

natural_key_fields = ["name"]
objects = NaturalKeyManager()

def __str__(self):
return self.name

Expand Down Expand Up @@ -91,13 +112,15 @@ class Meta:
app_label = "taggit"


class ItemBase(models.Model):
class ItemBase(NaturalKeyModel):
def __str__(self):
return gettext("%(object)s tagged with %(tag)s") % {
"object": self.content_object,
"tag": self.tag,
}

objects = NaturalKeyManager()

class Meta:
abstract = True

Expand Down Expand Up @@ -170,13 +193,15 @@ def tags_for(cls, model, instance=None, **extra_filters):

class GenericTaggedItemBase(CommonGenericTaggedItemBase):
object_id = models.IntegerField(verbose_name=_("object ID"), db_index=True)
natural_key_fields = ["object_id"]

class Meta:
abstract = True


class GenericUUIDTaggedItemBase(CommonGenericTaggedItemBase):
object_id = models.UUIDField(verbose_name=_("object ID"), db_index=True)
natural_key_fields = ["object_id"]

class Meta:
abstract = True
Expand Down
109 changes: 109 additions & 0 deletions tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from io import StringIO
from unittest import mock

Expand Down Expand Up @@ -1398,3 +1399,111 @@ def test_tests_have_no_pending_migrations(self):
out = StringIO()
call_command("makemigrations", "tests", dry_run=True, stdout=out)
self.assertEqual(out.getvalue().strip(), "No changes detected in app 'tests'")


class NaturalKeyTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.tag_names = ["circle", "square", "triangle", "rectangle", "pentagon"]
cls.filename = "test_data_dump.json"
cls.tag_count = len(cls.tag_names)

def setUp(self):
self.tags = self._create_tags()

def tearDown(self):
self._clear_existing_tags()
try:
os.remove(self.filename)
except FileNotFoundError:
pass

@property
def _queryset(self):
return Tag.objects.filter(name__in=self.tag_names)

def _create_tags(self):
return Tag.objects.bulk_create(
[Tag(name=shape, slug=shape) for shape in self.tag_names],
ignore_conflicts=True,
)

def _clear_existing_tags(self):
self._queryset.delete()

def _dump_model(self, model):
model_label = model._meta.label
with open(self.filename, "w") as f:
call_command(
"dumpdata",
model_label,
natural_primary=True,
use_natural_foreign_keys=True,
stdout=f,
)

def _load_model(self):
call_command("loaddata", self.filename)

def test_tag_natural_key(self):
"""
Test that tags can be dumped and loaded using natural keys.
"""

# confirm count in the DB
self.assertEqual(self._queryset.count(), self.tag_count)

# dump all tags to a file
self._dump_model(Tag)

# Delete all tags
self._clear_existing_tags()

# confirm all tags clear
self.assertEqual(self._queryset.count(), 0)

# load the tags from the file
self._load_model()

# confirm count in the DB
self.assertEqual(self._queryset.count(), self.tag_count)

def test_tag_reloading_with_changed_pk(self):
"""Test that tags are not reliant on the primary key of the tag model.

Test that data is correctly loaded after database state has changed.

"""
original_shape = self._queryset.first()
original_pk = original_shape.pk
original_shape_name = original_shape.name
new_shape_name = "hexagon"

# dump all tags to a file
self._dump_model(Tag)

# Delete the tag
self._clear_existing_tags()

# create new tag with the same PK
Tag.objects.create(name=new_shape_name, slug=new_shape_name, pk=original_pk)

# Load the tags from the file
self._load_model()

# confirm that load did not overwrite the new_shape
self.assertEqual(Tag.objects.get(pk=original_pk).name, new_shape_name)

# confirm that the original shape was reloaded with a different PK
self.assertNotEqual(Tag.objects.get(name=original_shape_name).pk, original_pk)

def test_get_by_natural_key(self):
# Test retrieval of tags by their natural key
for name in self.tag_names:
tag = Tag.objects.get_by_natural_key(name)
self.assertEqual(tag.name, name)

def test_wrong_number_of_args(self):
# Test that get_by_natural_key raises an error when the wrong number of args is passed
with self.assertRaises(ValueError):
Tag.objects.get_by_natural_key()
Loading