From e4bd26ece29f33b3659799d3b481a18d59255aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandru=20Arn=C4=83utu?= Date: Tue, 10 Mar 2020 09:21:11 +0200 Subject: [PATCH] Add support for storing Float values (#80) * Add LocalizedFloatValue * Add LocalizedFloatField * Add tests for float field * Create LocalizedNumericValue with __int__ and __float__ methods --- localized_fields/fields/__init__.py | 4 +- localized_fields/fields/float_field.py | 91 +++++++++++++ localized_fields/value.py | 48 +++++-- tests/test_float_field.py | 176 +++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 localized_fields/fields/float_field.py create mode 100644 tests/test_float_field.py diff --git a/localized_fields/fields/__init__.py b/localized_fields/fields/__init__.py index 3a4c2d7..2f65835 100644 --- a/localized_fields/fields/__init__.py +++ b/localized_fields/fields/__init__.py @@ -5,6 +5,7 @@ from .char_field import LocalizedCharField from .text_field import LocalizedTextField from .file_field import LocalizedFileField from .integer_field import LocalizedIntegerField +from .float_field import LocalizedFloatField __all__ = [ @@ -14,7 +15,8 @@ __all__ = [ 'LocalizedCharField', 'LocalizedTextField', 'LocalizedFileField', - 'LocalizedIntegerField' + 'LocalizedIntegerField', + 'LocalizedFloatField' ] try: diff --git a/localized_fields/fields/float_field.py b/localized_fields/fields/float_field.py new file mode 100644 index 0000000..87d2d91 --- /dev/null +++ b/localized_fields/fields/float_field.py @@ -0,0 +1,91 @@ +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, LocalizedFloatValue +from ..forms import LocalizedIntegerFieldForm + + +class LocalizedFloatField(LocalizedField): + """Stores float as a localized value.""" + + attr_class = LocalizedFloatValue + + @classmethod + def from_db_value(cls, value, *_) -> Optional[LocalizedFloatValue]: + 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:LocalizedFloatValue + 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]) -> LocalizedFloatValue: + """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: LocalizedFloatValue) -> dict: + """Gets the value in a format to store into the database.""" + + # apply default values + default_values = LocalizedFloatValue(self.default) + if isinstance(value, LocalizedFloatValue): + 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 floats + for lang_code, _ in settings.LANGUAGES: + local_value = prepped_value[lang_code] + try: + if local_value is not None: + float(local_value) + except (TypeError, ValueError): + raise IntegrityError('non-float value in column "%s.%s" violates ' + 'float 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 + + def formfield(self, **kwargs): + """Gets the form field associated with this field.""" + defaults = { + 'form_class': LocalizedIntegerFieldForm + } + + defaults.update(kwargs) + return super().formfield(**defaults) + + @staticmethod + def _convert_localized_value(value: LocalizedValue) -> LocalizedFloatValue: + """Converts from :see:LocalizedValue to :see:LocalizedFloatValue.""" + + float_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: + float_values[lang_code] = float(local_value) + except (ValueError, TypeError): + float_values[lang_code] = None + + return LocalizedFloatValue(float_values) diff --git a/localized_fields/value.py b/localized_fields/value.py index a1d7c29..625da90 100644 --- a/localized_fields/value.py +++ b/localized_fields/value.py @@ -206,7 +206,31 @@ class LocalizedFileValue(LocalizedValue): return self.get(translation.get_language()) -class LocalizedIntegerValue(LocalizedValue): +class LocalizedNumericValue(LocalizedValue): + 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) + + def __str__(self) -> str: + """Returns string representation of value""" + + value = self.translate() + return str(value) if value is not None else '' + + def __float__(self): + """Gets the value in the current language as a float""" + value = self.translate() + if value is None: + return self.default_value + + return float(value) + + +class LocalizedIntegerValue(LocalizedNumericValue): """All values are integers.""" default_value = None @@ -221,17 +245,19 @@ class LocalizedIntegerValue(LocalizedValue): 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 +class LocalizedFloatValue(LocalizedNumericValue): + """All values are floats""" - return int(value) + default_value = None - def __str__(self) -> str: - """Returns string representation of value""" + def translate(self): + """ + Gets the value in the current language, or in the configured + fallback language. + """ + value = super().translate() + if value is None or (isinstance(value, str) and value.strip() == ''): + return None - value = self.translate() - return str(value) if value is not None else '' + return float(value) diff --git a/tests/test_float_field.py b/tests/test_float_field.py new file mode 100644 index 0000000..60a620c --- /dev/null +++ b/tests/test_float_field.py @@ -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 LocalizedFloatField + +from .fake_model import get_fake_model + + +class LocalizedFloatFieldTestCase(TestCase): + """Tests whether the :see:LocalizedFloatField + and :see:LocalizedFloatValue works properly.""" + + TestModel = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.TestModel = get_fake_model({ + 'score': LocalizedFloatField() + }) + + def test_basic(self): + """Tests the basics of storing float values.""" + + obj = self.TestModel() + for index, (lang_code, _) in enumerate(settings.LANGUAGES): + obj.score.set(lang_code, index + 1.0) + obj.save() + + obj = self.TestModel.objects.all().first() + for index, (lang_code, _) in enumerate(settings.LANGUAGES): + assert obj.score.get(lang_code) == index + 1.0 + + 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.0) + 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.0) + 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 float + results in the value being returned in the currently + active language as an float.""" + + obj = self.TestModel() + for index, (lang_code, _) in enumerate(settings.LANGUAGES): + obj.score.set(lang_code, index + 1.0) + obj.save() + + obj.refresh_from_db() + for index, (lang_code, _) in enumerate(settings.LANGUAGES): + with translation.override(lang_code): + assert float(obj.score) == index + 1.0 + assert obj.score.translate() == index + 1.0 + + def test_translate_primary_fallback(self): + """Tests whether casting the value to an float + 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.0) + + secondary_language = settings.LANGUAGES[-1][0] + assert obj.score.get(secondary_language) is None + + with translation.override(secondary_language): + assert obj.score.translate() == 25.0 + assert float(obj.score) == 25.0 + + 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.0) + + secondary_language = settings.LANGUAGES[-1][0] + assert obj.score.get(secondary_language) is None + assert obj.score.get(secondary_language, 1337.0) == 1337.0 + + def test_completely_optional(self): + """Tests whether having all languages optional + works properly.""" + + model = get_fake_model({ + 'score': LocalizedFloatField(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-float.""" + + 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 + float.""" + + obj = self.TestModel() + obj.score.set(settings.LANGUAGE_CODE, 25.0) + 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': LocalizedFloatField(default={settings.LANGUAGE_CODE: 75.0}) + }) + + obj = model.objects.create() + assert obj.score.get(settings.LANGUAGE_CODE) == 75.0 + + 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.0 + else: + assert obj.score.get(lang_code) is None