mirror of
https://github.com/SectorLabs/django-localized-fields.git
synced 2025-10-30 02:28:57 +03:00
Added initial implementation + tests
This commit is contained in:
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))
|
||||
Reference in New Issue
Block a user