diff --git a/localized_fields/fields/__init__.py b/localized_fields/fields/__init__.py index 6bd3580..e6066df 100644 --- a/localized_fields/fields/__init__.py +++ b/localized_fields/fields/__init__.py @@ -2,6 +2,7 @@ from .autoslug_field import LocalizedAutoSlugField from .char_field import LocalizedCharField from .field import LocalizedField from .file_field import LocalizedFileField +from .float_field import LocalizedFloatField from .integer_field import LocalizedIntegerField from .text_field import LocalizedTextField from .uniqueslug_field import LocalizedUniqueSlugField @@ -14,6 +15,7 @@ __all__ = [ "LocalizedTextField", "LocalizedFileField", "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..721f7aa --- /dev/null +++ b/localized_fields/fields/float_field.py @@ -0,0 +1,95 @@ +from typing import Dict, Optional, Union + +from django.conf import settings +from django.db.utils import IntegrityError + +from ..forms import LocalizedIntegerFieldForm +from ..value import LocalizedFloatValue, LocalizedValue +from .field import LocalizedField + + +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 ef2685d..c318454 100644 --- a/localized_fields/value.py +++ b/localized_fields/value.py @@ -229,7 +229,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 @@ -244,17 +268,17 @@ 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..9dd8110 --- /dev/null +++ b/tests/test_float_field.py @@ -0,0 +1,172 @@ +from django.conf import settings +from django.db import connection +from django.db.utils import IntegrityError +from django.test import TestCase +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