Added initial implementation + tests

This commit is contained in:
Swen Kooij 2016-10-21 12:21:11 +03:00
parent d33fecb490
commit 612b3bf427
18 changed files with 1030 additions and 2 deletions

5
.coveragerc Normal file
View File

@ -0,0 +1,5 @@
[run]
include = localized_fields/*
omit = *migrations*, *tests*
plugins =
django_coverage_plugin

4
.gitignore vendored
View File

@ -4,3 +4,7 @@ env/
# Ignore Python byte code cache
*.pyc
__pycache__
# Ignore coverage reports
.coverage
htmlcov

View File

@ -0,0 +1,12 @@
from .localized_field import LocalizedField
from .localized_value import LocalizedValue
from .localized_autoslug_field import LocalizedAutoSlugField
from .localized_bleach_field import LocalizedBleachField
__all__ = [
'LocalizedField',
'LocalizedValue',
'LocalizedAutoSlugField',
'LocalizedBleachField'
]

View File

@ -0,0 +1,116 @@
from typing import Callable
from django.conf import settings
from django.utils.text import slugify
from .localized_field import LocalizedField
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."""
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedAutoSlugField."""
self.populate_from = kwargs.pop('populate_from', None)
super(LocalizedAutoSlugField, self).__init__(*args, **kwargs)
def deconstruct(self):
"""Deconstructs the field into something the database
can store."""
name, path, args, kwargs = super(
LocalizedAutoSlugField, self).deconstruct()
kwargs['populate_from'] = self.populate_from
return name, path, 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.
"""
slugs = LocalizedValue()
for lang_code, _ in settings.LANGUAGES:
value = self._get_populate_from_value(
instance,
self.populate_from,
lang_code
)
if not value:
continue
def is_unique(slug: str, language: str) -> bool:
"""Gets whether the specified slug is unique."""
unique_filter = {
'%s__%s__contains' % (self.name, language): slug
}
return not type(instance).objects.filter(**unique_filter).exists()
slug = self._make_unique_slug(slugify(value), lang_code, is_unique)
slugs.set(lang_code, slug)
setattr(instance, self.name, slugs)
return slugs
@staticmethod
def _make_unique_slug(slug: str, language: str, is_unique: Callable[[str], bool]) -> str:
"""Guarentees that the specified slug is unique by appending
a number until it is unique.
Arguments:
slug:
The slug to make unique.
is_unique:
Function that can be called to verify
whether the generate slug is unique.
Returns:
A guarenteed unique slug.
"""
index = 1
unique_slug = slug
while not is_unique(unique_slug, language):
unique_slug = '%s-%d' % (slug, index)
index += 1
return unique_slug
@staticmethod
def _get_populate_from_value(instance, field_name: str, language: str):
"""Gets the value to create a slug from in the specified language.
Arguments:
instance:
The model that the field resides on.
field_name:
The name of the field to generate a slug for.
language:
The language to generate the slug for.
Returns:
The text to generate a slug for.
"""
value = getattr(instance, field_name, None)
return value.get(language)

View File

@ -0,0 +1,39 @@
from django.conf import settings
from django_bleach.utils import get_bleach_default_options
import bleach
from .localized_field import LocalizedField
class LocalizedBleachField(LocalizedField):
"""Custom version of :see:BleachField that
is actually a :see:LocalizedField."""
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.
"""
localized_value = getattr(instance, self.attname)
if not localized_value:
return None
for lang_code, _ in settings.LANGUAGES:
value = localized_value.get(lang_code)
if not value:
continue
localized_value.set(
lang_code,
bleach.clean(value, get_bleach_default_options())
)
return localized_value

View File

@ -0,0 +1,159 @@
from django.conf import settings
from django.contrib.postgres.fields import HStoreField
from django.db.utils import IntegrityError
from ..forms import LocalizedFieldForm
from .localized_value import LocalizedValue
class LocalizedField(HStoreField):
"""A field that has the same value in multiple languages.
Internally this is stored as a :see:HStoreField where there
is a key for every language."""
Meta = None
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedValue."""
super(LocalizedField, self).__init__(*args, **kwargs)
@staticmethod
def from_db_value(value, *_):
"""Turns the specified database value into its Python
equivalent.
Arguments:
value:
The value that is stored in the database and
needs to be converted to its Python equivalent.
Returns:
A :see:LocalizedValue instance containing the
data extracted from the database.
"""
if not value:
return LocalizedValue()
return LocalizedValue(value)
def to_python(self, value: dict) -> LocalizedValue:
"""Turns the specified database value into its Python
equivalent.
Arguments:
value:
The value that is stored in the database and
needs to be converted to its Python equivalent.
Returns:
A :see:LocalizedValue instance containing the
data extracted from the database.
"""
if not value or not isinstance(value, dict):
return LocalizedValue()
return LocalizedValue(value)
def get_prep_value(self, value: LocalizedValue) -> dict:
"""Turns the specified value into something the database
can store.
If an illegal value (non-LocalizedValue instance) is
specified, we'll treat it as an empty :see:LocalizedValue
instance, on which the validation will fail.
Arguments:
value:
The :see:LocalizedValue instance to serialize
into a data type that the database can understand.
Returns:
A dictionary containing a key for every language,
extracted from the specified value.
"""
# default to None if this is an unknown type
if not isinstance(value, LocalizedValue) and value:
value = None
if value:
cleaned_value = self.clean(value)
self.validate(cleaned_value)
else:
cleaned_value = value
return super(LocalizedField, self).get_prep_value(
cleaned_value.__dict__ if cleaned_value else None
)
def clean(self, value, *_):
"""Cleans the specified value into something we
can store in the database.
For example, when all the language fields are
left empty, and the field is allows to be null,
we will store None instead of empty keys.
Arguments:
value:
The value to clean.
Returns:
The cleaned value, ready for database storage.
"""
if not value or not isinstance(value, LocalizedValue):
return None
# are any of the language fiels None/empty?
is_all_null = True
for lang_code, _ in settings.LANGUAGES:
if value.get(lang_code):
is_all_null = False
break
# all fields have been left empty and we support
# null values, let's return null to represent that
if is_all_null and self.null:
return None
return value
def validate(self, value: LocalizedValue, *_):
"""Validates that the value for the primary language
has been filled in.
Exceptions are raises in order to notify the user
of invalid values.
Arguments:
value:
The value to validate.
"""
if self.null:
return
primary_lang_val = getattr(value, settings.LANGUAGE_CODE)
if not primary_lang_val:
raise IntegrityError(
'null value in column "%s.%s" violates not-null constraint' % (
self.name,
settings.LANGUAGE_CODE
)
)
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = {
'form_class': LocalizedFieldForm
}
defaults.update(kwargs)
return super().formfield(**defaults)

View File

@ -0,0 +1,67 @@
from django.conf import settings
from django.utils import translation
class LocalizedValue:
"""Represents the value of a :see:LocalizedField."""
def __init__(self, keys: dict=None):
"""Initializes a new instance of :see:LocalizedValue.
Arguments:
keys:
The keys to initialize this value with. Every
key contains the value of this field in a
different language.
"""
for lang_code, _ in settings.LANGUAGES:
value = keys.get(lang_code) if keys else None
setattr(self, lang_code, value)
def get(self, language: str=None) -> str:
"""Gets the underlying value in the specified or
primary language.
Arguments:
language:
The language to get the value in.
Returns:
The value in the current language, or
the primary language in case no language
was specified.
"""
language = language or translation.get_language()
return getattr(self, language, None)
def set(self, language: str, value: str):
"""Sets the value in the specified language.
Arguments:
language:
The language to set the value in.
value:
The value to set.
"""
setattr(self, language, value)
return self
def __str__(self) -> str:
"""Gets the value in the current language, or falls
back to the primary language if there's no value
in the current language."""
value = self.get()
if not value:
value = self.get(settings.LANGUAGE_CODE)
return value or ''
def __repr__(self): # pragma: no cover
"""Gets a textual representation of this object."""
return 'LocalizedValue<%s> 0x%s' % (self.__dict__, id(self))

87
localized_fields/forms.py Normal file
View File

@ -0,0 +1,87 @@
from typing import List
from django import forms
from django.conf import settings
from django.forms import MultiWidget
from .fields.localized_value import LocalizedValue
class LocalizedFieldWidget(MultiWidget):
"""Widget that has an input box for every language."""
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedFieldWidget."""
widgets = []
for _ in settings.LANGUAGES:
widgets.append(forms.TextInput())
super(LocalizedFieldWidget, self).__init__(widgets, *args, **kwargs)
def decompress(self, value: LocalizedValue) -> List[str]:
"""Decompresses the specified value so
it can be spread over the internal widgets.
Arguments:
value:
The :see:LocalizedValue to display in this
widget.
Returns:
All values to display in the inner widgets.
"""
result = []
for lang_code, _ in settings.LANGUAGES:
result.append(value.get(lang_code))
return result
class LocalizedFieldForm(forms.MultiValueField):
"""Form for a localized field, allows editing
the field in multiple languages."""
widget = LocalizedFieldWidget()
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedFieldForm."""
fields = []
for lang_code, _ in settings.LANGUAGES:
field_options = {'required': False}
if lang_code == settings.LANGUAGE_CODE:
field_options['required'] = True
field_options['label'] = lang_code
fields.append(forms.fields.CharField(**field_options))
super(LocalizedFieldForm, self).__init__(
fields,
require_all_fields=False
)
def compress(self, value: List[str]) -> LocalizedValue:
"""Compresses the values from individual fields
into a single :see:LocalizedValue instance.
Arguments:
value:
The values from all the widgets.
Returns:
A :see:LocalizedValue containing all
the value in several languages.
"""
localized_value = LocalizedValue()
for (lang_code, _), value in zip(settings.LANGUAGES, value):
localized_value.set(lang_code, value)
return localized_value

View File

View File

@ -0,0 +1,104 @@
from django.conf import settings
from django.contrib.postgres.operations import HStoreExtension
from django.db import connection, migrations, models
from django.db.migrations.executor import MigrationExecutor
from django.test import TestCase
from django.utils.text import slugify
from ..fields import LocalizedAutoSlugField, LocalizedField, LocalizedValue
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()
class TestModel(models.Model):
"""Model used for testing the :see:LocalizedAutoSlugField."""
app_label = 'localized_fields'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.title = self.title or LocalizedValue()
self.slug = self.slug or LocalizedValue()
title = LocalizedField()
slug = LocalizedAutoSlugField(populate_from='title')
class TestProject:
def clone(self, *args, **kwargs):
return self
class TestMigration(migrations.Migration):
operations = [
HStoreExtension()
]
with connection.schema_editor() as schema_editor:
migration_executor = MigrationExecutor(schema_editor.connection)
migration_executor.apply_migration(
TestProject(),
TestMigration('eh', 'localized_fields')
)
schema_editor.create_model(TestModel)
cls.TestModel = TestModel
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'
@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

View File

@ -0,0 +1,86 @@
from django.conf import settings
from django.test import TestCase
from django_bleach.utils import get_bleach_default_options
import bleach
from ..fields import LocalizedBleachField, LocalizedValue
class TestModel:
"""Used to declare a bleach-able field on."""
def __init__(self, value):
"""Initializes a new instance of :see:TestModel.
Arguments:
The value to initialize with.
"""
self.value = value
class LocalizedBleachFieldTestCase(TestCase):
"""Tests the :see:LocalizedBleachField class."""
def test_pre_save(self):
"""Tests whether the :see:pre_save function
bleaches all values in a :see:LocalizedValue."""
value = self._get_test_value()
model, field = self._get_test_model(value)
bleached_value = field.pre_save(model, False)
self._validate(value, bleached_value)
def test_pre_save_none(self):
"""Tests whether the :see:pre_save function
works properly when specifying :see:None."""
model, field = self._get_test_model(None)
bleached_value = field.pre_save(model, False)
assert not bleached_value
@staticmethod
def _get_test_model(value):
"""Gets a test model and a artifically
constructed :see:LocalizedBleachField
instance to test with."""
model = TestModel(value)
field = LocalizedBleachField()
field.attname = 'value'
return model, field
@staticmethod
def _get_test_value():
"""Gets a :see:LocalizedValue instance for testing."""
value = LocalizedValue()
for lang_code, lang_name in settings.LANGUAGES:
value.set(lang_code, '<script>%s</script>' % lang_name)
return value
@staticmethod
def _validate(non_bleached_value, bleached_value):
"""Validates whether the specified non-bleached
value ended up being correctly bleached.
Arguments:
non_bleached_value:
The value before bleaching.
bleached_value:
The value after bleaching.
"""
for lang_code, _ in settings.LANGUAGES:
expected_value = bleach.clean(
non_bleached_value.get(lang_code),
get_bleach_default_options()
)
assert bleached_value.get(lang_code) == expected_value

View File

@ -0,0 +1,246 @@
from django.conf import settings
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils import translation
from ..fields import LocalizedField, LocalizedValue
from ..forms import LocalizedFieldForm
def get_init_values() -> dict:
"""Gets a test dictionary containing a key
for every language."""
keys = {}
for lang_code, lang_name in settings.LANGUAGES:
keys[lang_code] = 'value in %s' % lang_name
return keys
class LocalizedValueTestCase(TestCase):
"""Tests the :see:LocalizedValue class."""
@staticmethod
def tearDown():
"""Assures that the current language
is set back to the default."""
translation.activate(settings.LANGUAGE_CODE)
@staticmethod
def test_init():
"""Tests whether the __init__ function
of the :see:LocalizedValue class works
as expected."""
keys = get_init_values()
value = LocalizedValue(keys)
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code, None) == keys[lang_code]
@staticmethod
def test_init_default_values():
"""Tests wehther the __init__ function
of the :see:LocalizedValue accepts the
default value or an empty dict properly."""
value = LocalizedValue()
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code) is None
@staticmethod
def test_get_explicit():
"""Tests whether the the :see:LocalizedValue
class's :see:get function works properly
when specifying an explicit value."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
assert localized_value.get(language) == value
@staticmethod
def test_get_current_language():
"""Tests whether the :see:LocalizedValue
class's see:get function properly
gets the value in the current language."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
translation.activate(language)
assert localized_value.get() == value
@staticmethod
def test_set():
"""Tests whether the :see:LocalizedValue
class's see:set function works properly."""
localized_value = LocalizedValue()
for language, value in get_init_values():
localized_value.set(language, value)
assert localized_value.get(language) == value
assert getattr(localized_value, language) == value
@staticmethod
def test_str():
"""Tests whether the :see:LocalizedValue
class's __str__ works properly."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
translation.activate(language)
assert str(localized_value) == value
@staticmethod
def test_str_fallback():
"""Tests whether the :see:LocalizedValue
class's __str__'s fallback functionality
works properly."""
test_value = 'myvalue'
localized_value = LocalizedValue({
settings.LANGUAGE_CODE: test_value
})
other_language = settings.LANGUAGES[-1][0]
# make sure that, by default it returns
# the value in the default language
assert str(localized_value) == test_value
# make sure that it falls back to the
# primary language when there's no value
# available in the current language
translation.activate(other_language)
assert str(localized_value) == test_value
# make sure that it's just __str__ falling
# back and that for the other language
# there's no actual value
assert localized_value.get(other_language) != test_value
class LocalizedFieldTestCase(TestCase):
"""Tests the :see:LocalizedField class."""
@staticmethod
def test_from_db_value():
"""Tests whether the :see:from_db_value function
produces the expected :see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField.from_db_value(input_data)
for lang_code, _ in settings.LANGUAGES:
assert getattr(localized_value, lang_code) == input_data[lang_code]
@staticmethod
def test_from_db_value_none():
"""Tests whether the :see:from_db_valuei function
correctly handles None values."""
localized_value = LocalizedField.from_db_value(None)
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python():
"""Tests whether the :see:to_python function
produces the expected :see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField().to_python(input_data)
for language, value in input_data.items():
assert localized_value.get(language) == value
@staticmethod
def test_to_python_none():
"""Tests whether the :see:to_python function
produces the expected :see:LocalizedValue
instance when it is passes None."""
localized_value = LocalizedField().to_python(None)
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python_non_dict():
"""Tests whether the :see:to_python function produces
the expected :see:LocalizedValue when it is
passed a non-dictionary value."""
localized_value = LocalizedField().to_python(list())
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_get_prep_value():
""""Tests whether the :see:get_prep_value function
produces the expected dictionary."""
input_data = get_init_values()
localized_value = LocalizedValue(input_data)
output_data = LocalizedField().get_prep_value(localized_value)
for language, value in input_data.items():
assert language in output_data
assert output_data.get(language) == value
@staticmethod
def test_get_prep_value_none():
"""Tests whether the :see:get_prep_value function
produces the expected output when it is passed None."""
output_data = LocalizedField().get_prep_value(None)
assert not output_data
@staticmethod
def test_get_prep_value_no_localized_value():
"""Tests whether the :see:get_prep_value function
produces the expected output when it is passed a
non-LocalizedValue value."""
output_data = LocalizedField().get_prep_value(['huh'])
assert not output_data
def test_get_prep_value_clean(self):
"""Tests whether the :see:get_prep_value produces
None as the output when it is passed an empty, but
valid LocalizedValue value but, only when null=True."""
localized_value = LocalizedValue()
with self.assertRaises(IntegrityError):
LocalizedField(null=False).get_prep_value(localized_value)
assert not LocalizedField(null=True).get_prep_value(localized_value)
assert not LocalizedField().clean(None)
assert not LocalizedField().clean(['huh'])
@staticmethod
def test_formfield():
"""Tests whether the :see:formfield function
correctly returns a valid form."""
assert isinstance(
LocalizedField().formfield(),
LocalizedFieldForm
)

View File

@ -0,0 +1,34 @@
from django.conf import settings
from django.test import TestCase
from ..forms import LocalizedFieldForm
class LocalizedFieldFormTestCase(TestCase):
"""Tests the workings of the :see:LocalizedFieldForm class."""
@staticmethod
def test_init():
"""Tests whether the constructor correctly
creates a field for every language."""
form = LocalizedFieldForm()
for (lang_code, _), field in zip(settings.LANGUAGES, form.fields):
assert field.label == lang_code
if lang_code == settings.LANGUAGE_CODE:
assert field.required
else:
assert not field.required
@staticmethod
def test_compress():
"""Tests whether the :see:compress function
is working properly."""
input_value = [lang_name for _, lang_name in settings.LANGUAGES]
output_value = LocalizedFieldForm().compress(input_value)
for lang_code, lang_name in settings.LANGUAGES:
assert output_value.get(lang_code) == lang_name

View File

@ -0,0 +1,33 @@
from django.conf import settings
from django.test import TestCase
from ..fields import LocalizedValue
from ..forms import LocalizedFieldWidget
class LocalizedFieldWidgetTestCase(TestCase):
"""Tests the workings of the :see:LocalizedFieldWidget class."""
@staticmethod
def test_widget_creation():
"""Tests whether a widget is created for every
language correctly."""
widget = LocalizedFieldWidget()
assert len(widget.widgets) == len(settings.LANGUAGES)
@staticmethod
def test_decompress():
"""Tests whether a :see:LocalizedValue instance
can correctly be "decompressed" over the available
widgets."""
localized_value = LocalizedValue()
for lang_code, lang_name in settings.LANGUAGES:
localized_value.set(lang_code, lang_name)
widget = LocalizedFieldWidget()
decompressed_values = widget.decompress(localized_value)
for (lang_code, _), value in zip(settings.LANGUAGES, decompressed_values):
assert localized_value.get(lang_code) == value

View File

@ -2,6 +2,11 @@
import os
import sys
if __name__ == "__main__":
if __name__ == '__main__':
os.environ.setdefault(
'DJANGO_SETTINGS_MODULE',
'settings'
)
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

25
settings.py Normal file
View File

@ -0,0 +1,25 @@
DEBUG = True
TEMPLATE_DEBUG = True
SECRET_KEY = 'this is my secret key'
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'localized_fields',
'HOST': 'localhost'
}
}
LANGUAGE_CODE = 'en'
LANGUAGES = (
('en', 'English'),
('ro', 'Romanian'),
('nl', 'Dutch')
)
INSTALLED_APPS = [
'localized_fields'
]

View File

@ -11,7 +11,7 @@ setup(
packages=find_packages(),
include_package_data=True,
license='MIT License',
description='Implementation of localized model fields using PostgreSQL HStore fields.'
description='Implementation of localized model fields using PostgreSQL HStore fields.',
long_description=README,
url='http://sectorlabs.ro/',
author='Sector Labs',

6
test-requirements.txt Normal file
View File

@ -0,0 +1,6 @@
coverage==4.2
Django==1.10.2
django-autoslug==1.9.3
django-bleach==0.3.0
django-coverage-plugin==1.3.1
psycopg2==2.6.2