15 Commits

Author SHA1 Message Date
Swen Kooij
88e2d29596 Bump version number to 5.0a3 2018-06-28 12:39:25 +03:00
Swen Kooij
588f32086b Merge pull request #51 from SectorLabs/multiwidget
Copy the widget for each language
2018-06-28 12:39:02 +03:00
Cristi Ingineru
13e2666a51 Copy the widget for each language 2018-06-28 12:33:44 +03:00
Swen Kooij
2393539b44 Bump version number to 5.0a2 2018-06-19 12:30:04 +03:00
Swen Kooij
e322b3d63b Upgrade django-postgres-extra to 1.21a11 2018-06-19 12:29:55 +03:00
Swen Kooij
1b1d24a460 Make defaults work for LocalizedIntegerField 2018-06-15 17:07:12 +03:00
Swen Kooij
fb233e8f25 Make sure values are strings before saving LocalizedIntegerValue 2018-06-15 16:19:32 +03:00
Swen Kooij
bca94a3508 Add quick docs on LocalizedIntegerField 2018-06-15 13:04:00 +03:00
Swen Kooij
8c83fa6b49 Bump version number to 5.0a1 2018-06-15 12:58:27 +03:00
Swen Kooij
90597da8fd Add a LocalizedIntegerField 2018-06-15 12:58:01 +03:00
Swen Kooij
752e17064d Deprecate LocalizedFileValue.localized() 2018-06-14 08:01:10 +03:00
Swen Kooij
def7dae640 Add LocalizedValue.translate()
LocalizedValue.translate() behaves the exact same as the str(..) cast
works, with the exception that it returns None if there is no value
instead of an empty string. This makes it easier to implement custom
value classes on top of the LocalizedValue class.

Behavior for str(..) stays the same as it was.
2018-06-14 07:57:02 +03:00
Swen Kooij
db4324fbf3 Bump version number to 4.6a3 2018-04-02 15:59:42 +03:00
Swen Kooij
9ff0b781ab Upgrade django-postgres-extra to 1.21a9 2018-04-02 15:59:27 +03:00
Swen Kooij
a76101c9ad Fix LocalizedFieldsAdminMixin not having a base class
This was a breaking change and broke a lot of projects.
2018-04-02 15:59:16 +03:00
13 changed files with 405 additions and 46 deletions

View File

@@ -121,6 +121,7 @@ Or get it in a specific language:
print(new.title.get('en')) # prints 'english title'
print(new.title.get('ro')) # prints 'romanian title'
print(new.title.get()) # whatever language is the primary one
print(new.title.get('ar', 'haha')) # prints 'haha' if there is no value in arabic
You can also explicitly set a value in a certain language:
@@ -136,7 +137,7 @@ Constraints
**Required/Optional**
Constraints is enforced on a database level.
Constraints are enforced on a database level.
* Optional filling
@@ -274,8 +275,24 @@ Besides ``LocalizedField``, there's also:
title = LocalizedField()
description = LocalizedBleachField()
* ``LocalizedIntegerField``
This is an experimental field type introduced in version 5.0 and is subject to change. It also has
some pretty major downsides due to the fact that values are stored as strings and are converted
back and forth.
Allows storing integers in multiple languages. This works exactly like ``LocalizedField`` except that
all values must be integers. Do note that values are stored as strings in your database because
the backing field type is ``hstore``, which only allows storing integers. The ``LocalizedIntegerField``
takes care of ensuring that all values are integers and converts the stored strings back to integers
when retrieving them from the database. Do not expect to be able to do queries such as:
.. code-block:: python
MyModel.objects.filter(score__en__gt=1)
* ``LocalizedCharField`` and ``LocalizedTextField``
This fields following the Django convention for string-based fields use the empty string as value for “no data”, not NULL.
These fields following the Django convention for string-based fields use the empty string as value for “no data”, not NULL.
``LocalizedCharField`` uses ``TextInput`` (``<input type="text">``) widget for render.
Example usage:

View File

@@ -1,3 +1,5 @@
from django.contrib.admin import ModelAdmin
from . import widgets
from .fields import LocalizedField, LocalizedCharField, LocalizedTextField, \
LocalizedFileField
@@ -11,7 +13,7 @@ FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = {
}
class LocalizedFieldsAdminMixin:
class LocalizedFieldsAdminMixin(ModelAdmin):
"""Mixin for making the fancy widgets work in Django Admin."""
class Media:

View File

@@ -4,6 +4,7 @@ from .uniqueslug_field import LocalizedUniqueSlugField
from .char_field import LocalizedCharField
from .text_field import LocalizedTextField
from .file_field import LocalizedFileField
from .integer_field import LocalizedIntegerField
__all__ = [
@@ -12,7 +13,8 @@ __all__ = [
'LocalizedUniqueSlugField',
'LocalizedCharField',
'LocalizedTextField',
'LocalizedFileField'
'LocalizedFileField',
'LocalizedIntegerField'
]
try:

View File

@@ -1,6 +1,6 @@
import json
from typing import Union, List
from typing import Union, List, Optional
from django.conf import settings
from django.db.utils import IntegrityError
@@ -53,7 +53,7 @@ class LocalizedField(HStoreField):
setattr(model, self.name, self.descriptor_class(self))
@classmethod
def from_db_value(cls, value, *_):
def from_db_value(cls, value, *_) -> Optional[LocalizedValue]:
"""Turns the specified database value into its Python
equivalent.

View File

@@ -0,0 +1,81 @@
from typing import Optional, Union, Dict
from django.conf import settings
from django.db.utils import IntegrityError
from .field import LocalizedField
from ..value import LocalizedValue, LocalizedIntegerValue
class LocalizedIntegerField(LocalizedField):
"""Stores integers as a localized value."""
attr_class = LocalizedIntegerValue
@classmethod
def from_db_value(cls, value, *_) -> Optional[LocalizedIntegerValue]:
db_value = super().from_db_value(value)
if db_value is None:
return db_value
# if we were used in an expression somehow then it might be
# that we're returning an individual value or an array, so
# we should not convert that into an :see:LocalizedIntegerValue
if not isinstance(db_value, LocalizedValue):
return db_value
return cls._convert_localized_value(db_value)
def to_python(self, value: Union[Dict[str, int], int, None]) -> LocalizedIntegerValue:
"""Converts the value from a database value into a Python value."""
db_value = super().to_python(value)
return self._convert_localized_value(db_value)
def get_prep_value(self, value: LocalizedIntegerValue) -> dict:
"""Gets the value in a format to store into the database."""
# apply default values
default_values = LocalizedIntegerValue(self.default)
if isinstance(value, LocalizedIntegerValue):
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code)
if local_value is None:
value.set(lang_code, default_values.get(lang_code, None))
prepped_value = super().get_prep_value(value)
if prepped_value is None:
return None
# make sure all values are proper integers
for lang_code, _ in settings.LANGUAGES:
local_value = prepped_value[lang_code]
try:
if local_value is not None:
int(local_value)
except (TypeError, ValueError):
raise IntegrityError('non-integer value in column "%s.%s" violates '
'integer constraint' % (self.name, lang_code))
# convert to a string before saving because the underlying
# type is hstore, which only accept strings
prepped_value[lang_code] = str(local_value) if local_value is not None else None
return prepped_value
@staticmethod
def _convert_localized_value(value: LocalizedValue) -> LocalizedIntegerValue:
"""Converts from :see:LocalizedValue to :see:LocalizedIntegerValue."""
integer_values = {}
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code, None)
if local_value is None or local_value.strip() == '':
local_value = None
try:
integer_values[lang_code] = int(local_value)
except (ValueError, TypeError):
integer_values[lang_code] = None
return LocalizedIntegerValue(integer_values)

View File

@@ -1,4 +1,16 @@
{% for widget in widget.subwidgets %}
<label for="{{ widget.attrs.id }}">{{ widget.lang_name }}</label>
{% include widget.template_name %}
{% endfor %}
{% with widget_id=widget.attrs.id %}
<div class="localized-fields-widget" role="tabs" data-synctabs="translation">
<ul class="localized-fields-widget tabs">
{% for widget in widget.subwidgets %}
<li class="localized-fields-widget tab">
<label for="{{ widget_id }}_{{ widget.lang_code }}">{{ widget.lang_name|capfirst }}</label>
</li>
{% endfor %}
</ul>
{% for widget in widget.subwidgets %}
<div role="tabpanel" id="{{ widget_id }}_{{ widget.lang_code }}">
{% include widget.template_name %}
</div>
{% endfor %}
</div>
{% endwith %}

View File

@@ -1,5 +1,7 @@
import deprecation
import collections
from typing import Optional
from django.conf import settings
from django.utils import translation
@@ -36,7 +38,8 @@ class LocalizedValue(dict):
"""
language = language or settings.LANGUAGE_CODE
return super().get(language, default)
value = super().get(language, default)
return value if value is not None else default
def set(self, language: str, value: str):
"""Sets the value in the specified language.
@@ -98,10 +101,10 @@ class LocalizedValue(dict):
for val in value:
self._interpret_value(val)
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."""
def translate(self) -> Optional[str]:
"""Gets the value in the current language or falls
back to the next language if there's no value in the
current language."""
fallbacks = getattr(settings, 'LOCALIZED_FIELDS_FALLBACKS', {})
@@ -112,9 +115,16 @@ class LocalizedValue(dict):
for lang_code in languages:
value = self.get(lang_code)
if value:
return value or ''
return value or None
return ''
return None
def __str__(self) -> str:
"""Gets the value in the current language or falls
back to the next language if there's no value in the
current language."""
return self.translate() or ''
def __eq__(self, other):
"""Compares :paramref:self to :paramref:other for
@@ -183,8 +193,38 @@ class LocalizedFileValue(LocalizedValue):
def __str__(self) -> str:
"""Returns string representation of value"""
return str(super().__str__())
@deprecation.deprecated(deprecated_in='4.6', removed_in='5.0',
current_version='4.6',
details='Use the translate() function instead.')
def localized(self):
"""Returns value for current language"""
return self.get(translation.get_language())
class LocalizedIntegerValue(LocalizedValue):
"""All values are integers."""
default_value = None
def translate(self):
"""Gets the value in the current language, or
in the configured fallbck language."""
value = super().translate()
if value is None or (isinstance(value, str) and value.strip() == ''):
return None
return int(value)
def __int__(self):
"""Gets the value in the current language as an integer."""
value = self.translate()
if value is None:
return self.default_value
return int(value)

View File

@@ -1,3 +1,5 @@
import copy
from typing import List
from django.conf import settings
@@ -16,7 +18,7 @@ class LocalizedFieldWidget(forms.MultiWidget):
"""Initializes a new instance of :see:LocalizedFieldWidget."""
initial_widgets = [
self.widget
copy.copy(self.widget)
for _ in settings.LANGUAGES
]

View File

@@ -1 +1,2 @@
django-postgres-extra==1.21a8
django-postgres-extra==1.21a9
deprecation==2.0.3

View File

@@ -7,7 +7,7 @@ with open(os.path.join(os.path.dirname(__file__), 'README.rst'), encoding='utf-8
setup(
name='django-localized-fields',
version='4.6a2',
version='5.0a3',
packages=find_packages(exclude=['tests']),
include_package_data=True,
license='MIT License',
@@ -18,8 +18,9 @@ setup(
author_email='open-source@sectorlabs.ro',
keywords=['django', 'localized', 'language', 'models', 'fields'],
install_requires=[
'django-postgres-extra>=1.11',
'Django>=1.11'
'django-postgres-extra>=1.21a11',
'Django>=1.11',
'deprecation==2.0.3'
],
classifiers=[
'Environment :: Web Environment',

176
tests/test_integer_field.py Normal file
View File

@@ -0,0 +1,176 @@
from django.test import TestCase
from django.db.utils import IntegrityError
from django.conf import settings
from django.db import connection
from django.utils import translation
from localized_fields.fields import LocalizedIntegerField
from .fake_model import get_fake_model
class LocalizedIntegerFieldTestCase(TestCase):
"""Tests whether the :see:LocalizedIntegerField
and :see:LocalizedIntegerValue works properly."""
TestModel = None
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.TestModel = get_fake_model({
'score': LocalizedIntegerField()
})
def test_basic(self):
"""Tests the basics of storing integer values."""
obj = self.TestModel()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
obj.score.set(lang_code, index + 1)
obj.save()
obj = self.TestModel.objects.all().first()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
assert obj.score.get(lang_code) == index + 1
def test_primary_language_required(self):
"""Tests whether the primary language is required by
default and all other languages are optiona."""
# not filling in anything should raise IntegrityError,
# the primary language is required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
obj.save()
# when filling all other languages besides the primary language
# should still raise an error because the primary is always required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
obj.score.set(lang_code, 23)
obj.save()
def test_default_value_none(self):
"""Tests whether the default value for optional languages
is NoneType."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 1234)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
assert obj.score.get(lang_code) is None
def test_translate(self):
"""Tests whether casting the value to an integer
results in the value being returned in the currently
active language as an integer."""
obj = self.TestModel()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
obj.score.set(lang_code, index + 1)
obj.save()
obj.refresh_from_db()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
with translation.override(lang_code):
assert int(obj.score) == index + 1
assert obj.score.translate() == index + 1
def test_translate_primary_fallback(self):
"""Tests whether casting the value to an integer
results in the value begin returned in the active
language and falls back to the primary language
if there is no value in that language."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.score.get(secondary_language) is None
with translation.override(secondary_language):
assert obj.score.translate() == 25
assert int(obj.score) == 25
def test_get_default_value(self):
"""Tests whether getting the value in a specific
language properly returns the specified default
in case it is not available."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.score.get(secondary_language) is None
assert obj.score.get(secondary_language, 1337) == 1337
def test_completely_optional(self):
"""Tests whether having all languages optional
works properly."""
model = get_fake_model({
'score': LocalizedIntegerField(null=True, required=[], blank=True)
})
obj = model()
obj.save()
for lang_code, _ in settings.LANGUAGES:
assert getattr(obj.score, lang_code) is None
def test_store_string(self):
"""Tests whether the field properly raises
an error when trying to store a non-integer."""
for lang_code, _ in settings.LANGUAGES:
obj = self.TestModel()
with self.assertRaises(IntegrityError):
obj.score.set(lang_code, 'haha')
obj.save()
def test_none_if_illegal_value_stored(self):
"""Tests whether None is returned for a language
if the value stored in the database is not an
integer."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25)
obj.save()
with connection.cursor() as cursor:
table_name = self.TestModel._meta.db_table
cursor.execute("update %s set score = 'en=>haha'" % table_name);
obj.refresh_from_db()
assert obj.score.get(settings.LANGUAGE_CODE) is None
def test_default_value(self):
"""Tests whether a default is properly set
when specified."""
model = get_fake_model({
'score': LocalizedIntegerField(default={settings.LANGUAGE_CODE: 75})
})
obj = model.objects.create()
assert obj.score.get(settings.LANGUAGE_CODE) == 75
obj = model()
for lang_code, _ in settings.LANGUAGES:
obj.score.set(lang_code, None)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
assert obj.score.get(lang_code) == 75
else:
assert obj.score.get(lang_code) is None

View File

@@ -1,7 +1,7 @@
from django.conf import settings
from django.test import TestCase
from django.utils import translation
from django.db.models import F
from django.conf import settings
from django.utils import translation
from django.test import TestCase, override_settings
from localized_fields.value import LocalizedValue
@@ -90,18 +90,6 @@ class LocalizedValueTestCase(TestCase):
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_eq():
"""Tests whether the __eq__ operator
@@ -116,9 +104,21 @@ class LocalizedValueTestCase(TestCase):
assert a != b
@staticmethod
def test_str_fallback():
def test_translate():
"""Tests whether the :see:LocalizedValue
class's __str__'s fallback functionality
class's __str__ works properly."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
translation.activate(language)
assert localized_value.translate() == value
@staticmethod
def test_translate_fallback():
"""Tests whether the :see:LocalizedValue
class's translate()'s fallback functionality
works properly."""
test_value = 'myvalue'
@@ -131,13 +131,13 @@ class LocalizedValueTestCase(TestCase):
# make sure that, by default it returns
# the value in the default language
assert str(localized_value) == test_value
assert localized_value.translate() == 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
assert localized_value.translate() == test_value
# make sure that it's just __str__ falling
# back and that for the other language
@@ -145,12 +145,35 @@ class LocalizedValueTestCase(TestCase):
assert localized_value.get(other_language) != test_value
@staticmethod
def test_str_fallback_custom_fallback():
def test_translate_none():
"""Tests whether the :see:LocalizedValue
class's translate() method properly returns
None when there is no value."""
# with no value, we always expect it to return None
localized_value = LocalizedValue()
assert localized_value.translate() == None
assert str(localized_value) == ''
# with no value for the default language, the default
# behavior is to return None, unless a custom fallback
# chain is configured, which there is not for this test
other_language = settings.LANGUAGES[-1][0]
localized_value = LocalizedValue({
other_language: 'hey'
})
translation.activate(settings.LANGUAGE_CODE)
assert localized_value.translate() == None
assert str(localized_value) == ''
@staticmethod
def test_translate_fallback_custom_fallback():
"""Tests whether the :see:LocalizedValue class's
__str__'s fallback functionality properly respects
translate()'s fallback functionality properly respects
the LOCALIZED_FIELDS_FALLBACKS setting."""
settings.LOCALIZED_FIELDS_FALLBACKS = {
fallbacks = {
'nl': ['ro']
}
@@ -159,8 +182,9 @@ class LocalizedValueTestCase(TestCase):
'ro': 'ro'
})
with translation.override('nl'):
assert str(localized_value) == 'ro'
with override_settings(LOCALIZED_FIELDS_FALLBACKS=fallbacks):
with translation.override('nl'):
assert localized_value.translate() == 'ro'
@staticmethod
def test_deconstruct():

View File

@@ -16,6 +16,7 @@ class LocalizedFieldWidgetTestCase(TestCase):
widget = LocalizedFieldWidget()
assert len(widget.widgets) == len(settings.LANGUAGES)
assert len(set(widget.widgets)) == len(widget.widgets)
@staticmethod
def test_decompress():