From a66b3492cd6290b8062a2318c1439cdacaffb47f Mon Sep 17 00:00:00 2001 From: Gherman Razvan <62335074+GRazvan12@users.noreply.github.com> Date: Thu, 10 Aug 2023 15:33:47 +0300 Subject: [PATCH] Add LocalizedBooleanField (#93) --- localized_fields/admin.py | 2 + localized_fields/fields/__init__.py | 2 + localized_fields/fields/boolean_field.py | 110 ++++++++++++ localized_fields/forms.py | 11 ++ localized_fields/value.py | 29 ++++ localized_fields/widgets.py | 12 ++ setup.py | 2 +- tests/test_boolean_field.py | 211 +++++++++++++++++++++++ tests/test_field.py | 4 +- 9 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 localized_fields/fields/boolean_field.py create mode 100644 tests/test_boolean_field.py diff --git a/localized_fields/admin.py b/localized_fields/admin.py index 63650e0..7f84aef 100644 --- a/localized_fields/admin.py +++ b/localized_fields/admin.py @@ -1,5 +1,6 @@ from . import widgets from .fields import ( + LocalizedBooleanField, LocalizedCharField, LocalizedField, LocalizedFileField, @@ -11,6 +12,7 @@ FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = { LocalizedCharField: {"widget": widgets.AdminLocalizedCharFieldWidget}, LocalizedTextField: {"widget": widgets.AdminLocalizedFieldWidget}, LocalizedFileField: {"widget": widgets.AdminLocalizedFileFieldWidget}, + LocalizedBooleanField: {"widget": widgets.AdminLocalizedBooleanFieldWidget}, } diff --git a/localized_fields/fields/__init__.py b/localized_fields/fields/__init__.py index e6066df..297bc2a 100644 --- a/localized_fields/fields/__init__.py +++ b/localized_fields/fields/__init__.py @@ -1,4 +1,5 @@ from .autoslug_field import LocalizedAutoSlugField +from .boolean_field import LocalizedBooleanField from .char_field import LocalizedCharField from .field import LocalizedField from .file_field import LocalizedFileField @@ -16,6 +17,7 @@ __all__ = [ "LocalizedFileField", "LocalizedIntegerField", "LocalizedFloatField", + "LocalizedBooleanField", ] try: diff --git a/localized_fields/fields/boolean_field.py b/localized_fields/fields/boolean_field.py new file mode 100644 index 0000000..9ace47f --- /dev/null +++ b/localized_fields/fields/boolean_field.py @@ -0,0 +1,110 @@ +from typing import Dict, Optional, Union + +from django.conf import settings +from django.db.utils import IntegrityError + +from ..forms import LocalizedBooleanFieldForm +from ..value import LocalizedBooleanValue, LocalizedValue +from .field import LocalizedField + + +class LocalizedBooleanField(LocalizedField): + """Stores booleans as a localized value.""" + + attr_class = LocalizedBooleanValue + + @classmethod + def from_db_value(cls, value, *_) -> Optional[LocalizedBooleanValue]: + db_value = super().from_db_value(value) + + if db_value is None: + return db_value + + if isinstance(db_value, str): + if db_value.lower() == "true": + return True + return False + + if not isinstance(db_value, LocalizedValue): + return db_value + + return cls._convert_localized_value(db_value) + + def to_python( + self, value: Union[Dict[str, str], str, None] + ) -> LocalizedBooleanValue: + """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: LocalizedBooleanValue) -> dict: + """Gets the value in a format to store into the database.""" + + # apply default values + default_values = LocalizedBooleanValue(self.default) + if isinstance(value, LocalizedBooleanValue): + 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 values to be converted to bool + for lang_code, _ in settings.LANGUAGES: + local_value = prepped_value[lang_code] + + if local_value is not None and local_value.lower() not in ( + "false", + "true", + ): + raise IntegrityError( + 'non-boolean value in column "%s.%s" violates ' + "boolean 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": LocalizedBooleanFieldForm} + + defaults.update(kwargs) + return super().formfield(**defaults) + + @staticmethod + def _convert_localized_value( + value: LocalizedValue, + ) -> LocalizedBooleanValue: + """Converts from :see:LocalizedValue to :see:LocalizedBooleanValue.""" + + integer_values = {} + for lang_code, _ in settings.LANGUAGES: + local_value = value.get(lang_code, None) + + if isinstance(local_value, str): + if local_value.lower() == "false": + local_value = False + elif local_value.lower() == "true": + local_value = True + else: + raise ValueError( + f"Could not convert value {local_value} to boolean." + ) + + integer_values[lang_code] = local_value + elif local_value is not None: + raise TypeError( + f"Expected value of type str instead of {type(local_value)}." + ) + + return LocalizedBooleanValue(integer_values) diff --git a/localized_fields/forms.py b/localized_fields/forms.py index 32ebd35..bf46cbe 100644 --- a/localized_fields/forms.py +++ b/localized_fields/forms.py @@ -6,12 +6,14 @@ from django.core.exceptions import ValidationError from django.forms.widgets import FILE_INPUT_CONTRADICTION from .value import ( + LocalizedBooleanValue, LocalizedFileValue, LocalizedIntegerValue, LocalizedStringValue, LocalizedValue, ) from .widgets import ( + AdminLocalizedBooleanFieldWidget, AdminLocalizedIntegerFieldWidget, LocalizedCharFieldWidget, LocalizedFieldWidget, @@ -102,6 +104,15 @@ class LocalizedIntegerFieldForm(LocalizedFieldForm): value_class = LocalizedIntegerValue +class LocalizedBooleanFieldForm(LocalizedFieldForm, forms.BooleanField): + """Form for a localized boolean field, allows editing the field in multiple + languages.""" + + widget = AdminLocalizedBooleanFieldWidget + field_class = forms.fields.BooleanField + value_class = LocalizedBooleanValue + + class LocalizedFileFieldForm(LocalizedFieldForm, forms.FileField): """Form for a localized file field, allows editing the field in multiple languages.""" diff --git a/localized_fields/value.py b/localized_fields/value.py index 6b47fc7..f318181 100644 --- a/localized_fields/value.py +++ b/localized_fields/value.py @@ -237,6 +237,35 @@ class LocalizedFileValue(LocalizedValue): return self.get(translation.get_language()) +class LocalizedBooleanValue(LocalizedValue): + 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 + + if isinstance(value, bool): + return value + + if value.lower() == "true": + return True + return False + + def __bool__(self): + """Gets the value in the current language as a boolean.""" + value = self.translate() + + return value + + def __str__(self): + """Returns string representation of value.""" + + value = self.translate() + return str(value) if value is not None else "" + + class LocalizedNumericValue(LocalizedValue): def __int__(self): """Gets the value in the current language as an integer.""" diff --git a/localized_fields/widgets.py b/localized_fields/widgets.py index 12b3238..fed1abf 100644 --- a/localized_fields/widgets.py +++ b/localized_fields/widgets.py @@ -120,6 +120,18 @@ class AdminLocalizedFieldWidget(LocalizedFieldWidget): widget = widgets.AdminTextareaWidget +class AdminLocalizedBooleanFieldWidget(LocalizedFieldWidget): + widget = forms.Select + + def __init__(self, *args, **kwargs): + """Initializes a new instance of :see:LocalizedBooleanFieldWidget.""" + + super().__init__(*args, **kwargs) + + for widget in self.widgets: + widget.choices = [("False", False), ("True", True)] + + class AdminLocalizedCharFieldWidget(AdminLocalizedFieldWidget): widget = widgets.AdminTextInputWidget diff --git a/setup.py b/setup.py index 648b2aa..837c87c 100644 --- a/setup.py +++ b/setup.py @@ -90,7 +90,7 @@ setup( "psycopg2==2.8.4", ], "analysis": [ - "black==19.3b0", + "black==22.3.0", "flake8==3.7.7", "autoflake==1.3", "autopep8==1.4.4", diff --git a/tests/test_boolean_field.py b/tests/test_boolean_field.py new file mode 100644 index 0000000..78064d7 --- /dev/null +++ b/tests/test_boolean_field.py @@ -0,0 +1,211 @@ +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 LocalizedBooleanField +from localized_fields.value import LocalizedBooleanValue + +from .fake_model import get_fake_model + + +class LocalizedBooleanFieldTestCase(TestCase): + """Tests whether the :see:LocalizedBooleanField and + :see:LocalizedIntegerValue works properly.""" + + TestModel = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.TestModel = get_fake_model({"translated": LocalizedBooleanField()}) + + def test_basic(self): + """Tests the basics of storing boolean values.""" + + obj = self.TestModel() + for lang_code, _ in settings.LANGUAGES: + obj.translated.set(lang_code, False) + obj.save() + + obj = self.TestModel.objects.all().first() + for lang_code, _ in settings.LANGUAGES: + assert obj.translated.get(lang_code) is False + + def test_primary_language_required(self): + """Tests whether the primary language is required by default and all + other languages are optional.""" + + # 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.translated.set(lang_code, True) + obj.save() + + def test_default_value_none(self): + """Tests whether the default value for optional languages is + NoneType.""" + + obj = self.TestModel() + obj.translated.set(settings.LANGUAGE_CODE, True) + obj.save() + + for lang_code, _ in settings.LANGUAGES: + if lang_code == settings.LANGUAGE_CODE: + continue + + assert obj.translated.get(lang_code) is None + + def test_translate(self): + """Tests whether casting the value to a boolean results in the value + being returned in the currently active language as a boolean.""" + + obj = self.TestModel() + for lang_code, _ in settings.LANGUAGES: + obj.translated.set(lang_code, True) + obj.save() + + obj.refresh_from_db() + for lang_code, _ in settings.LANGUAGES: + with translation.override(lang_code): + assert bool(obj.translated) is True + assert obj.translated.translate() is True + + def test_translate_primary_fallback(self): + """Tests whether casting the value to a boolean results in the value + being returned in the active language and falls back to the primary + language if there is no value in that language.""" + + obj = self.TestModel() + obj.translated.set(settings.LANGUAGE_CODE, True) + + secondary_language = settings.LANGUAGES[-1][0] + assert obj.translated.get(secondary_language) is None + + with translation.override(secondary_language): + assert obj.translated.translate() is True + assert bool(obj.translated) is True + + 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.translated.set(settings.LANGUAGE_CODE, True) + + secondary_language = settings.LANGUAGES[-1][0] + assert obj.translated.get(secondary_language) is None + assert obj.translated.get(secondary_language, False) is False + + def test_completely_optional(self): + """Tests whether having all languages optional works properly.""" + + model = get_fake_model( + { + "translated": LocalizedBooleanField( + null=True, required=[], blank=True + ) + } + ) + + obj = model() + obj.save() + + for lang_code, _ in settings.LANGUAGES: + assert getattr(obj.translated, lang_code) is None + + def test_store_string(self): + """Tests whether the field properly raises an error when trying to + store a non-boolean.""" + + for lang_code, _ in settings.LANGUAGES: + obj = self.TestModel() + with self.assertRaises(IntegrityError): + obj.translated.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 a boolean.""" + + obj = self.TestModel() + obj.translated.set(settings.LANGUAGE_CODE, False) + obj.save() + + with connection.cursor() as cursor: + table_name = self.TestModel._meta.db_table + cursor.execute("update %s set translated = 'en=>haha'" % table_name) + + with self.assertRaises(ValueError): + obj.refresh_from_db() + + def test_default_value(self): + """Tests whether a default is properly set when specified.""" + + model = get_fake_model( + { + "translated": LocalizedBooleanField( + default={settings.LANGUAGE_CODE: True} + ) + } + ) + + obj = model.objects.create() + assert obj.translated.get(settings.LANGUAGE_CODE) is True + + obj = model() + for lang_code, _ in settings.LANGUAGES: + obj.translated.set(lang_code, None) + obj.save() + + for lang_code, _ in settings.LANGUAGES: + if lang_code == settings.LANGUAGE_CODE: + assert obj.translated.get(lang_code) is True + else: + assert obj.translated.get(lang_code) is None + + def test_default_value_update(self): + """Tests whether a default is properly set when specified during + updates.""" + + model = get_fake_model( + { + "translated": LocalizedBooleanField( + default={settings.LANGUAGE_CODE: True}, null=True + ) + } + ) + + obj = model.objects.create( + translated=LocalizedBooleanValue({settings.LANGUAGE_CODE: False}) + ) + assert obj.translated.get(settings.LANGUAGE_CODE) is False + + model.objects.update( + translated=LocalizedBooleanValue({settings.LANGUAGE_CODE: None}) + ) + obj.refresh_from_db() + assert obj.translated.get(settings.LANGUAGE_CODE) is True + + def test_callable_default_value(self): + output = {"en": True} + + def func(): + return output + + model = get_fake_model({"test": LocalizedBooleanField(default=func)}) + obj = model.objects.create() + + assert obj.test["en"] == output["en"] diff --git a/tests/test_field.py b/tests/test_field.py index 8e72e9c..28aa9ad 100644 --- a/tests/test_field.py +++ b/tests/test_field.py @@ -121,8 +121,8 @@ class LocalizedFieldTestCase(TestCase): @staticmethod def test_get_prep_value(): - """"Tests whether the :see:get_prep_value function produces the - expected dictionary.""" + """Tests whether the :see:get_prep_value function produces the expected + dictionary.""" input_data = get_init_values() localized_value = LocalizedValue(input_data)