mirror of
https://github.com/SectorLabs/django-localized-fields.git
synced 2025-04-24 11:22:54 +03:00
Added initial implementation + tests
This commit is contained in:
parent
d33fecb490
commit
612b3bf427
5
.coveragerc
Normal file
5
.coveragerc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[run]
|
||||||
|
include = localized_fields/*
|
||||||
|
omit = *migrations*, *tests*
|
||||||
|
plugins =
|
||||||
|
django_coverage_plugin
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -4,3 +4,7 @@ env/
|
|||||||
# Ignore Python byte code cache
|
# Ignore Python byte code cache
|
||||||
*.pyc
|
*.pyc
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|
||||||
|
# Ignore coverage reports
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
12
localized_fields/fields/__init__.py
Normal file
12
localized_fields/fields/__init__.py
Normal 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'
|
||||||
|
]
|
116
localized_fields/fields/localized_autoslug_field.py
Normal file
116
localized_fields/fields/localized_autoslug_field.py
Normal 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)
|
39
localized_fields/fields/localized_bleach_field.py
Normal file
39
localized_fields/fields/localized_bleach_field.py
Normal 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
|
159
localized_fields/fields/localized_field.py
Normal file
159
localized_fields/fields/localized_field.py
Normal 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)
|
67
localized_fields/fields/localized_value.py
Normal file
67
localized_fields/fields/localized_value.py
Normal 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
87
localized_fields/forms.py
Normal 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
|
0
localized_fields/tests/__init__.py
Normal file
0
localized_fields/tests/__init__.py
Normal file
104
localized_fields/tests/test_localized_auto_slug_field.py
Normal file
104
localized_fields/tests/test_localized_auto_slug_field.py
Normal 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
|
86
localized_fields/tests/test_localized_bleach_field.py
Normal file
86
localized_fields/tests/test_localized_bleach_field.py
Normal 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
|
246
localized_fields/tests/test_localized_field.py
Normal file
246
localized_fields/tests/test_localized_field.py
Normal 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
|
||||||
|
)
|
34
localized_fields/tests/test_localized_field_form.py
Normal file
34
localized_fields/tests/test_localized_field_form.py
Normal 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
|
33
localized_fields/tests/test_localized_field_widget.py
Normal file
33
localized_fields/tests/test_localized_field_widget.py
Normal 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
|
@ -2,6 +2,11 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == '__main__':
|
||||||
|
os.environ.setdefault(
|
||||||
|
'DJANGO_SETTINGS_MODULE',
|
||||||
|
'settings'
|
||||||
|
)
|
||||||
|
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
25
settings.py
Normal file
25
settings.py
Normal 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'
|
||||||
|
]
|
2
setup.py
2
setup.py
@ -11,7 +11,7 @@ setup(
|
|||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
license='MIT License',
|
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,
|
long_description=README,
|
||||||
url='http://sectorlabs.ro/',
|
url='http://sectorlabs.ro/',
|
||||||
author='Sector Labs',
|
author='Sector Labs',
|
||||||
|
6
test-requirements.txt
Normal file
6
test-requirements.txt
Normal 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
|
Loading…
x
Reference in New Issue
Block a user