From 612b3bf4278feb5fa94efdb511714162020b421d Mon Sep 17 00:00:00 2001 From: Swen Kooij Date: Fri, 21 Oct 2016 12:21:11 +0300 Subject: [PATCH] Added initial implementation + tests --- .coveragerc | 5 + .gitignore | 4 + localized_fields/fields/__init__.py | 12 + .../fields/localized_autoslug_field.py | 116 +++++++++ .../fields/localized_bleach_field.py | 39 +++ localized_fields/fields/localized_field.py | 159 +++++++++++ localized_fields/fields/localized_value.py | 67 +++++ localized_fields/forms.py | 87 +++++++ localized_fields/tests/__init__.py | 0 .../tests/test_localized_auto_slug_field.py | 104 ++++++++ .../tests/test_localized_bleach_field.py | 86 ++++++ .../tests/test_localized_field.py | 246 ++++++++++++++++++ .../tests/test_localized_field_form.py | 34 +++ .../tests/test_localized_field_widget.py | 33 +++ manage.py | 7 +- settings.py | 25 ++ setup.py | 2 +- test-requirements.txt | 6 + 18 files changed, 1030 insertions(+), 2 deletions(-) create mode 100644 .coveragerc create mode 100644 localized_fields/fields/__init__.py create mode 100644 localized_fields/fields/localized_autoslug_field.py create mode 100644 localized_fields/fields/localized_bleach_field.py create mode 100644 localized_fields/fields/localized_field.py create mode 100644 localized_fields/fields/localized_value.py create mode 100644 localized_fields/forms.py create mode 100644 localized_fields/tests/__init__.py create mode 100644 localized_fields/tests/test_localized_auto_slug_field.py create mode 100644 localized_fields/tests/test_localized_bleach_field.py create mode 100644 localized_fields/tests/test_localized_field.py create mode 100644 localized_fields/tests/test_localized_field_form.py create mode 100644 localized_fields/tests/test_localized_field_widget.py create mode 100644 settings.py create mode 100644 test-requirements.txt diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4ebca25 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +include = localized_fields/* +omit = *migrations*, *tests* +plugins = + django_coverage_plugin diff --git a/.gitignore b/.gitignore index bfe3bdd..2930ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ env/ # Ignore Python byte code cache *.pyc __pycache__ + +# Ignore coverage reports +.coverage +htmlcov diff --git a/localized_fields/fields/__init__.py b/localized_fields/fields/__init__.py new file mode 100644 index 0000000..c8f8f18 --- /dev/null +++ b/localized_fields/fields/__init__.py @@ -0,0 +1,12 @@ +from .localized_field import LocalizedField +from .localized_value import LocalizedValue +from .localized_autoslug_field import LocalizedAutoSlugField +from .localized_bleach_field import LocalizedBleachField + + +__all__ = [ + 'LocalizedField', + 'LocalizedValue', + 'LocalizedAutoSlugField', + 'LocalizedBleachField' +] diff --git a/localized_fields/fields/localized_autoslug_field.py b/localized_fields/fields/localized_autoslug_field.py new file mode 100644 index 0000000..dc19c91 --- /dev/null +++ b/localized_fields/fields/localized_autoslug_field.py @@ -0,0 +1,116 @@ +from typing import Callable + +from django.conf import settings +from django.utils.text import slugify + +from .localized_field import LocalizedField +from .localized_value import LocalizedValue + + +class LocalizedAutoSlugField(LocalizedField): + """Custom version of :see:AutoSlugField that + can operate on :see:LocalizedField and provides + unique slugs for every language.""" + + def __init__(self, *args, **kwargs): + """Initializes a new instance of :see:LocalizedAutoSlugField.""" + + self.populate_from = kwargs.pop('populate_from', None) + super(LocalizedAutoSlugField, self).__init__(*args, **kwargs) + + def deconstruct(self): + """Deconstructs the field into something the database + can store.""" + + name, path, args, kwargs = super( + LocalizedAutoSlugField, self).deconstruct() + kwargs['populate_from'] = self.populate_from + + return name, path, args, kwargs + + def pre_save(self, instance, add: bool): + """Ran just before the model is saved, allows us to built + the slug. + + Arguments: + instance: + The model that is being saved. + + add: + Indicates whether this is a new entry + to the database or an update. + """ + + slugs = LocalizedValue() + + for lang_code, _ in settings.LANGUAGES: + value = self._get_populate_from_value( + instance, + self.populate_from, + lang_code + ) + + if not value: + continue + + def is_unique(slug: str, language: str) -> bool: + """Gets whether the specified slug is unique.""" + + unique_filter = { + '%s__%s__contains' % (self.name, language): slug + } + + return not type(instance).objects.filter(**unique_filter).exists() + + slug = self._make_unique_slug(slugify(value), lang_code, is_unique) + slugs.set(lang_code, slug) + + setattr(instance, self.name, slugs) + return slugs + + @staticmethod + def _make_unique_slug(slug: str, language: str, is_unique: Callable[[str], bool]) -> str: + """Guarentees that the specified slug is unique by appending + a number until it is unique. + + Arguments: + slug: + The slug to make unique. + + is_unique: + Function that can be called to verify + whether the generate slug is unique. + + Returns: + A guarenteed unique slug. + """ + + index = 1 + unique_slug = slug + + while not is_unique(unique_slug, language): + unique_slug = '%s-%d' % (slug, index) + index += 1 + + return unique_slug + + @staticmethod + def _get_populate_from_value(instance, field_name: str, language: str): + """Gets the value to create a slug from in the specified language. + + Arguments: + instance: + The model that the field resides on. + + field_name: + The name of the field to generate a slug for. + + language: + The language to generate the slug for. + + Returns: + The text to generate a slug for. + """ + + value = getattr(instance, field_name, None) + return value.get(language) diff --git a/localized_fields/fields/localized_bleach_field.py b/localized_fields/fields/localized_bleach_field.py new file mode 100644 index 0000000..b5c96cb --- /dev/null +++ b/localized_fields/fields/localized_bleach_field.py @@ -0,0 +1,39 @@ +from django.conf import settings +from django_bleach.utils import get_bleach_default_options +import bleach + +from .localized_field import LocalizedField + + +class LocalizedBleachField(LocalizedField): + """Custom version of :see:BleachField that + is actually a :see:LocalizedField.""" + + def pre_save(self, instance, add: bool): + """Ran just before the model is saved, allows us to built + the slug. + + Arguments: + instance: + The model that is being saved. + + add: + Indicates whether this is a new entry + to the database or an update. + """ + + localized_value = getattr(instance, self.attname) + if not localized_value: + return None + + for lang_code, _ in settings.LANGUAGES: + value = localized_value.get(lang_code) + if not value: + continue + + localized_value.set( + lang_code, + bleach.clean(value, get_bleach_default_options()) + ) + + return localized_value diff --git a/localized_fields/fields/localized_field.py b/localized_fields/fields/localized_field.py new file mode 100644 index 0000000..156e631 --- /dev/null +++ b/localized_fields/fields/localized_field.py @@ -0,0 +1,159 @@ +from django.conf import settings +from django.contrib.postgres.fields import HStoreField +from django.db.utils import IntegrityError + +from ..forms import LocalizedFieldForm +from .localized_value import LocalizedValue + + +class LocalizedField(HStoreField): + """A field that has the same value in multiple languages. + + Internally this is stored as a :see:HStoreField where there + is a key for every language.""" + + Meta = None + + def __init__(self, *args, **kwargs): + """Initializes a new instance of :see:LocalizedValue.""" + + super(LocalizedField, self).__init__(*args, **kwargs) + + @staticmethod + def from_db_value(value, *_): + """Turns the specified database value into its Python + equivalent. + + Arguments: + value: + The value that is stored in the database and + needs to be converted to its Python equivalent. + + Returns: + A :see:LocalizedValue instance containing the + data extracted from the database. + """ + + if not value: + return LocalizedValue() + + return LocalizedValue(value) + + def to_python(self, value: dict) -> LocalizedValue: + """Turns the specified database value into its Python + equivalent. + + Arguments: + value: + The value that is stored in the database and + needs to be converted to its Python equivalent. + + Returns: + A :see:LocalizedValue instance containing the + data extracted from the database. + """ + + if not value or not isinstance(value, dict): + return LocalizedValue() + + return LocalizedValue(value) + + def get_prep_value(self, value: LocalizedValue) -> dict: + """Turns the specified value into something the database + can store. + + If an illegal value (non-LocalizedValue instance) is + specified, we'll treat it as an empty :see:LocalizedValue + instance, on which the validation will fail. + + Arguments: + value: + The :see:LocalizedValue instance to serialize + into a data type that the database can understand. + + Returns: + A dictionary containing a key for every language, + extracted from the specified value. + """ + + # default to None if this is an unknown type + if not isinstance(value, LocalizedValue) and value: + value = None + + if value: + cleaned_value = self.clean(value) + self.validate(cleaned_value) + else: + cleaned_value = value + + return super(LocalizedField, self).get_prep_value( + cleaned_value.__dict__ if cleaned_value else None + ) + + def clean(self, value, *_): + """Cleans the specified value into something we + can store in the database. + + For example, when all the language fields are + left empty, and the field is allows to be null, + we will store None instead of empty keys. + + Arguments: + value: + The value to clean. + + Returns: + The cleaned value, ready for database storage. + """ + + if not value or not isinstance(value, LocalizedValue): + return None + + # are any of the language fiels None/empty? + is_all_null = True + for lang_code, _ in settings.LANGUAGES: + if value.get(lang_code): + is_all_null = False + break + + # all fields have been left empty and we support + # null values, let's return null to represent that + if is_all_null and self.null: + return None + + return value + + def validate(self, value: LocalizedValue, *_): + """Validates that the value for the primary language + has been filled in. + + Exceptions are raises in order to notify the user + of invalid values. + + Arguments: + value: + The value to validate. + """ + + if self.null: + return + + primary_lang_val = getattr(value, settings.LANGUAGE_CODE) + + if not primary_lang_val: + raise IntegrityError( + 'null value in column "%s.%s" violates not-null constraint' % ( + self.name, + settings.LANGUAGE_CODE + ) + ) + + def formfield(self, **kwargs): + """Gets the form field associated with this field.""" + + defaults = { + 'form_class': LocalizedFieldForm + } + + defaults.update(kwargs) + return super().formfield(**defaults) diff --git a/localized_fields/fields/localized_value.py b/localized_fields/fields/localized_value.py new file mode 100644 index 0000000..79145b9 --- /dev/null +++ b/localized_fields/fields/localized_value.py @@ -0,0 +1,67 @@ +from django.conf import settings +from django.utils import translation + + +class LocalizedValue: + """Represents the value of a :see:LocalizedField.""" + + def __init__(self, keys: dict=None): + """Initializes a new instance of :see:LocalizedValue. + + Arguments: + keys: + The keys to initialize this value with. Every + key contains the value of this field in a + different language. + """ + + for lang_code, _ in settings.LANGUAGES: + value = keys.get(lang_code) if keys else None + setattr(self, lang_code, value) + + def get(self, language: str=None) -> str: + """Gets the underlying value in the specified or + primary language. + + Arguments: + language: + The language to get the value in. + + Returns: + The value in the current language, or + the primary language in case no language + was specified. + """ + + language = language or translation.get_language() + return getattr(self, language, None) + + def set(self, language: str, value: str): + """Sets the value in the specified language. + + Arguments: + language: + The language to set the value in. + + value: + The value to set. + """ + + setattr(self, language, value) + return self + + 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.""" + + value = self.get() + if not value: + value = self.get(settings.LANGUAGE_CODE) + + return value or '' + + def __repr__(self): # pragma: no cover + """Gets a textual representation of this object.""" + + return 'LocalizedValue<%s> 0x%s' % (self.__dict__, id(self)) diff --git a/localized_fields/forms.py b/localized_fields/forms.py new file mode 100644 index 0000000..82108ed --- /dev/null +++ b/localized_fields/forms.py @@ -0,0 +1,87 @@ +from typing import List + +from django import forms +from django.conf import settings +from django.forms import MultiWidget + +from .fields.localized_value import LocalizedValue + + +class LocalizedFieldWidget(MultiWidget): + """Widget that has an input box for every language.""" + + def __init__(self, *args, **kwargs): + """Initializes a new instance of :see:LocalizedFieldWidget.""" + + widgets = [] + + for _ in settings.LANGUAGES: + widgets.append(forms.TextInput()) + + super(LocalizedFieldWidget, self).__init__(widgets, *args, **kwargs) + + def decompress(self, value: LocalizedValue) -> List[str]: + """Decompresses the specified value so + it can be spread over the internal widgets. + + Arguments: + value: + The :see:LocalizedValue to display in this + widget. + + Returns: + All values to display in the inner widgets. + """ + + result = [] + + for lang_code, _ in settings.LANGUAGES: + result.append(value.get(lang_code)) + + return result + + +class LocalizedFieldForm(forms.MultiValueField): + """Form for a localized field, allows editing + the field in multiple languages.""" + + widget = LocalizedFieldWidget() + + def __init__(self, *args, **kwargs): + """Initializes a new instance of :see:LocalizedFieldForm.""" + + fields = [] + + for lang_code, _ in settings.LANGUAGES: + field_options = {'required': False} + + if lang_code == settings.LANGUAGE_CODE: + field_options['required'] = True + + field_options['label'] = lang_code + fields.append(forms.fields.CharField(**field_options)) + + super(LocalizedFieldForm, self).__init__( + fields, + require_all_fields=False + ) + + def compress(self, value: List[str]) -> LocalizedValue: + """Compresses the values from individual fields + into a single :see:LocalizedValue instance. + + Arguments: + value: + The values from all the widgets. + + Returns: + A :see:LocalizedValue containing all + the value in several languages. + """ + + localized_value = LocalizedValue() + + for (lang_code, _), value in zip(settings.LANGUAGES, value): + localized_value.set(lang_code, value) + + return localized_value diff --git a/localized_fields/tests/__init__.py b/localized_fields/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/localized_fields/tests/test_localized_auto_slug_field.py b/localized_fields/tests/test_localized_auto_slug_field.py new file mode 100644 index 0000000..f18e78a --- /dev/null +++ b/localized_fields/tests/test_localized_auto_slug_field.py @@ -0,0 +1,104 @@ +from django.conf import settings +from django.contrib.postgres.operations import HStoreExtension +from django.db import connection, migrations, models +from django.db.migrations.executor import MigrationExecutor +from django.test import TestCase +from django.utils.text import slugify + +from ..fields import LocalizedAutoSlugField, LocalizedField, LocalizedValue + + +class LocalizedAutoSlugFieldTestCase(TestCase): + """Tests the :see:LocalizedAutoSlugField class.""" + + TestModel = None + + @classmethod + def setUpClass(cls): + """Creates the test model in the database.""" + + super(LocalizedAutoSlugFieldTestCase, cls).setUpClass() + + class TestModel(models.Model): + """Model used for testing the :see:LocalizedAutoSlugField.""" + + app_label = 'localized_fields' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.title = self.title or LocalizedValue() + self.slug = self.slug or LocalizedValue() + + title = LocalizedField() + slug = LocalizedAutoSlugField(populate_from='title') + + class TestProject: + + def clone(self, *args, **kwargs): + return self + + class TestMigration(migrations.Migration): + operations = [ + HStoreExtension() + ] + + with connection.schema_editor() as schema_editor: + migration_executor = MigrationExecutor(schema_editor.connection) + migration_executor.apply_migration( + TestProject(), + TestMigration('eh', 'localized_fields') + ) + schema_editor.create_model(TestModel) + + cls.TestModel = TestModel + + def test_populate(self): + """Tests whether the :see:LocalizedAutoSlugField's + populating feature works correctly.""" + + obj = self.TestModel() + obj.title.en = 'this is my title' + obj.save() + + assert obj.slug.get('en') == slugify(obj.title.en) + + def test_populate_multiple_languages(self): + """Tests whether the :see:LocalizedAutoSlugField's + populating feature correctly works for all languages.""" + + obj = self.TestModel() + + for lang_code, lang_name in settings.LANGUAGES: + obj.title.set(lang_code, 'title %s' % lang_name) + + obj.save() + + for lang_code, lang_name in settings.LANGUAGES: + assert obj.slug.get(lang_code) == 'title-%s' % lang_name.lower() + + def test_unique_slug(self): + """Tests whether the :see:LocalizedAutoSlugField + correctly generates unique slugs.""" + + obj = self.TestModel() + obj.title.en = 'title' + obj.save() + + another_obj = self.TestModel() + another_obj.title.en = 'title' + another_obj.save() + + assert another_obj.slug.en == 'title-1' + + @staticmethod + def test_deconstruct(): + """Tests whether the :see:deconstruct + function properly retains options + specified in the constructor.""" + + field = LocalizedAutoSlugField(populate_from='title') + _, _, _, kwargs = field.deconstruct() + + assert 'populate_from' in kwargs + assert kwargs['populate_from'] == field.populate_from diff --git a/localized_fields/tests/test_localized_bleach_field.py b/localized_fields/tests/test_localized_bleach_field.py new file mode 100644 index 0000000..fc8ae42 --- /dev/null +++ b/localized_fields/tests/test_localized_bleach_field.py @@ -0,0 +1,86 @@ +from django.conf import settings +from django.test import TestCase +from django_bleach.utils import get_bleach_default_options +import bleach + +from ..fields import LocalizedBleachField, LocalizedValue + + +class TestModel: + """Used to declare a bleach-able field on.""" + + def __init__(self, value): + """Initializes a new instance of :see:TestModel. + + Arguments: + The value to initialize with. + """ + + self.value = value + + +class LocalizedBleachFieldTestCase(TestCase): + """Tests the :see:LocalizedBleachField class.""" + + def test_pre_save(self): + """Tests whether the :see:pre_save function + bleaches all values in a :see:LocalizedValue.""" + + value = self._get_test_value() + model, field = self._get_test_model(value) + + bleached_value = field.pre_save(model, False) + self._validate(value, bleached_value) + + def test_pre_save_none(self): + """Tests whether the :see:pre_save function + works properly when specifying :see:None.""" + + model, field = self._get_test_model(None) + + bleached_value = field.pre_save(model, False) + assert not bleached_value + + @staticmethod + def _get_test_model(value): + """Gets a test model and a artifically + constructed :see:LocalizedBleachField + instance to test with.""" + + model = TestModel(value) + + field = LocalizedBleachField() + field.attname = 'value' + return model, field + + @staticmethod + def _get_test_value(): + """Gets a :see:LocalizedValue instance for testing.""" + + value = LocalizedValue() + + for lang_code, lang_name in settings.LANGUAGES: + value.set(lang_code, '' % lang_name) + + return value + + @staticmethod + def _validate(non_bleached_value, bleached_value): + """Validates whether the specified non-bleached + value ended up being correctly bleached. + + Arguments: + non_bleached_value: + The value before bleaching. + + bleached_value: + The value after bleaching. + """ + + for lang_code, _ in settings.LANGUAGES: + expected_value = bleach.clean( + non_bleached_value.get(lang_code), + get_bleach_default_options() + ) + + assert bleached_value.get(lang_code) == expected_value diff --git a/localized_fields/tests/test_localized_field.py b/localized_fields/tests/test_localized_field.py new file mode 100644 index 0000000..32e4895 --- /dev/null +++ b/localized_fields/tests/test_localized_field.py @@ -0,0 +1,246 @@ +from django.conf import settings +from django.db.utils import IntegrityError +from django.test import TestCase +from django.utils import translation + +from ..fields import LocalizedField, LocalizedValue +from ..forms import LocalizedFieldForm + + +def get_init_values() -> dict: + """Gets a test dictionary containing a key + for every language.""" + + keys = {} + + for lang_code, lang_name in settings.LANGUAGES: + keys[lang_code] = 'value in %s' % lang_name + + return keys + + +class LocalizedValueTestCase(TestCase): + """Tests the :see:LocalizedValue class.""" + + @staticmethod + def tearDown(): + """Assures that the current language + is set back to the default.""" + + translation.activate(settings.LANGUAGE_CODE) + + @staticmethod + def test_init(): + """Tests whether the __init__ function + of the :see:LocalizedValue class works + as expected.""" + + keys = get_init_values() + value = LocalizedValue(keys) + + for lang_code, _ in settings.LANGUAGES: + assert getattr(value, lang_code, None) == keys[lang_code] + + @staticmethod + def test_init_default_values(): + """Tests wehther the __init__ function + of the :see:LocalizedValue accepts the + default value or an empty dict properly.""" + + value = LocalizedValue() + + for lang_code, _ in settings.LANGUAGES: + assert getattr(value, lang_code) is None + + @staticmethod + def test_get_explicit(): + """Tests whether the the :see:LocalizedValue + class's :see:get function works properly + when specifying an explicit value.""" + + keys = get_init_values() + localized_value = LocalizedValue(keys) + + for language, value in keys.items(): + assert localized_value.get(language) == value + + @staticmethod + def test_get_current_language(): + """Tests whether the :see:LocalizedValue + class's see:get function properly + gets the value in the current language.""" + + keys = get_init_values() + localized_value = LocalizedValue(keys) + + for language, value in keys.items(): + translation.activate(language) + assert localized_value.get() == value + + @staticmethod + def test_set(): + """Tests whether the :see:LocalizedValue + class's see:set function works properly.""" + + localized_value = LocalizedValue() + + for language, value in get_init_values(): + localized_value.set(language, value) + 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_str_fallback(): + """Tests whether the :see:LocalizedValue + class's __str__'s fallback functionality + works properly.""" + + test_value = 'myvalue' + + localized_value = LocalizedValue({ + settings.LANGUAGE_CODE: test_value + }) + + other_language = settings.LANGUAGES[-1][0] + + # make sure that, by default it returns + # the value in the default language + assert str(localized_value) == 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 + + # make sure that it's just __str__ falling + # back and that for the other language + # there's no actual value + assert localized_value.get(other_language) != test_value + + +class LocalizedFieldTestCase(TestCase): + """Tests the :see:LocalizedField class.""" + + @staticmethod + def test_from_db_value(): + """Tests whether the :see:from_db_value function + produces the expected :see:LocalizedValue.""" + + input_data = get_init_values() + localized_value = LocalizedField.from_db_value(input_data) + + for lang_code, _ in settings.LANGUAGES: + assert getattr(localized_value, lang_code) == input_data[lang_code] + + @staticmethod + def test_from_db_value_none(): + """Tests whether the :see:from_db_valuei function + correctly handles None values.""" + + localized_value = LocalizedField.from_db_value(None) + + for lang_code, _ in settings.LANGUAGES: + assert localized_value.get(lang_code) is None + + @staticmethod + def test_to_python(): + """Tests whether the :see:to_python function + produces the expected :see:LocalizedValue.""" + + input_data = get_init_values() + localized_value = LocalizedField().to_python(input_data) + + for language, value in input_data.items(): + assert localized_value.get(language) == value + + @staticmethod + def test_to_python_none(): + """Tests whether the :see:to_python function + produces the expected :see:LocalizedValue + instance when it is passes None.""" + + localized_value = LocalizedField().to_python(None) + assert localized_value + + for lang_code, _ in settings.LANGUAGES: + assert localized_value.get(lang_code) is None + + @staticmethod + def test_to_python_non_dict(): + """Tests whether the :see:to_python function produces + the expected :see:LocalizedValue when it is + passed a non-dictionary value.""" + + localized_value = LocalizedField().to_python(list()) + assert localized_value + + for lang_code, _ in settings.LANGUAGES: + assert localized_value.get(lang_code) is None + + @staticmethod + def test_get_prep_value(): + """"Tests whether the :see:get_prep_value function + produces the expected dictionary.""" + + input_data = get_init_values() + localized_value = LocalizedValue(input_data) + + output_data = LocalizedField().get_prep_value(localized_value) + + for language, value in input_data.items(): + assert language in output_data + assert output_data.get(language) == value + + @staticmethod + def test_get_prep_value_none(): + """Tests whether the :see:get_prep_value function + produces the expected output when it is passed None.""" + + output_data = LocalizedField().get_prep_value(None) + assert not output_data + + @staticmethod + def test_get_prep_value_no_localized_value(): + """Tests whether the :see:get_prep_value function + produces the expected output when it is passed a + non-LocalizedValue value.""" + + output_data = LocalizedField().get_prep_value(['huh']) + assert not output_data + + def test_get_prep_value_clean(self): + """Tests whether the :see:get_prep_value produces + None as the output when it is passed an empty, but + valid LocalizedValue value but, only when null=True.""" + + localized_value = LocalizedValue() + + with self.assertRaises(IntegrityError): + LocalizedField(null=False).get_prep_value(localized_value) + + assert not LocalizedField(null=True).get_prep_value(localized_value) + assert not LocalizedField().clean(None) + assert not LocalizedField().clean(['huh']) + + @staticmethod + def test_formfield(): + """Tests whether the :see:formfield function + correctly returns a valid form.""" + + assert isinstance( + LocalizedField().formfield(), + LocalizedFieldForm + ) diff --git a/localized_fields/tests/test_localized_field_form.py b/localized_fields/tests/test_localized_field_form.py new file mode 100644 index 0000000..f6e49df --- /dev/null +++ b/localized_fields/tests/test_localized_field_form.py @@ -0,0 +1,34 @@ +from django.conf import settings +from django.test import TestCase + +from ..forms import LocalizedFieldForm + + +class LocalizedFieldFormTestCase(TestCase): + """Tests the workings of the :see:LocalizedFieldForm class.""" + + @staticmethod + def test_init(): + """Tests whether the constructor correctly + creates a field for every language.""" + + form = LocalizedFieldForm() + + for (lang_code, _), field in zip(settings.LANGUAGES, form.fields): + assert field.label == lang_code + + if lang_code == settings.LANGUAGE_CODE: + assert field.required + else: + assert not field.required + + @staticmethod + def test_compress(): + """Tests whether the :see:compress function + is working properly.""" + + input_value = [lang_name for _, lang_name in settings.LANGUAGES] + output_value = LocalizedFieldForm().compress(input_value) + + for lang_code, lang_name in settings.LANGUAGES: + assert output_value.get(lang_code) == lang_name diff --git a/localized_fields/tests/test_localized_field_widget.py b/localized_fields/tests/test_localized_field_widget.py new file mode 100644 index 0000000..e9c17af --- /dev/null +++ b/localized_fields/tests/test_localized_field_widget.py @@ -0,0 +1,33 @@ +from django.conf import settings +from django.test import TestCase + +from ..fields import LocalizedValue +from ..forms import LocalizedFieldWidget + + +class LocalizedFieldWidgetTestCase(TestCase): + """Tests the workings of the :see:LocalizedFieldWidget class.""" + + @staticmethod + def test_widget_creation(): + """Tests whether a widget is created for every + language correctly.""" + + widget = LocalizedFieldWidget() + assert len(widget.widgets) == len(settings.LANGUAGES) + + @staticmethod + def test_decompress(): + """Tests whether a :see:LocalizedValue instance + can correctly be "decompressed" over the available + widgets.""" + + localized_value = LocalizedValue() + for lang_code, lang_name in settings.LANGUAGES: + localized_value.set(lang_code, lang_name) + + widget = LocalizedFieldWidget() + decompressed_values = widget.decompress(localized_value) + + for (lang_code, _), value in zip(settings.LANGUAGES, decompressed_values): + assert localized_value.get(lang_code) == value diff --git a/manage.py b/manage.py index b7f40a0..0a6393c 100644 --- a/manage.py +++ b/manage.py @@ -2,6 +2,11 @@ import os import sys -if __name__ == "__main__": +if __name__ == '__main__': + os.environ.setdefault( + 'DJANGO_SETTINGS_MODULE', + 'settings' + ) + from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..1b986f1 --- /dev/null +++ b/settings.py @@ -0,0 +1,25 @@ +DEBUG = True +TEMPLATE_DEBUG = True + +SECRET_KEY = 'this is my secret key' + +TEST_RUNNER = 'django.test.runner.DiscoverRunner' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'localized_fields', + 'HOST': 'localhost' + } +} + +LANGUAGE_CODE = 'en' +LANGUAGES = ( + ('en', 'English'), + ('ro', 'Romanian'), + ('nl', 'Dutch') +) + +INSTALLED_APPS = [ + 'localized_fields' +] diff --git a/setup.py b/setup.py index 797577c..e558067 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( packages=find_packages(), include_package_data=True, license='MIT License', - description='Implementation of localized model fields using PostgreSQL HStore fields.' + description='Implementation of localized model fields using PostgreSQL HStore fields.', long_description=README, url='http://sectorlabs.ro/', author='Sector Labs', diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..0921fcd --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,6 @@ +coverage==4.2 +Django==1.10.2 +django-autoslug==1.9.3 +django-bleach==0.3.0 +django-coverage-plugin==1.3.1 +psycopg2==2.6.2