diff --git a/localized_fields/fields/__init__.py b/localized_fields/fields/__init__.py index 9c9889c..3a4c2d7 100644 --- a/localized_fields/fields/__init__.py +++ b/localized_fields/fields/__init__.py @@ -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: diff --git a/localized_fields/fields/field.py b/localized_fields/fields/field.py index 333b374..3f5d4ae 100644 --- a/localized_fields/fields/field.py +++ b/localized_fields/fields/field.py @@ -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. diff --git a/localized_fields/fields/integer_field.py b/localized_fields/fields/integer_field.py new file mode 100644 index 0000000..97ed9de --- /dev/null +++ b/localized_fields/fields/integer_field.py @@ -0,0 +1,68 @@ +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: LocalizedValue) -> dict: + """Gets the value in a format to store into the database.""" + + 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: + try: + if prepped_value[lang_code] is not None: + int(prepped_value[lang_code]) + except (TypeError, ValueError): + raise IntegrityError('non-integer value in column "%s.%s" violates ' + 'integer constraint' % (self.name, lang_code)) + + 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) diff --git a/localized_fields/value.py b/localized_fields/value.py index 1e78577..65f2a0c 100644 --- a/localized_fields/value.py +++ b/localized_fields/value.py @@ -38,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. @@ -192,6 +193,7 @@ 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', @@ -199,4 +201,30 @@ class LocalizedFileValue(LocalizedValue): 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) diff --git a/tests/test_integer_field.py b/tests/test_integer_field.py new file mode 100644 index 0000000..74039e6 --- /dev/null +++ b/tests/test_integer_field.py @@ -0,0 +1,154 @@ +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