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

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))