From e14350fbf3ce1b4f5f3e531dc7a79187b9152f88 Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Wed, 1 Feb 2017 16:59:13 +0200 Subject: [PATCH] Started work on LocalizedMagicSlugField This will be a superior version of LocalizedAutoSlugField, but one that doesn't have concurrency issues and takes advantage of the new UNIQUE CONSTRAINTs. --- localized_fields/__init__.py | 3 +- localized_fields/fields/__init__.py | 4 +- .../fields/localized_autoslug_field.py | 7 +- .../fields/localized_magicslug_field.py | 65 +++++++ tests/fake_model.py | 28 ++-- tests/test_localized_auto_slug_field.py | 94 ----------- tests/test_localized_model.py | 11 +- tests/test_localized_slug_fields.py | 158 ++++++++++++++++++ 8 files changed, 250 insertions(+), 120 deletions(-) create mode 100644 localized_fields/fields/localized_magicslug_field.py delete mode 100644 tests/test_localized_auto_slug_field.py create mode 100644 tests/test_localized_slug_fields.py diff --git a/localized_fields/__init__.py b/localized_fields/__init__.py index 59bcb62..60842a2 100644 --- a/localized_fields/__init__.py +++ b/localized_fields/__init__.py @@ -1,7 +1,7 @@ from .util import get_language_codes from .forms import LocalizedFieldForm, LocalizedFieldWidget from .fields import (LocalizedField, LocalizedBleachField, - LocalizedAutoSlugField) + LocalizedAutoSlugField, LocalizedMagicSlugField) from .localized_value import LocalizedValue from .models import LocalizedModel @@ -10,6 +10,7 @@ __all__ = [ 'LocalizedField', 'LocalizedValue', 'LocalizedAutoSlugField', + 'LocalizedMagicSlugField', 'LocalizedBleachField', 'LocalizedFieldWidget', 'LocalizedFieldForm', diff --git a/localized_fields/fields/__init__.py b/localized_fields/fields/__init__.py index c259c5e..b83087c 100644 --- a/localized_fields/fields/__init__.py +++ b/localized_fields/fields/__init__.py @@ -1,10 +1,12 @@ from .localized_field import LocalizedField from .localized_autoslug_field import LocalizedAutoSlugField +from .localized_magicslug_field import LocalizedMagicSlugField from .localized_bleach_field import LocalizedBleachField __all__ = [ 'LocalizedField', 'LocalizedAutoSlugField', - 'LocalizedBleachField' + 'LocalizedMagicSlugField', + 'LocalizedBleachField', ] diff --git a/localized_fields/fields/localized_autoslug_field.py b/localized_fields/fields/localized_autoslug_field.py index 60394ea..3cf9d5e 100644 --- a/localized_fields/fields/localized_autoslug_field.py +++ b/localized_fields/fields/localized_autoslug_field.py @@ -9,9 +9,8 @@ from ..localized_value import LocalizedValue class LocalizedAutoSlugField(LocalizedField): - """Custom version of :see:AutoSlugField that - can operate on :see:LocalizedField and provides - unique slugs for every language.""" + """Automatically provides slugs for a localized + field upon saving.""" def __init__(self, *args, **kwargs): """Initializes a new instance of :see:LocalizedAutoSlugField.""" @@ -29,8 +28,8 @@ class LocalizedAutoSlugField(LocalizedField): name, path, args, kwargs = super( LocalizedAutoSlugField, self).deconstruct() - kwargs['populate_from'] = self.populate_from + kwargs['populate_from'] = self.populate_from return name, path, args, kwargs def formfield(self, **kwargs): diff --git a/localized_fields/fields/localized_magicslug_field.py b/localized_fields/fields/localized_magicslug_field.py new file mode 100644 index 0000000..0140e01 --- /dev/null +++ b/localized_fields/fields/localized_magicslug_field.py @@ -0,0 +1,65 @@ +from django.conf import settings +from django.utils.text import slugify + +from ..localized_value import LocalizedValue +from .localized_autoslug_field import LocalizedAutoSlugField +from ..util import get_language_codes + + +class LocalizedMagicSlugField(LocalizedAutoSlugField): + """Automatically provides slugs for a localized + field upon saving." + + An improved version of :see:LocalizedAutoSlugField, + which adds: + + - Concurrency safety + - Improved performance + + When in doubt, use this over :see:LocalizedAutoSlugField. + """ + + def __init__(self, *args, **kwargs): + """Initializes a new instance of :see:LocalizedMagicSlugField.""" + + self.populate_from = kwargs.pop('populate_from') + kwargs['uniqueness'] = kwargs.pop('uniqueness', get_language_codes()) + + super(LocalizedAutoSlugField, self).__init__( + *args, + **kwargs + ) + + def pre_save(self, instance, add: bool): + """Ran just before the model is saved, allows us to built + the slug. + + Arguments: + instance: + The model that is being saved. + + add: + Indicates whether this is a new entry + to the database or an update. + + Returns: + The localized slug that was generated. + """ + + slugs = LocalizedValue() + + for lang_code, _ in settings.LANGUAGES: + value = self._get_populate_from_value( + instance, + self.populate_from, + lang_code + ) + + if not value: + continue + + slug = slugify(value, allow_unicode=True) + slugs.set(lang_code, slug) + + setattr(instance, self.name, slugs) + return slugs diff --git a/tests/fake_model.py b/tests/fake_model.py index bdaf23e..383af97 100644 --- a/tests/fake_model.py +++ b/tests/fake_model.py @@ -1,28 +1,23 @@ from django.contrib.postgres.operations import HStoreExtension from django.db import connection, migrations from django.db.migrations.executor import MigrationExecutor +import sys from localized_fields import (LocalizedAutoSlugField, LocalizedField, - LocalizedModel) - -MODEL = None + LocalizedModel, LocalizedMagicSlugField) -def get_fake_model(): +def get_fake_model(name='TestModel', fields={}): """Creates a fake model to use during unit tests.""" - global MODEL + attributes = { + 'app_label': 'localized_fields', + '__module__': __name__, + '__name__': name + } - if MODEL: - return MODEL - - class TestModel(LocalizedModel): - """Model used for testing the :see:LocalizedAutoSlugField.""" - - app_label = 'localized_fields' - - title = LocalizedField() - slug = LocalizedAutoSlugField(populate_from='title') + attributes.update(fields) + TestModel = type(name, (LocalizedModel,), attributes) class TestProject: @@ -43,5 +38,4 @@ def get_fake_model(): schema_editor.create_model(TestModel) - MODEL = TestModel - return MODEL + return TestModel diff --git a/tests/test_localized_auto_slug_field.py b/tests/test_localized_auto_slug_field.py deleted file mode 100644 index d1de675..0000000 --- a/tests/test_localized_auto_slug_field.py +++ /dev/null @@ -1,94 +0,0 @@ -from django import forms -from django.conf import settings -from django.test import TestCase -from django.utils.text import slugify - -from localized_fields import LocalizedAutoSlugField - -from .fake_model import get_fake_model - - -class LocalizedAutoSlugFieldTestCase(TestCase): - """Tests the :see:LocalizedAutoSlugField class.""" - - TestModel = None - - @classmethod - def setUpClass(cls): - """Creates the test model in the database.""" - - super(LocalizedAutoSlugFieldTestCase, cls).setUpClass() - - cls.TestModel = get_fake_model() - - def test_populate(self): - """Tests whether the :see:LocalizedAutoSlugField's - populating feature works correctly.""" - - obj = self.TestModel() - obj.title.en = 'this is my title' - obj.save() - - assert obj.slug.get('en') == slugify(obj.title.en) - - def test_populate_multiple_languages(self): - """Tests whether the :see:LocalizedAutoSlugField's - populating feature correctly works for all languages.""" - - obj = self.TestModel() - - for lang_code, lang_name in settings.LANGUAGES: - obj.title.set(lang_code, 'title %s' % lang_name) - - obj.save() - - for lang_code, lang_name in settings.LANGUAGES: - assert obj.slug.get(lang_code) == 'title-%s' % lang_name.lower() - - def test_unique_slug(self): - """Tests whether the :see:LocalizedAutoSlugField - correctly generates unique slugs.""" - - obj = self.TestModel() - obj.title.en = 'title' - obj.save() - - another_obj = self.TestModel() - another_obj.title.en = 'title' - another_obj.save() - - assert another_obj.slug.en == 'title-1' - - def test_unique_slug_utf(self): - """Tests whether generating a slug works - when the value consists completely out - of non-ASCII characters.""" - - obj = self.TestModel() - obj.title.en = 'مكاتب للايجار بشارع بورسعيد' - obj.save() - - assert obj.slug.en == 'مكاتب-للايجار-بشارع-بورسعيد' - - @staticmethod - def test_deconstruct(): - """Tests whether the :see:deconstruct - function properly retains options - specified in the constructor.""" - - field = LocalizedAutoSlugField(populate_from='title') - _, _, _, kwargs = field.deconstruct() - - assert 'populate_from' in kwargs - assert kwargs['populate_from'] == field.populate_from - - @staticmethod - def test_formfield(): - """Tests whether the :see:formfield method - returns a valid form field that is hidden.""" - - field = LocalizedAutoSlugField(populate_from='title') - form_field = field.formfield() - - assert isinstance(form_field, forms.CharField) - assert isinstance(form_field.widget, forms.HiddenInput) diff --git a/tests/test_localized_model.py b/tests/test_localized_model.py index 962c41a..908684c 100644 --- a/tests/test_localized_model.py +++ b/tests/test_localized_model.py @@ -1,6 +1,6 @@ from django.test import TestCase -from localized_fields import LocalizedValue +from localized_fields import LocalizedField, LocalizedValue from .fake_model import get_fake_model @@ -16,7 +16,12 @@ class LocalizedModelTestCase(TestCase): super(LocalizedModelTestCase, cls).setUpClass() - cls.TestModel = get_fake_model() + cls.TestModel = get_fake_model( + 'LocalizedModelTestCase', + { + 'title': LocalizedField() + } + ) @classmethod def test_defaults(cls): @@ -46,4 +51,4 @@ class LocalizedModelTestCase(TestCase): assert isinstance(obj.title, LocalizedValue) assert obj.title.en == 'english_title' assert obj.title.ro == 'romanian_title' - assert obj.title.nl == 'dutch_title' \ No newline at end of file + assert obj.title.nl == 'dutch_title' diff --git a/tests/test_localized_slug_fields.py b/tests/test_localized_slug_fields.py new file mode 100644 index 0000000..6b13f21 --- /dev/null +++ b/tests/test_localized_slug_fields.py @@ -0,0 +1,158 @@ +from django import forms +from django.conf import settings +from django.test import TestCase +from django.utils.text import slugify + +from localized_fields import LocalizedField, LocalizedAutoSlugField, LocalizedMagicSlugField + +from .fake_model import get_fake_model + + +class LocalizedSlugFieldTestCase(TestCase): + """Tests the localized slug classes.""" + + AutoSlugModel = None + MagicSlugModel = None + + @classmethod + def setUpClass(cls): + """Creates the test models in the database.""" + + super(LocalizedSlugFieldTestCase, cls).setUpClass() + + cls.AutoSlugModel = get_fake_model( + 'LocalizedAutoSlugFieldTestModel', + { + 'title': LocalizedField(), + 'slug': LocalizedAutoSlugField(populate_from='title') + } + ) + + cls.MagicSlugModel = get_fake_model( + 'LocalizedMagicSlugFieldTestModel', + { + 'title': LocalizedField(), + 'slug': LocalizedMagicSlugField(populate_from='title') + } + ) + + @classmethod + def test_populate_auto(cls): + cls._test_populate(cls.AutoSlugModel) + + @classmethod + def test_populate_magic(cls): + cls._test_populate(cls.MagicSlugModel) + + @classmethod + def test_populate_multiple_languages_auto(cls): + cls._test_populate_multiple_languages(cls.AutoSlugModel) + + @classmethod + def test_populate_multiple_languages_magic(cls): + cls._test_populate_multiple_languages(cls.MagicSlugModel) + + @classmethod + def test_unique_slug_auto(cls): + cls._test_unique_slug(cls.AutoSlugModel) + + @classmethod + def test_unique_slug_magic(cls): + cls._test_unique_slug(cls.MagicSlugModel) + + @classmethod + def test_unique_slug_utf_auto(cls): + cls._test_unique_slug_utf(cls.AutoSlugModel) + + @classmethod + def test_unique_slug_utf_magic(cls): + cls._test_unique_slug_utf(cls.MagicSlugModel) + + @classmethod + def test_deconstruct_auto(cls): + cls._test_deconstruct(LocalizedAutoSlugField) + cls._test_deconstruct(LocalizedMagicSlugField) + + @classmethod + def test_deconstruct_magic(cls): + cls._test_deconstruct(LocalizedMagicSlugField) + + @classmethod + def test_formfield_auto(cls): + cls._test_formfield(LocalizedAutoSlugField) + + @classmethod + def test_formfield_magic(cls): + cls._test_formfield(LocalizedMagicSlugField) + + @staticmethod + def _test_populate(model): + """Tests whether the populating feature works correctly.""" + + obj = model() + obj.title.en = 'this is my title' + obj.save() + + assert obj.slug.get('en') == slugify(obj.title.en) + + @staticmethod + def _test_populate_multiple_languages(model): + """Tests whether the populating feature correctly + works for all languages.""" + + obj = model() + for lang_code, lang_name in settings.LANGUAGES: + obj.title.set(lang_code, 'title %s' % lang_name) + + obj.save() + + for lang_code, lang_name in settings.LANGUAGES: + assert obj.slug.get(lang_code) == 'title-%s' % lang_name.lower() + + @staticmethod + def _test_unique_slug(model): + """Tests whether unique slugs are properly generated.""" + + obj = model() + obj.title.en = 'title' + obj.save() + + another_obj = model() + another_obj.title.en = 'title' + another_obj.save() + + assert another_obj.slug.en == 'title-1' + + @staticmethod + def _test_unique_slug_utf(model): + """Tests whether generating a slug works + when the value consists completely out + of non-ASCII characters.""" + + obj = model() + obj.title.en = 'مكاتب للايجار بشارع بورسعيد' + obj.save() + + assert obj.slug.en == 'مكاتب-للايجار-بشارع-بورسعيد' + + @staticmethod + def _test_deconstruct(field_type): + """Tests whether the :see:deconstruct + function properly retains options + specified in the constructor.""" + + field = field_type(populate_from='title') + _, _, _, kwargs = field.deconstruct() + + assert 'populate_from' in kwargs + assert kwargs['populate_from'] == field.populate_from + + @staticmethod + def _test_formfield(field_type): + """Tests whether the :see:formfield method + returns a valid form field that is hidden.""" + + form_field = field_type(populate_from='title').formfield() + + assert isinstance(form_field, forms.CharField) + assert isinstance(form_field.widget, forms.HiddenInput)