diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 5635672..bb1d0ff 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -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: diff --git a/README.rst b/README.rst index b6628a6..dcc347d 100644 --- a/README.rst +++ b/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? diff --git a/localized_fields/fields/field.py b/localized_fields/fields/field.py index 321a8d7..6a57a2f 100644 --- a/localized_fields/fields/field.py +++ b/localized_fields/fields/field.py @@ -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 diff --git a/localized_fields/fields/file_field.py b/localized_fields/fields/file_field.py index 49bbf86..e6777a8 100644 --- a/localized_fields/fields/file_field.py +++ b/localized_fields/fields/file_field.py @@ -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) diff --git a/localized_fields/forms.py b/localized_fields/forms.py index c227ba3..21606cf 100644 --- a/localized_fields/forms.py +++ b/localized_fields/forms.py @@ -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 diff --git a/setup.py b/setup.py index 974fcca..ad2491d 100644 --- a/setup.py +++ b/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', diff --git a/tests/test_field.py b/tests/test_field.py index b998a46..3bd896c 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -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=' ') diff --git a/tests/test_file_field.py b/tests/test_file_field.py index 923ebb8..529564e 100644 --- a/tests/test_file_field.py +++ b/tests/test_file_field.py @@ -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 diff --git a/tests/test_file_field_form.py b/tests/test_file_field_form.py index 3b33ba4..74dcc7c 100644 --- a/tests/test_file_field_form.py +++ b/tests/test_file_field_form.py @@ -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 - diff --git a/tests/test_form.py b/tests/test_form.py index a5b1157..0bedcc1 100644 --- a/tests/test_form.py +++ b/tests/test_form.py @@ -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 diff --git a/tests/test_slug_fields.py b/tests/test_slug_fields.py index afd45ea..4d2d172 100644 --- a/tests/test_slug_fields.py +++ b/tests/test_slug_fields.py @@ -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 diff --git a/tests/test_value.py b/tests/test_value.py index 4d0fe61..844bac0 100644 --- a/tests/test_value.py +++ b/tests/test_value.py @@ -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'] } diff --git a/tests/test_widget.py b/tests/test_widget.py index c0f07de..192032b 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -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('