mirror of
https://github.com/SectorLabs/django-localized-fields.git
synced 2025-04-24 19:32:53 +03:00
Merge branch 'master' into widget_refactor
This commit is contained in:
commit
1accee0b59
@ -13,17 +13,20 @@ filter:
|
||||
build:
|
||||
environment:
|
||||
python: '3.5.0'
|
||||
node: 'v6.2.0'
|
||||
variables:
|
||||
DJANGO_SETTINGS_MODULES: settings
|
||||
DATABASE_URL: postgres://scrutinizer:scrutinizer@localhost:5434/localized_fields
|
||||
postgresql: true
|
||||
redis: true
|
||||
|
||||
dependencies:
|
||||
override:
|
||||
- 'pip install -r requirements/test.txt'
|
||||
tests:
|
||||
override:
|
||||
-
|
||||
command: pep8 ./localized_fields/
|
||||
-
|
||||
command: flake8 ./localized_fields/
|
||||
-
|
||||
command: coverage run manage.py test
|
||||
coverage:
|
||||
|
37
README.rst
37
README.rst
@ -12,9 +12,9 @@ django-localized-fields
|
||||
.. image:: https://badge.fury.io/py/django-localized-fields.svg
|
||||
:target: https://pypi.python.org/pypi/django-localized-fields
|
||||
|
||||
``django-localized-fields`` is an implementation of a field class for Django models that allows the field's value to be set in multiple languages. It does this by utilizing the ``hstore`` type (PostgreSQL specific), which is available as ``models.HStoreField`` in Django 1.10.
|
||||
``django-localized-fields`` is an implementation of a field class for Django models that allows the field's value to be set in multiple languages. It does this by utilizing the ``hstore`` type (PostgreSQL specific), which is available as ``models.HStoreField`` since Django 1.10.
|
||||
|
||||
This package requires Python 3.5 or newer, Django 1.10 or newer and PostgreSQL 9.6 or newer.
|
||||
This package requires Python 3.5 or newer, Django 1.11 or newer and PostgreSQL 9.6 or newer.
|
||||
|
||||
Installation
|
||||
------------
|
||||
@ -129,21 +129,42 @@ Constraints
|
||||
|
||||
**Required/Optional**
|
||||
|
||||
At the moment, it is not possible to select two languages to be marked as required. The constraint is **not** enforced on a database level.
|
||||
Constraints is enforced on a database level.
|
||||
|
||||
* Make the primary language **required** and the others optional (this is the **default**):
|
||||
* Optional filling
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyModel(models.Model):
|
||||
title = LocalizedField(required=True)
|
||||
title = LocalizedField(blank=True, null=True, required=False)
|
||||
|
||||
* Make all languages optional:
|
||||
* Make translation required for any language
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyModel(models.Model):
|
||||
title = LocalizedField(null=True)
|
||||
title = LocalizedField(blank=False, null=False, required=False)
|
||||
|
||||
* Make translation required for specific languages
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyModel(models.Model):
|
||||
title = LocalizedField(blank=False, null=False, required=['en', 'ro'])
|
||||
|
||||
* Make translation required for all languages
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyModel(models.Model):
|
||||
title = LocalizedField(blank=False, null=False, required=True)
|
||||
|
||||
* By default the primary language **required** and the others optional:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyModel(models.Model):
|
||||
title = LocalizedField()
|
||||
|
||||
**Uniqueness**
|
||||
|
||||
@ -335,7 +356,7 @@ Frequently asked questions (FAQ)
|
||||
|
||||
2. Does this package work with Django 1.X?
|
||||
|
||||
No. Only Django 1.10 or newer is supported. This is because we rely on Django's ``HStoreField``.
|
||||
No. Only Django 1.11 or newer is supported. This is because we rely on Django's ``HStoreField`` and template-based widget rendering.
|
||||
|
||||
3. Does this package come with support for Django Admin?
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from typing import Union
|
||||
from typing import Union, List
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.utils import IntegrityError
|
||||
@ -27,10 +27,17 @@ class LocalizedField(HStoreField):
|
||||
# The descriptor to use for accessing the attribute off of the class.
|
||||
descriptor_class = LocalizedValueDescriptor
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, required: Union[bool, List[str]]=None, **kwargs):
|
||||
"""Initializes a new instance of :see:LocalizedField."""
|
||||
|
||||
super(LocalizedField, self).__init__(*args, **kwargs)
|
||||
super(LocalizedField, self).__init__(*args, required=required, **kwargs)
|
||||
|
||||
if (self.required is None and self.blank) or self.required is False:
|
||||
self.required = []
|
||||
elif self.required is None and not self.blank:
|
||||
self.required = [settings.LANGUAGE_CODE]
|
||||
elif self.required is True:
|
||||
self.required = [lang_code for lang_code, _ in settings.LANGUAGES]
|
||||
|
||||
def contribute_to_class(self, model, name, **kwargs):
|
||||
"""Adds this field to the specifed model.
|
||||
@ -170,9 +177,6 @@ class LocalizedField(HStoreField):
|
||||
# are any of the language fiels None/empty?
|
||||
is_all_null = True
|
||||
for lang_code, _ in settings.LANGUAGES:
|
||||
# NOTE(seroy): use check for None, instead of
|
||||
# `bool(value.get(lang_code))==True` condition, cause in this way
|
||||
# we can not save '' value
|
||||
if value.get(lang_code) is not None:
|
||||
is_all_null = False
|
||||
break
|
||||
@ -185,8 +189,8 @@ class LocalizedField(HStoreField):
|
||||
return value
|
||||
|
||||
def validate(self, value: LocalizedValue, *_):
|
||||
"""Validates that the value for the primary language
|
||||
has been filled in.
|
||||
"""Validates that the values has been filled in for all required
|
||||
languages
|
||||
|
||||
Exceptions are raises in order to notify the user
|
||||
of invalid values.
|
||||
@ -199,36 +203,19 @@ class LocalizedField(HStoreField):
|
||||
if self.null:
|
||||
return
|
||||
|
||||
primary_lang_val = getattr(value, settings.LANGUAGE_CODE)
|
||||
for lang in self.required:
|
||||
lang_val = getattr(value, settings.LANGUAGE_CODE)
|
||||
|
||||
# NOTE(seroy): use check for None, instead of `not primary_lang_val`
|
||||
# condition, cause in this way we can not save '' value
|
||||
if primary_lang_val is None:
|
||||
raise IntegrityError(
|
||||
'null value in column "%s.%s" violates not-null constraint' % (
|
||||
self.name,
|
||||
settings.LANGUAGE_CODE
|
||||
)
|
||||
)
|
||||
if lang_val is None:
|
||||
raise IntegrityError('null value in column "%s.%s" violates '
|
||||
'not-null constraint' % (self.name, lang))
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
"""Gets the form field associated with this field."""
|
||||
|
||||
defaults = {
|
||||
'form_class': LocalizedFieldForm
|
||||
}
|
||||
|
||||
defaults = dict(
|
||||
form_class=LocalizedFieldForm,
|
||||
required=False if self.blank else self.required
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
def deconstruct(self):
|
||||
"""Gets the values to pass to :see:__init__ when
|
||||
re-creating this object."""
|
||||
|
||||
name, path, args, kwargs = super(
|
||||
LocalizedField, self).deconstruct()
|
||||
|
||||
if self.uniqueness:
|
||||
kwargs['uniqueness'] = self.uniqueness
|
||||
|
||||
return name, path, args, kwargs
|
||||
|
@ -146,8 +146,6 @@ class LocalizedFileField(LocalizedField):
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
defaults = {'form_class': LocalizedFileFieldForm}
|
||||
if 'initial' in kwargs:
|
||||
defaults['required'] = False
|
||||
defaults.update(kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
from typing import List
|
||||
from typing import List, Union
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@ -19,28 +19,29 @@ class LocalizedFieldForm(forms.MultiValueField):
|
||||
field_class = forms.fields.CharField
|
||||
value_class = LocalizedValue
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, required: Union[bool, List[str]]=False, **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'] = kwargs.get('required', True)
|
||||
|
||||
field_options['label'] = lang_code
|
||||
field_options = dict(
|
||||
required=required if type(required) is bool else (lang_code in
|
||||
required),
|
||||
label=lang_code
|
||||
)
|
||||
fields.append(self.field_class(**field_options))
|
||||
|
||||
super(LocalizedFieldForm, self).__init__(
|
||||
fields,
|
||||
required=required if type(required) is bool else True,
|
||||
require_all_fields=False,
|
||||
*args, **kwargs
|
||||
)
|
||||
|
||||
# set 'required' attribute for each widget separately
|
||||
for f, w in zip(self.fields, self.widget.widgets):
|
||||
w.is_required = f.required
|
||||
for field, widget in zip(self.fields, self.widget.widgets):
|
||||
widget.is_required = field.required
|
||||
|
||||
def compress(self, value: List[str]) -> value_class:
|
||||
"""Compresses the values from individual fields
|
||||
|
3
setup.py
3
setup.py
@ -18,7 +18,8 @@ setup(
|
||||
author_email='open-source@sectorlabs.ro',
|
||||
keywords=['django', 'localized', 'language', 'models', 'fields'],
|
||||
install_requires=[
|
||||
'django-postgres-extra>=1.11'
|
||||
'django-postgres-extra>=1.11',
|
||||
'Django>=1.11'
|
||||
],
|
||||
classifiers=[
|
||||
'Environment :: Web Environment',
|
||||
|
@ -9,11 +9,31 @@ from localized_fields.forms import LocalizedFieldForm
|
||||
from localized_fields.value import LocalizedValue
|
||||
|
||||
from .data import get_init_values
|
||||
from .fake_model import get_fake_model
|
||||
|
||||
|
||||
class LocalizedFieldTestCase(TestCase):
|
||||
"""Tests the :see:LocalizedField class."""
|
||||
|
||||
@staticmethod
|
||||
def test_init():
|
||||
"""Tests whether the :see:__init__ function
|
||||
correctly handles parameters"""
|
||||
|
||||
field = LocalizedField(blank=True)
|
||||
assert field.required == []
|
||||
|
||||
field = LocalizedField(blank=False)
|
||||
assert field.required == [settings.LANGUAGE_CODE]
|
||||
|
||||
field = LocalizedField(required=True)
|
||||
assert field.required == [lang_code for lang_code, _ in
|
||||
settings.LANGUAGES]
|
||||
|
||||
field = LocalizedField(required=False)
|
||||
assert field.required == []
|
||||
|
||||
|
||||
@staticmethod
|
||||
def test_from_db_value():
|
||||
"""Tests whether the :see:from_db_value function
|
||||
@ -156,3 +176,90 @@ class LocalizedFieldTestCase(TestCase):
|
||||
LocalizedField().formfield(),
|
||||
LocalizedFieldForm
|
||||
)
|
||||
|
||||
# case optional filling
|
||||
field = LocalizedField(blank=True, required=[])
|
||||
assert not field.formfield().required
|
||||
for field in field.formfield().fields:
|
||||
assert not field.required
|
||||
|
||||
# case required for any language
|
||||
field = LocalizedField(blank=False, required=[])
|
||||
assert field.formfield().required
|
||||
for field in field.formfield().fields:
|
||||
assert not field.required
|
||||
|
||||
# case required for specific languages
|
||||
required_langs = ['ro', 'nl']
|
||||
field = LocalizedField(blank=False, required=required_langs)
|
||||
assert field.formfield().required
|
||||
for field in field.formfield().fields:
|
||||
if field.label in required_langs:
|
||||
assert field.required
|
||||
else:
|
||||
assert not field.required
|
||||
|
||||
# case required for all languages
|
||||
field = LocalizedField(blank=False, required=True)
|
||||
assert field.formfield().required
|
||||
for field in field.formfield().fields:
|
||||
assert field.required
|
||||
|
||||
def test_required_all(self):
|
||||
"""Tests whether passing required=True properly validates
|
||||
that all languages are filled in."""
|
||||
|
||||
model = get_fake_model(dict(
|
||||
title=LocalizedField(required=True)
|
||||
))
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=dict(ro='romanian', nl='dutch'))
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=dict(nl='dutch'))
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=dict(random='random'))
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=dict())
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=None)
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title='')
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=' ')
|
||||
|
||||
def test_required_some(self):
|
||||
"""Tests whether passing an array to required,
|
||||
properly validates whether the specified languages
|
||||
are marked as required."""
|
||||
|
||||
model = get_fake_model(dict(
|
||||
title=LocalizedField(required=['nl', 'ro'])
|
||||
))
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=dict(ro='romanian', nl='dutch'))
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=dict(nl='dutch'))
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=dict(random='random'))
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=dict())
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=None)
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title='')
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
model.objects.create(title=' ')
|
||||
|
@ -72,7 +72,7 @@ class LocalizedFileFieldTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def test_save_form_data(cls):
|
||||
"""Tests whether the :see:save_form_data function correctly set
|
||||
"""Tests whether the :see:save_form_data function correctly set
|
||||
a valid value."""
|
||||
|
||||
instance = cls.FileFieldModel()
|
||||
@ -87,7 +87,7 @@ class LocalizedFileFieldTestCase(TestCase):
|
||||
instance = cls.FileFieldModel()
|
||||
instance.file = {'en': ContentFile("test", "testfilename")}
|
||||
instance._meta.get_field('file').pre_save(instance, False)
|
||||
assert instance.file.en._committed == True
|
||||
assert instance.file.en._committed is True
|
||||
|
||||
@classmethod
|
||||
def test_file_methods(cls):
|
||||
@ -135,17 +135,17 @@ class LocalizedFileFieldTestCase(TestCase):
|
||||
|
||||
@staticmethod
|
||||
def test_get_prep_value():
|
||||
"""Tests whether the :see:get_prep_value function returns correctly
|
||||
"""Tests whether the :see:get_prep_value function returns correctly
|
||||
value."""
|
||||
|
||||
value = LocalizedValue({'en': None})
|
||||
assert LocalizedFileField().get_prep_value(None) == None
|
||||
assert LocalizedFileField().get_prep_value(None) is None
|
||||
assert isinstance(LocalizedFileField().get_prep_value(value), dict)
|
||||
assert LocalizedFileField().get_prep_value(value)['en'] == ''
|
||||
|
||||
@staticmethod
|
||||
def test_formfield():
|
||||
"""Tests whether the :see:formfield function correctly returns
|
||||
"""Tests whether the :see:formfield function correctly returns
|
||||
a valid form."""
|
||||
|
||||
form_field = LocalizedFileField().formfield()
|
||||
@ -161,6 +161,5 @@ class LocalizedFileFieldTestCase(TestCase):
|
||||
name, path, args, kwargs = LocalizedFileField().deconstruct()
|
||||
assert 'upload_to' in kwargs
|
||||
assert 'storage' not in kwargs
|
||||
name, path, \
|
||||
args, kwargs = LocalizedFileField(storage='test').deconstruct()
|
||||
name, path, args, kwargs = LocalizedFileField(storage='test').deconstruct()
|
||||
assert 'storage' in kwargs
|
||||
|
@ -28,7 +28,7 @@ class LocalizedFileFieldFormTestCase(TestCase):
|
||||
formfield.clean(['', ''], ['', ''])
|
||||
|
||||
def test_bound_data(self):
|
||||
"""Tests whether the :see:bound_data function is returns correctly
|
||||
"""Tests whether the :see:bound_data function is returns correctly
|
||||
value"""
|
||||
|
||||
formfield = LocalizedFileFieldForm()
|
||||
@ -38,4 +38,3 @@ class LocalizedFileFieldFormTestCase(TestCase):
|
||||
value = [None] * len(settings.LANGUAGES)
|
||||
expected_value = [''] * len(settings.LANGUAGES)
|
||||
assert formfield.bound_data(value, initial) == expected_value
|
||||
|
||||
|
@ -11,8 +11,8 @@ class LocalizedFieldFormTestCase(TestCase):
|
||||
def test_init():
|
||||
"""Tests whether the constructor correctly
|
||||
creates a field for every language."""
|
||||
|
||||
form = LocalizedFieldForm()
|
||||
# case required for specific language
|
||||
form = LocalizedFieldForm(required=[settings.LANGUAGE_CODE])
|
||||
|
||||
for (lang_code, _), field in zip(settings.LANGUAGES, form.fields):
|
||||
assert field.label == lang_code
|
||||
@ -22,6 +22,25 @@ class LocalizedFieldFormTestCase(TestCase):
|
||||
else:
|
||||
assert not field.required
|
||||
|
||||
# case required for all languages
|
||||
form = LocalizedFieldForm(required=True)
|
||||
assert form.required
|
||||
for field in form.fields:
|
||||
assert field.required
|
||||
|
||||
# case optional filling
|
||||
form = LocalizedFieldForm(required=False)
|
||||
assert not form.required
|
||||
for field in form.fields:
|
||||
assert not field.required
|
||||
|
||||
# case required for any language
|
||||
form = LocalizedFieldForm(required=[])
|
||||
assert form.required
|
||||
for field in form.fields:
|
||||
assert not field.required
|
||||
|
||||
|
||||
@staticmethod
|
||||
def test_compress():
|
||||
"""Tests whether the :see:compress function
|
||||
|
@ -124,7 +124,7 @@ class LocalizedSlugFieldTestCase(TestCase):
|
||||
def generate_slug(instance):
|
||||
return instance.title
|
||||
|
||||
model = get_fake_model({
|
||||
get_fake_model({
|
||||
'title': LocalizedField(),
|
||||
'slug': LocalizedUniqueSlugField(populate_from=generate_slug)
|
||||
})
|
||||
@ -138,7 +138,6 @@ class LocalizedSlugFieldTestCase(TestCase):
|
||||
for lang_code, lang_name in settings.LANGUAGES:
|
||||
assert obj.slug.get(lang_code) == 'title-%s' % lang_name.lower()
|
||||
|
||||
|
||||
@staticmethod
|
||||
def test_populate_multiple_from_fields():
|
||||
"""Tests whether populating the slug from multiple
|
||||
|
@ -149,8 +149,6 @@ class LocalizedValueTestCase(TestCase):
|
||||
__str__'s fallback functionality properly respects
|
||||
the LOCALIZED_FIELDS_FALLBACKS setting."""
|
||||
|
||||
test_value = 'myvalue'
|
||||
|
||||
settings.LOCALIZED_FIELDS_FALLBACKS = {
|
||||
'nl': ['ro']
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import re
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
@ -55,3 +56,12 @@ class LocalizedFieldWidgetTestCase(TestCase):
|
||||
attrs=dict(required=True))
|
||||
assert context['widget']['subwidgets'][0]['attrs']['required']
|
||||
assert 'required' not in context['widget']['subwidgets'][1]['attrs']
|
||||
|
||||
@staticmethod
|
||||
def test_render():
|
||||
"""Tests whether the :see:LocalizedFieldWidget correctly
|
||||
render."""
|
||||
|
||||
widget = LocalizedFieldWidget()
|
||||
output = widget.render(name='title', value=None)
|
||||
assert bool(re.search('<label (.|\n|\t)*>\w+<\/label>', output))
|
||||
|
Loading…
x
Reference in New Issue
Block a user