diff --git a/README.rst b/README.rst index 081fe12..0ad18e4 100644 --- a/README.rst +++ b/README.rst @@ -32,7 +32,7 @@ Installation .... 'django.contrib.postgres', - 'localized_fields' + 'localized_fields.apps.LocalizedFieldsConfig' ] 3. Set the database engine to ``psqlextra.backend``: @@ -160,7 +160,7 @@ By default the values stored in a ``LocalizedField`` are *not unique*. You can e .. code-block:: python - from localized_fields import get_language_codes + from localized_fields.util import get_language_codes class MyModel(models.Model): title = LocalizedField(uniqueness=get_language_codes()) @@ -176,7 +176,7 @@ By default the values stored in a ``LocalizedField`` are *not unique*. You can e .. code-block:: python - from localized_fields import get_language_codes + from localized_fields.util import get_language_codes class MyModel(models.Model): title = LocalizedField(uniqueness=[(*get_language_codes())]) @@ -192,9 +192,8 @@ Besides ``LocalizedField``, there's also: .. code-block:: python - from localized_fields import (LocalizedModel, - LocalizedField, - LocalizedUniqueSlugField) + from localized_fields.models import LocalizedModel + from localized_fields.fields import LocalizedField, LocalizedUniqueSlugField class MyModel(LocalizedModel): title = LocalizedField() @@ -216,10 +215,9 @@ Besides ``LocalizedField``, there's also: .. code-block:: python - from localized_fields import (LocalizedField, - LocalizedUniqueSlugField) + from localized_fields.fields import LocalizedField, LocalizedAutoSlugField - class MyModel(models.Model): + class MyModel(LocalizedModel): title = LocalizedField() slug = LocalizedAutoSlugField(populate_from='title') @@ -227,14 +225,14 @@ Besides ``LocalizedField``, there's also: * ``LocalizedBleachField`` Automatically bleaches the content of the field. + * django-bleach Example usage: .. code-block:: python - from localized_fields import (LocalizedField, - LocalizedBleachField) + from localized_fields.fields import LocalizedField, LocalizedBleachField class MyModel(models.Model): title = LocalizedField() diff --git a/localized_fields/__init__.py b/localized_fields/__init__.py index b6b1714..e69de29 100644 --- a/localized_fields/__init__.py +++ b/localized_fields/__init__.py @@ -1,32 +0,0 @@ -from .forms import LocalizedFieldForm, LocalizedFieldWidget -from .fields import (LocalizedAutoSlugField, LocalizedField, - LocalizedUniqueSlugField, LocalizedCharField, - LocalizedTextField, LocalizedFileField) -from .localized_value import LocalizedValue -from .mixins import AtomicSlugRetryMixin -from .models import LocalizedModel -from .util import get_language_codes - -__all__ = [ - 'get_language_codes', - 'LocalizedField', - 'LocalizedModel', - 'LocalizedValue', - 'LocalizedAutoSlugField', - 'LocalizedUniqueSlugField', - 'LocalizedBleachField', - 'LocalizedCharField', - 'LocalizedTextField', - 'LocalizedFileField', - 'LocalizedFieldWidget', - 'LocalizedFieldForm', - 'AtomicSlugRetryMixin' -] - -try: - from .fields import LocalizedBleachField - __all__ += [ - 'LocalizedBleachField' - ] -except ImportError: - pass diff --git a/localized_fields/admin.py b/localized_fields/admin.py index d6615fa..98597ea 100644 --- a/localized_fields/admin.py +++ b/localized_fields/admin.py @@ -1,8 +1,9 @@ from django.contrib.admin import ModelAdmin +from . import widgets from .fields import LocalizedField, LocalizedCharField, LocalizedTextField, \ LocalizedFileField -from . import widgets + FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = { @@ -14,17 +15,22 @@ FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = { class LocalizedFieldsAdminMixin(ModelAdmin): + """Mixin for making the fancy widgets work in Django Admin.""" + class Media: css = { 'all': ( 'localized_fields/localized-fields-admin.css', ) } + js = ( 'localized_fields/localized-fields-admin.js', ) def __init__(self, *args, **kwargs): + """Initializes a new instance of :see:LocalizedFieldsAdminMixin.""" + super(LocalizedFieldsAdminMixin, self).__init__(*args, **kwargs) overrides = FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS.copy() overrides.update(self.formfield_overrides) diff --git a/localized_fields/descriptor.py b/localized_fields/descriptor.py new file mode 100644 index 0000000..dd7000a --- /dev/null +++ b/localized_fields/descriptor.py @@ -0,0 +1,65 @@ +from django.conf import settings +from django.utils import six, translation + + +class LocalizedValueDescriptor: + """ + The descriptor for the localized value attribute on the model instance. + Returns a :see:LocalizedValue when accessed so you can do stuff like:: + + >>> from myapp.models import MyModel + >>> instance = MyModel() + >>> instance.value.en = 'English value' + + Assigns a strings to active language key in :see:LocalizedValue on + assignment so you can do:: + + >>> from django.utils import translation + >>> from myapp.models import MyModel + + >>> translation.activate('nl') + >>> instance = MyModel() + >>> instance.title = 'dutch title' + >>> print(instance.title.nl) # prints 'dutch title' + """ + + def __init__(self, field): + """Initializes a new instance of :see:LocalizedValueDescriptor.""" + + self.field = field + + def __get__(self, instance, cls=None): + if instance is None: + return self + + # This is slightly complicated, so worth an explanation. + # `instance.localizedvalue` needs to ultimately return some instance of + # `LocalizedValue`, probably a subclass. + + # The instance dict contains whatever was originally assigned + # in __set__. + + if self.field.name in instance.__dict__: + value = instance.__dict__[self.field.name] + elif instance.pk is not None: + instance.refresh_from_db(fields=[self.field.name]) + value = getattr(instance, self.field.name) + else: + value = None + + if value is None: + attr = self.field.attr_class() + instance.__dict__[self.field.name] = attr + + if isinstance(value, dict): + attr = self.field.attr_class(value) + instance.__dict__[self.field.name] = attr + + return instance.__dict__[self.field.name] + + def __set__(self, instance, value): + if isinstance(value, six.string_types): + language = translation.get_language() or settings.LANGUAGE_CODE + self.__get__(instance).set(language, value) # pylint: disable=no-member + else: + instance.__dict__[self.field.name] = value diff --git a/localized_fields/expressions.py b/localized_fields/expressions.py new file mode 100644 index 0000000..b0314b2 --- /dev/null +++ b/localized_fields/expressions.py @@ -0,0 +1,25 @@ +from django.conf import settings +from django.utils import translation + +from psqlextra import expressions + + +class LocalizedRef(expressions.HStoreRef): + """Expression that selects the value in a field only in + the currently active language.""" + + def __init__(self, name: str, lang: str=None): + """Initializes a new instance of :see:LocalizedRef. + + Arguments: + name: + The field/column to select from. + + lang: + The language to get the field/column in. + If not specified, the currently active language + is used. + """ + + language = lang or translation.get_language() or settings.LANGUAGE_CODE + super().__init__(name, language) diff --git a/localized_fields/fields/__init__.py b/localized_fields/fields/__init__.py index 0f61e82..8d5ffc3 100644 --- a/localized_fields/fields/__init__.py +++ b/localized_fields/fields/__init__.py @@ -1,6 +1,6 @@ -from .localized_field import LocalizedField -from .localized_autoslug_field import LocalizedAutoSlugField -from .localized_uniqueslug_field import LocalizedUniqueSlugField +from .field import LocalizedField +from .autoslug_field import LocalizedAutoSlugField +from .uniqueslug_field import LocalizedUniqueSlugField from .localized_char_field import LocalizedCharField from .localized_text_field import LocalizedTextField from .localized_file_field import LocalizedFileField @@ -16,7 +16,7 @@ __all__ = [ ] try: - from .localized_bleach_field import LocalizedBleachField + from .bleach_field import LocalizedBleachField __all__ += [ 'LocalizedBleachField' ] diff --git a/localized_fields/fields/localized_autoslug_field.py b/localized_fields/fields/autoslug_field.py similarity index 82% rename from localized_fields/fields/localized_autoslug_field.py rename to localized_fields/fields/autoslug_field.py index 8ce26db..537aa46 100644 --- a/localized_fields/fields/localized_autoslug_field.py +++ b/localized_fields/fields/autoslug_field.py @@ -1,12 +1,12 @@ -from typing import Callable +from typing import Callable, Tuple from datetime import datetime from django import forms from django.conf import settings from django.utils.text import slugify -from .localized_field import LocalizedField -from ..localized_value import LocalizedValue +from .field import LocalizedField +from ..value import LocalizedValue class LocalizedAutoSlugField(LocalizedField): @@ -69,13 +69,7 @@ class LocalizedAutoSlugField(LocalizedField): slugs = LocalizedValue() - for lang_code, _ in settings.LANGUAGES: - value = self._get_populate_from_value( - instance, - self.populate_from, - lang_code - ) - + for lang_code, value in self._get_populate_values(instance): if not value: continue @@ -128,6 +122,30 @@ class LocalizedAutoSlugField(LocalizedField): return unique_slug + def _get_populate_values(self, instance) -> Tuple[str, str]: + """Gets all values (for each language) from the + specified's instance's `populate_from` field. + + Arguments: + instance: + The instance to get the values from. + + Returns: + A list of (lang_code, value) tuples. + """ + + return [ + ( + lang_code, + self._get_populate_from_value( + instance, + self.populate_from, + lang_code + ), + ) + for lang_code, _ in settings.LANGUAGES + ] + @staticmethod def _get_populate_from_value(instance, field_name: str, language: str): """Gets the value to create a slug from in the specified language. diff --git a/localized_fields/fields/localized_bleach_field.py b/localized_fields/fields/bleach_field.py similarity index 95% rename from localized_fields/fields/localized_bleach_field.py rename to localized_fields/fields/bleach_field.py index 92fad99..3535de7 100644 --- a/localized_fields/fields/localized_bleach_field.py +++ b/localized_fields/fields/bleach_field.py @@ -1,8 +1,9 @@ import bleach + from django.conf import settings from django_bleach.utils import get_bleach_default_options -from .localized_field import LocalizedField +from .field import LocalizedField class LocalizedBleachField(LocalizedField): diff --git a/localized_fields/fields/localized_field.py b/localized_fields/fields/field.py similarity index 69% rename from localized_fields/fields/localized_field.py rename to localized_fields/fields/field.py index 05b80a9..321a8d7 100644 --- a/localized_fields/fields/localized_field.py +++ b/localized_fields/fields/field.py @@ -1,70 +1,15 @@ +import json + +from typing import Union + from django.conf import settings from django.db.utils import IntegrityError -from django.utils import six, translation from psqlextra.fields import HStoreField from ..forms import LocalizedFieldForm -from ..localized_value import LocalizedValue - - -class LocalizedValueDescriptor(object): - """ - The descriptor for the localized value attribute on the model instance. - Returns a :see:LocalizedValue when accessed so you can do stuff like:: - - >>> from myapp.models import MyModel - >>> instance = MyModel() - >>> instance.value.en = 'English value' - - Assigns a strings to active language key in :see:LocalizedValue on - assignment so you can do:: - - >>> from django.utils import translation - >>> from myapp.models import MyModel - - >>> translation.activate('nl') - >>> instance = MyModel() - >>> instance.title = 'dutch title' - >>> print(instance.title.nl) # prints 'dutch title' - """ - def __init__(self, field): - self.field = field - - def __get__(self, instance, cls=None): - if instance is None: - return self - - # This is slightly complicated, so worth an explanation. - # `instance.localizedvalue` needs to ultimately return some instance of - # `LocalizedValue`, probably a subclass. - - # The instance dict contains whatever was originally assigned - # in __set__. - if self.field.name in instance.__dict__: - value = instance.__dict__[self.field.name] - elif instance.pk is not None: - instance.refresh_from_db(fields=[self.field.name]) - value = getattr(instance, self.field.name) - else: - value = None - - if value is None: - attr = self.field.attr_class() - instance.__dict__[self.field.name] = attr - - if isinstance(value, dict): - attr = self.field.attr_class(value) - instance.__dict__[self.field.name] = attr - - return instance.__dict__[self.field.name] - - def __set__(self, instance, value): - if isinstance(value, six.string_types): - self.__get__(instance).set(translation.get_language() or - settings.LANGUAGE_CODE, value) - else: - instance.__dict__[self.field.name] = value +from ..value import LocalizedValue +from ..descriptor import LocalizedValueDescriptor class LocalizedField(HStoreField): @@ -87,9 +32,18 @@ class LocalizedField(HStoreField): super(LocalizedField, self).__init__(*args, **kwargs) - def contribute_to_class(self, cls, name, **kwargs): - super(LocalizedField, self).contribute_to_class(cls, name, **kwargs) - setattr(cls, self.name, self.descriptor_class(self)) + def contribute_to_class(self, model, name, **kwargs): + """Adds this field to the specifed model. + + Arguments: + cls: + The model to add the field to. + + name: + The name of the field to add. + """ + super(LocalizedField, self).contribute_to_class(model, name, **kwargs) + setattr(model, self.name, self.descriptor_class(self)) @classmethod def from_db_value(cls, value, *_): @@ -112,9 +66,31 @@ class LocalizedField(HStoreField): else: return cls.attr_class() + # we can get a list if an aggregation expression was used.. + # if we the expression was flattened when only one key was selected + # then we don't wrap each value in a localized value, otherwise we do + if isinstance(value, list): + result = [] + for inner_val in value: + if isinstance(inner_val, dict): + if inner_val is None: + result.append(None) + else: + result.append(cls.attr_class(inner_val)) + else: + result.append(inner_val) + + return result + + # this is for when you select an individual key, it will be string, + # not a dictionary, we'll give it to you as a flat value, not as a + # localized value instance + if not isinstance(value, dict): + return value + return cls.attr_class(value) - def to_python(self, value: dict) -> LocalizedValue: + def to_python(self, value: Union[dict, str, None]) -> LocalizedValue: """Turns the specified database value into its Python equivalent. @@ -128,10 +104,17 @@ class LocalizedField(HStoreField): data extracted from the database. """ - if not value or not isinstance(value, dict): + # first let the base class handle the deserialization, this is in case we + # get specified a json string representing a dict + try: + deserialized_value = super(LocalizedField, self).to_python(value) + except json.JSONDecodeError: + deserialized_value = value + + if not deserialized_value: return self.attr_class() - return self.attr_class(value) + return self.attr_class(deserialized_value) def get_prep_value(self, value: LocalizedValue) -> dict: """Turns the specified value into something the database diff --git a/localized_fields/fields/localized_char_field.py b/localized_fields/fields/localized_char_field.py index 1438af7..6799b4f 100644 --- a/localized_fields/fields/localized_char_field.py +++ b/localized_fields/fields/localized_char_field.py @@ -1,6 +1,6 @@ from ..forms import LocalizedCharFieldForm -from .localized_field import LocalizedField -from ..localized_value import LocalizedStringValue +from .field import LocalizedField +from ..value import LocalizedStringValue class LocalizedCharField(LocalizedField): diff --git a/localized_fields/fields/localized_file_field.py b/localized_fields/fields/localized_file_field.py index 05c5af6..76980d7 100644 --- a/localized_fields/fields/localized_file_field.py +++ b/localized_fields/fields/localized_file_field.py @@ -8,10 +8,10 @@ from django.core.files.storage import default_storage from django.utils.encoding import force_str, force_text from localized_fields.fields import LocalizedField -from localized_fields.fields.localized_field import LocalizedValueDescriptor -from localized_fields.localized_value import LocalizedValue +from localized_fields.fields.field import LocalizedValueDescriptor +from localized_fields.value import LocalizedValue -from ..localized_value import LocalizedFileValue +from ..value import LocalizedFileValue from ..forms import LocalizedFileFieldForm diff --git a/localized_fields/fields/localized_uniqueslug_field.py b/localized_fields/fields/localized_uniqueslug_field.py deleted file mode 100644 index 120089a..0000000 --- a/localized_fields/fields/localized_uniqueslug_field.py +++ /dev/null @@ -1,156 +0,0 @@ -from datetime import datetime - -from django.conf import settings -from django import forms -from django.utils.text import slugify -from django.db import transaction -from django.db.utils import IntegrityError - - -from ..util import get_language_codes -from ..localized_value import LocalizedValue -from .localized_field import LocalizedField - - -class LocalizedUniqueSlugField(LocalizedField): - """Automatically provides slugs for a localized field upon saving." - - An improved version of :see:LocalizedAutoSlugField, - which adds: - - - Concurrency safety - - Improved performance - - When in doubt, use this over :see:LocalizedAutoSlugField. - """ - - def __init__(self, *args, **kwargs): - """Initializes a new instance of :see:LocalizedUniqueSlugField.""" - - kwargs['uniqueness'] = kwargs.pop('uniqueness', get_language_codes()) - - self.populate_from = kwargs.pop('populate_from') - self.include_time = kwargs.pop('include_time', False) - - super().__init__(*args, **kwargs) - - def deconstruct(self): - """Deconstructs the field into something the database - can store.""" - - name, path, args, kwargs = super( - LocalizedUniqueSlugField, self).deconstruct() - - kwargs['populate_from'] = self.populate_from - kwargs['include_time'] = self.include_time - return name, path, args, kwargs - - def formfield(self, **kwargs): - """Gets the form field associated with this field. - - Because this is a slug field which is automatically - populated, it should be hidden from the form. - """ - - defaults = { - 'form_class': forms.CharField, - 'required': False - } - - defaults.update(kwargs) - - form_field = super().formfield(**defaults) - form_field.widget = forms.HiddenInput() - - return form_field - - def contribute_to_class(self, cls, name, *args, **kwargs): - """Hook that allow us to operate with model class. We overwrite save() - method to run retry logic. - - Arguments: - cls: - Model class. - - name: - Name of field in model. - """ - # apparently in inheritance cases, contribute_to_class is called more - # than once, so we have to be careful not to overwrite the original - # save method. - if not hasattr(cls, '_orig_save'): - cls._orig_save = cls.save - max_retries = getattr( - settings, - 'LOCALIZED_FIELDS_MAX_RETRIES', - 100 - ) - - def _new_save(instance, *args_, **kwargs_): - retries = 0 - while True: - with transaction.atomic(): - try: - slugs = self.populate_slugs(instance, retries) - setattr(instance, name, slugs) - instance._orig_save(*args_, **kwargs_) - break - except IntegrityError as e: - if retries >= max_retries: - raise e - # check to be sure a slug fight caused - # the IntegrityError - s_e = str(e) - if name in s_e and 'unique' in s_e: - retries += 1 - else: - raise e - - cls.save = _new_save - super().contribute_to_class(cls, name, *args, **kwargs) - - def populate_slugs(self, instance, retries=0): - """Built the slug from populate_from field. - - Arguments: - instance: - The model that is being saved. - - retries: - The value of the current attempt. - - Returns: - The localized slug that was generated. - """ - slugs = LocalizedValue() - populates_slugs = getattr(instance, self.populate_from, {}) - for lang_code, _ in settings.LANGUAGES: - - value = populates_slugs.get(lang_code) - - if not value: - continue - - slug = slugify(value, allow_unicode=True) - - # verify whether it's needed to re-generate a slug, - # if not, re-use the same slug - if instance.pk is not None: - current_slug = getattr(instance, self.name).get(lang_code) - if current_slug is not None: - stripped_slug = current_slug[0:current_slug.rfind('-')] - if slug == stripped_slug: - slugs.set(lang_code, current_slug) - continue - - if self.include_time: - slug += '-%d' % datetime.now().microsecond - - if retries > 0: - # do not add another - if we already added time - if not self.include_time: - slug += '-' - slug += '%d' % retries - - slugs.set(lang_code, slug) - return slugs diff --git a/localized_fields/fields/uniqueslug_field.py b/localized_fields/fields/uniqueslug_field.py new file mode 100644 index 0000000..ecfbafd --- /dev/null +++ b/localized_fields/fields/uniqueslug_field.py @@ -0,0 +1,104 @@ +from datetime import datetime + +from django.utils.text import slugify +from django.core.exceptions import ImproperlyConfigured + +from .autoslug_field import LocalizedAutoSlugField +from ..util import get_language_codes +from ..mixins import AtomicSlugRetryMixin +from ..value import LocalizedValue + + +class LocalizedUniqueSlugField(LocalizedAutoSlugField): + """Automatically provides slugs for a localized + field upon saving." + + An improved version of :see:LocalizedAutoSlugField, + which adds: + + - Concurrency safety + - Improved performance + + When in doubt, use this over :see:LocalizedAutoSlugField. + Inherit from :see:AtomicSlugRetryMixin in your model to + make this field work properly. + """ + + def __init__(self, *args, **kwargs): + """Initializes a new instance of :see:LocalizedUniqueSlugField.""" + + kwargs['uniqueness'] = kwargs.pop('uniqueness', get_language_codes()) + + super(LocalizedUniqueSlugField, self).__init__( + *args, + **kwargs + ) + + self.populate_from = kwargs.pop('populate_from') + self.include_time = kwargs.pop('include_time', False) + + def deconstruct(self): + """Deconstructs the field into something the database + can store.""" + + name, path, args, kwargs = super( + LocalizedUniqueSlugField, self).deconstruct() + + kwargs['populate_from'] = self.populate_from + kwargs['include_time'] = self.include_time + 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. + + Returns: + The localized slug that was generated. + """ + + if not isinstance(instance, AtomicSlugRetryMixin): + raise ImproperlyConfigured(( + 'Model \'%s\' does not inherit from AtomicSlugRetryMixin. ' + 'Without this, the LocalizedUniqueSlugField will not work.' + ) % type(instance).__name__) + + slugs = LocalizedValue() + + for lang_code, value in self._get_populate_values(instance): + if not value: + continue + + slug = slugify(value, allow_unicode=True) + + # verify whether it's needed to re-generate a slug, + # if not, re-use the same slug + if instance.pk is not None: + current_slug = getattr(instance, self.name).get(lang_code) + if current_slug is not None: + stripped_slug = current_slug[0:current_slug.rfind('-')] + if slug == stripped_slug: + slugs.set(lang_code, current_slug) + continue + + if self.include_time: + slug += '-%d' % datetime.now().microsecond + + retries = getattr(instance, 'retries', 0) + if retries > 0: + # do not add another - if we already added time + if not self.include_time: + slug += '-' + slug += '%d' % retries + + slugs.set(lang_code, slug) + + setattr(instance, self.name, slugs) + return slugs diff --git a/localized_fields/forms.py b/localized_fields/forms.py index b7588ef..4e4d2c5 100644 --- a/localized_fields/forms.py +++ b/localized_fields/forms.py @@ -5,7 +5,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.forms.widgets import FILE_INPUT_CONTRADICTION -from .localized_value import LocalizedValue, LocalizedStringValue, \ +from .value import LocalizedValue, LocalizedStringValue, \ LocalizedFileValue from .widgets import LocalizedFieldWidget, LocalizedCharFieldWidget, \ LocalizedFileWidget diff --git a/localized_fields/hstore_index.py b/localized_fields/hstore_index.py deleted file mode 100644 index 352d03f..0000000 --- a/localized_fields/hstore_index.py +++ /dev/null @@ -1,132 +0,0 @@ -"""This module is unused, but should be contributed to Django.""" - -from typing import List - -from django.db import models - - -class HStoreIndex(models.Index): - """Allows creating a index on a specific HStore index. - - Note: pieces of code in this class have been copied - from the base class. There was no way around this.""" - - def __init__(self, field: str, keys: List[str], unique: bool=False, - name: str=''): - """Initializes a new instance of :see:HStoreIndex. - - Arguments: - field: - Name of the hstore field for - which's keys to create a index for. - - keys: - The name of the hstore keys to - create the index on. - - unique: - Whether this index should - be marked as UNIQUE. - - name: - The name of the index. If left - empty, one will be generated. - """ - - self.field = field - self.keys = keys - self.unique = unique - - # this will eventually set self.name - super(HStoreIndex, self).__init__( - fields=[field], - name=name - ) - - def get_sql_create_template_values(self, model, schema_editor, using): - """Gets the values for the SQL template. - - Arguments: - model: - The model this index applies to. - - schema_editor: - The schema editor to modify the schema. - - using: - Optional: "USING" statement. - - Returns: - Dictionary of keys to pass into the SQL template. - """ - - fields = [model._meta.get_field(field_name) for field_name, order in self.fields_orders] - tablespace_sql = schema_editor._get_index_tablespace_sql(model, fields) - quote_name = schema_editor.quote_name - - columns = [ - '(%s->\'%s\')' % (self.field, key) - for key in self.keys - ] - - return { - 'table': quote_name(model._meta.db_table), - 'name': quote_name(self.name), - 'columns': ', '.join(columns), - 'using': using, - 'extra': tablespace_sql, - } - - def create_sql(self, model, schema_editor, using=''): - """Gets the SQL to execute when creating the index. - - Arguments: - model: - The model this index applies to. - - schema_editor: - The schema editor to modify the schema. - - using: - Optional: "USING" statement. - - Returns: - SQL string to execute to create this index. - """ - - sql_create_index = schema_editor.sql_create_index - if self.unique: - sql_create_index = sql_create_index.replace('CREATE', 'CREATE UNIQUE') - sql_parameters = self.get_sql_create_template_values(model, schema_editor, using) - return sql_create_index % sql_parameters - - def remove_sql(self, model, schema_editor): - """Gets the SQL to execute to remove this index. - - Arguments: - model: - The model this index applies to. - - schema_editor: - The schema editor to modify the schema. - - Returns: - SQL string to execute to remove this index. - """ - quote_name = schema_editor.quote_name - return schema_editor.sql_delete_index % { - 'table': quote_name(model._meta.db_table), - 'name': quote_name(self.name), - } - - def deconstruct(self): - """Gets the values to pass to :see:__init__ when - re-creating this object.""" - - path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__) - return (path, (), { - 'field': self.field, - 'keys': self.keys, - 'unique': self.unique, - 'name': self.name - }) diff --git a/localized_fields/mixins.py b/localized_fields/mixins.py index dc3b2b2..1402715 100644 --- a/localized_fields/mixins.py +++ b/localized_fields/mixins.py @@ -1,17 +1,38 @@ -from django.core.checks import Warning +from django.db import transaction +from django.conf import settings +from django.db.utils import IntegrityError + class AtomicSlugRetryMixin: - """A Mixin keeped for backwards compatibility""" + """Makes :see:LocalizedUniqueSlugField work by retrying upon + violation of the UNIQUE constraint.""" - @classmethod - def check(cls, **kwargs): - errors = super().check(**kwargs) - errors.append( - Warning( - 'localized_fields.AtomicSlugRetryMixin is deprecated', - hint='There is no need to use ' - 'localized_fields.AtomicSlugRetryMixin', - obj=cls - ) + def save(self, *args, **kwargs): + """Saves this model instance to the database.""" + + max_retries = getattr( + settings, + 'LOCALIZED_FIELDS_MAX_RETRIES', + 100 ) - return errors + + if not hasattr(self, 'retries'): + self.retries = 0 + + with transaction.atomic(): + try: + return super().save(*args, **kwargs) + except IntegrityError as ex: + # this is as retarded as it looks, there's no + # way we can put the retry logic inside the slug + # field class... we can also not only catch exceptions + # that apply to slug fields... so yea.. this is as + # retarded as it gets... i am sorry :( + if 'slug' not in str(ex): + raise ex + + if self.retries >= max_retries: + raise ex + + self.retries += 1 + return self.save() diff --git a/localized_fields/models.py b/localized_fields/models.py index 6d9faa8..e298f04 100644 --- a/localized_fields/models.py +++ b/localized_fields/models.py @@ -1,21 +1,16 @@ -from django.db import models -from django.core.checks import Warning +from psqlextra.models import PostgresModel + +from .mixins import AtomicSlugRetryMixin -class LocalizedModel(models.Model): - """A model keeped for backwards compatibility""" +class LocalizedModel(AtomicSlugRetryMixin, PostgresModel): + """Turns a model into a model that contains LocalizedField's. - @classmethod - def check(cls, **kwargs): - errors = super().check(**kwargs) - errors.append( - Warning( - 'localized_fields.LocalizedModel is deprecated', - hint='There is no need to use localized_fields.LocalizedModel', - obj=cls - ) - ) - return errors + For basic localisation functionality, it isn't needed to inherit + from LocalizedModel. However, for certain features, this is required. + + It is definitely needed for :see:LocalizedUniqueSlugField, unless you + manually inherit from AtomicSlugRetryMixin.""" class Meta: abstract = True diff --git a/localized_fields/localized_value.py b/localized_fields/value.py similarity index 78% rename from localized_fields/localized_value.py rename to localized_fields/value.py index 40d2dfe..cb864b9 100644 --- a/localized_fields/localized_value.py +++ b/localized_fields/value.py @@ -1,3 +1,6 @@ +import collections + + from django.conf import settings from django.utils import translation @@ -16,17 +19,10 @@ class LocalizedValue(dict): different language. """ - # NOTE(seroy): First fill all the keys with default value, - # in order to attributes will be for each language - for lang_code, _ in settings.LANGUAGES: - value = keys.get(lang_code) if isinstance(keys, dict) else \ - self.default_value - self.set(lang_code, value) + super().__init__({}) + self._interpret_value(keys) - if isinstance(keys, str): - setattr(self, settings.LANGUAGE_CODE, keys) - - def get(self, language: str=None) -> str: + def get(self, language: str=None, default: str=None) -> str: """Gets the underlying value in the specified or primary language. @@ -41,7 +37,7 @@ class LocalizedValue(dict): """ language = language or settings.LANGUAGE_CODE - return super().get(language, None) + return super().get(language, default) def set(self, language: str, value: str): """Sets the value in the specified language. @@ -69,6 +65,40 @@ class LocalizedValue(dict): path = 'localized_fields.localized_value.%s' % self.__class__.__name__ return path, [self.__dict__], {} + def _interpret_value(self, value): + """Interprets a value passed in the constructor as + a :see:LocalizedValue. + + If string: + Assumes it's the default language. + + If dict: + Each key is a language and the value a string + in that language. + + If list: + Recurse into to apply rules above. + + Arguments: + value: + The value to interpret. + """ + + for lang_code, _ in settings.LANGUAGES: + self.set(lang_code, self.default_value) + + if isinstance(value, str): + self.set(settings.LANGUAGE_CODE, value) + + elif isinstance(value, dict): + for lang_code, _ in settings.LANGUAGES: + lang_value = value.get(lang_code, self.default_value) + self.set(lang_code, lang_value) + + elif isinstance(value, collections.Iterable): + for val in value: + self._interpret_value(val) + def __str__(self) -> str: """Gets the value in the current language, or falls back to the primary language if there's no value diff --git a/localized_fields/widgets.py b/localized_fields/widgets.py index d321ace..1005e02 100644 --- a/localized_fields/widgets.py +++ b/localized_fields/widgets.py @@ -5,7 +5,7 @@ from django import forms from django.contrib.admin import widgets from django.template.loader import render_to_string -from .localized_value import LocalizedValue +from .value import LocalizedValue class LocalizedFieldWidget(forms.MultiWidget): @@ -15,12 +15,12 @@ class LocalizedFieldWidget(forms.MultiWidget): def __init__(self, *args, **kwargs): """Initializes a new instance of :see:LocalizedFieldWidget.""" - widgets = [] + initial_widgets = [ + self.widget + for _ in settings.LANGUAGES + ] - for _ in settings.LANGUAGES: - widgets.append(self.widget) - - super(LocalizedFieldWidget, self).__init__(widgets, *args, **kwargs) + super().__init__(initial_widgets, *args, **kwargs) def decompress(self, value: LocalizedValue) -> List[str]: """Decompresses the specified value so @@ -36,7 +36,6 @@ class LocalizedFieldWidget(forms.MultiWidget): """ result = [] - for lang_code, _ in settings.LANGUAGES: if value: result.append(value.get(lang_code)) @@ -88,7 +87,8 @@ class AdminLocalizedFieldWidget(LocalizedFieldWidget): } return render_to_string(self.template, context) - def build_widget_attrs(self, widget, value, attrs): + @staticmethod + def build_widget_attrs(widget, value, attrs): attrs = dict(attrs) # Copy attrs to avoid modifying the argument. if (not widget.use_required_attribute(value) or not widget.is_required) \ and 'required' in attrs: diff --git a/settings.py b/settings.py index 4cdade5..2be9ca2 100644 --- a/settings.py +++ b/settings.py @@ -21,6 +21,7 @@ LANGUAGES = ( ) INSTALLED_APPS = ( + 'localized_fields', 'tests', ) diff --git a/setup.py b/setup.py index 898e656..cb672e1 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: setup( name='django-localized-fields', - version='3.6', + version='4.1', packages=find_packages(), include_package_data=True, license='MIT License', @@ -18,7 +18,7 @@ setup( author_email='open-source@sectorlabs.ro', keywords=['django', 'localized', 'language', 'models', 'fields'], install_requires=[ - 'django-postgres-extra>=1.4' + 'django-postgres-extra>=1.11' ], classifiers=[ 'Environment :: Web Environment', diff --git a/tests/data.py b/tests/data.py new file mode 100644 index 0000000..d074ca1 --- /dev/null +++ b/tests/data.py @@ -0,0 +1,13 @@ +from django.conf import settings + + +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 diff --git a/tests/fake_model.py b/tests/fake_model.py index d79e217..dc8cf8e 100644 --- a/tests/fake_model.py +++ b/tests/fake_model.py @@ -2,7 +2,7 @@ from django.db import connection, migrations from django.db.migrations.executor import MigrationExecutor from django.contrib.postgres.operations import HStoreExtension -from localized_fields import LocalizedModel +from localized_fields.models import LocalizedModel def define_fake_model(name='TestModel', fields=None): diff --git a/tests/test_localized_bleach_field.py b/tests/test_bleach_field.py similarity index 96% rename from tests/test_localized_bleach_field.py rename to tests/test_bleach_field.py index 765a507..0901d3f 100644 --- a/tests/test_localized_bleach_field.py +++ b/tests/test_bleach_field.py @@ -1,9 +1,11 @@ +import bleach + from django.conf import settings from django.test import TestCase from django_bleach.utils import get_bleach_default_options -import bleach -from localized_fields import LocalizedBleachField, LocalizedValue +from localized_fields.fields import LocalizedBleachField +from localized_fields.value import LocalizedValue class TestModel: diff --git a/tests/test_bulk.py b/tests/test_bulk.py new file mode 100644 index 0000000..e62f732 --- /dev/null +++ b/tests/test_bulk.py @@ -0,0 +1,45 @@ +from django.db import models +from django.test import TestCase + +from localized_fields.fields import LocalizedField, LocalizedUniqueSlugField + +from .fake_model import get_fake_model + + +class LocalizedBulkTestCase(TestCase): + """Tests bulk operations with data structures provided + by the django-localized-fields library.""" + + @staticmethod + def test_localized_bulk_insert(): + """Tests whether bulk inserts work properly when using + a :see:LocalizedUniqueSlugField in the model.""" + + model = get_fake_model( + 'BulkSlugInsertModel', + { + 'name': LocalizedField(), + 'slug': LocalizedUniqueSlugField(populate_from='name', include_time=True), + 'score': models.IntegerField() + } + ) + + to_create = [ + model(name={'en': 'english name 1', 'ro': 'romanian name 1'}, score=1), + model(name={'en': 'english name 2', 'ro': 'romanian name 2'}, score=2), + model(name={'en': 'english name 3', 'ro': 'romanian name 3'}, score=3) + ] + + model.objects.bulk_create(to_create) + assert model.objects.all().count() == 3 + + for obj in to_create: + obj_db = model.objects.filter( + name__en=obj.name.en, + name__ro=obj.name.ro, + score=obj.score + ).first() + + assert obj_db + assert len(obj_db.slug.en) >= len(obj_db.name.en) + assert len(obj_db.slug.ro) >= len(obj_db.name.ro) diff --git a/tests/test_expressions.py b/tests/test_expressions.py new file mode 100644 index 0000000..91a2c98 --- /dev/null +++ b/tests/test_expressions.py @@ -0,0 +1,86 @@ +from django.test import TestCase +from django.db import models +from django.utils import translation +from django.conf import settings +from django.contrib.postgres.aggregates import ArrayAgg + +from localized_fields.fields import LocalizedField +from localized_fields.value import LocalizedValue +from localized_fields.expressions import LocalizedRef + +from .fake_model import get_fake_model + + +class LocalizedExpressionsTestCase(TestCase): + """Tests whether expressions properly work with :see:LocalizedField.""" + + TestModel1 = None + TestModel2 = None + + @classmethod + def setUpClass(cls): + """Creates the test model in the database.""" + + super(LocalizedExpressionsTestCase, cls).setUpClass() + + cls.TestModel1 = get_fake_model( + 'LocalizedExpressionsTestCase2', + { + 'name': models.CharField(null=False, blank=False, max_length=255), + } + ) + + cls.TestModel2 = get_fake_model( + 'LocalizedExpressionsTestCase1', + { + 'text': LocalizedField(), + 'other': models.ForeignKey(cls.TestModel1, related_name='features') + } + ) + + @classmethod + def test_localized_ref(cls): + """Tests whether the :see:LocalizedRef expression properly works.""" + + obj = cls.TestModel1.objects.create(name='bla bla') + for i in range(0, 10): + cls.TestModel2.objects.create( + text=LocalizedValue(dict(en='text_%d_en' % i, ro='text_%d_ro' % i, nl='text_%d_nl' % i)), + other=obj + ) + + def create_queryset(ref): + return ( + cls.TestModel1.objects + .annotate(mytexts=ref) + .values_list('mytexts', flat=True) + ) + + # assert that it properly selects the currently active language + for lang_code, _ in settings.LANGUAGES: + translation.activate(lang_code) + queryset = create_queryset(LocalizedRef('features__text')) + + for index, value in enumerate(queryset): + assert translation.get_language() in value + assert str(index) in value + + # ensure that the default language is used in case no + # language is active at all + translation.deactivate_all() + queryset = create_queryset(LocalizedRef('features__text')) + for index, value in enumerate(queryset): + assert settings.LANGUAGE_CODE in value + assert str(index) in value + + # ensures that overriding the language works properly + queryset = create_queryset(LocalizedRef('features__text', 'ro')) + for index, value in enumerate(queryset): + assert 'ro' in value + assert str(index) in value + + # ensures that using this in combination with ArrayAgg works properly + queryset = create_queryset(ArrayAgg(LocalizedRef('features__text', 'ro'))).first() + assert isinstance(queryset, list) + for value in queryset: + assert 'ro' in value diff --git a/tests/test_field.py b/tests/test_field.py new file mode 100644 index 0000000..b998a46 --- /dev/null +++ b/tests/test_field.py @@ -0,0 +1,158 @@ +import json + +from django.conf import settings +from django.db.utils import IntegrityError +from django.test import TestCase + +from localized_fields.fields import LocalizedField +from localized_fields.forms import LocalizedFieldForm +from localized_fields.value import LocalizedValue + +from .data import get_init_values + + +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_value 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 + + def test_from_db_value_none_return_none(self): + """Tests whether the :see:from_db_value function + correctly handles None values when LOCALIZED_FIELDS_EXPERIMENTAL + is set to True.""" + + with self.settings(LOCALIZED_FIELDS_EXPERIMENTAL=True): + localized_value = LocalizedField.from_db_value(None) + + assert localized_value 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_non_json(): + """Tests whether the :see:to_python function + properly handles a string that is not JSON.""" + + localized_value = LocalizedField().to_python('my value') + assert localized_value.get() == 'my 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_to_python_str(): + """Tests whether the :see:to_python function produces + the expected :see:LocalizedValue when it is + passed serialized string value.""" + + serialized_str = json.dumps(get_init_values()) + localized_value = LocalizedField().to_python(serialized_str) + assert isinstance(localized_value, LocalizedValue) + + for language, value in get_init_values().items(): + assert localized_value.get(language) == value + assert getattr(localized_value, language) == value + + @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/tests/test_localized_field_form.py b/tests/test_form.py similarity index 94% rename from tests/test_localized_field_form.py rename to tests/test_form.py index abc79c7..a5b1157 100644 --- a/tests/test_localized_field_form.py +++ b/tests/test_form.py @@ -1,7 +1,7 @@ from django.conf import settings from django.test import TestCase -from localized_fields import LocalizedFieldForm +from localized_fields.forms import LocalizedFieldForm class LocalizedFieldFormTestCase(TestCase): diff --git a/tests/test_localized_field.py b/tests/test_localized_field.py deleted file mode 100644 index 45d657f..0000000 --- a/tests/test_localized_field.py +++ /dev/null @@ -1,288 +0,0 @@ -from django.conf import settings -from django.db.utils import IntegrityError -from django.test import TestCase -from django.utils import translation - -from localized_fields import LocalizedField, LocalizedFieldForm, LocalizedValue - - -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_default_language(): - """Tests whether the :see:LocalizedValue - class's see:get function properly - gets the value in the default language.""" - - keys = get_init_values() - localized_value = LocalizedValue(keys) - - for language, _ in keys.items(): - translation.activate(language) - assert localized_value.get() == keys[settings.LANGUAGE_CODE] - - @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_eq(): - """Tests whether the __eq__ operator - of :see:LocalizedValue works properly.""" - - a = LocalizedValue({'en': 'a', 'ar': 'b'}) - b = LocalizedValue({'en': 'a', 'ar': 'b'}) - - assert a == b - - b.en = 'b' - assert a != b - - @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 - - @staticmethod - def test_deconstruct(): - """Tests whether the :see:LocalizedValue - class's :see:deconstruct function works properly.""" - - keys = get_init_values() - value = LocalizedValue(keys) - - path, args, kwargs = value.deconstruct() - - assert args[0] == keys - - @staticmethod - def test_construct_string(): - """Tests whether the :see:LocalizedValue's constructor - assumes the primary language when passing a single string.""" - - value = LocalizedValue('beer') - assert value.get(settings.LANGUAGE_CODE) == 'beer' - - -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_value 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 - - def test_from_db_value_none_return_none(self): - """Tests whether the :see:from_db_value function - correctly handles None values when LOCALIZED_FIELDS_EXPERIMENTAL - is set to True.""" - - with self.settings(LOCALIZED_FIELDS_EXPERIMENTAL=True): - localized_value = LocalizedField.from_db_value(None) - - assert localized_value 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/tests/test_localized_file_field.py b/tests/test_localized_file_field.py index 6dcecc2..890bcc2 100644 --- a/tests/test_localized_file_field.py +++ b/tests/test_localized_file_field.py @@ -7,10 +7,11 @@ from django import forms from django.test import TestCase, override_settings from django.core.files.base import File, ContentFile from django.core.files import temp as tempfile -from localized_fields import LocalizedFileField, LocalizedValue +from localized_fields.fields import LocalizedFileField +from localized_fields.value import LocalizedValue from localized_fields.fields.localized_file_field import LocalizedFieldFile from localized_fields.forms import LocalizedFileFieldForm -from localized_fields.localized_value import LocalizedFileValue +from localized_fields.value import LocalizedFileValue from localized_fields.widgets import LocalizedFileWidget from .fake_model import get_fake_model diff --git a/tests/test_localized_model.py b/tests/test_model.py similarity index 93% rename from tests/test_localized_model.py rename to tests/test_model.py index 908684c..974b975 100644 --- a/tests/test_localized_model.py +++ b/tests/test_model.py @@ -1,6 +1,7 @@ from django.test import TestCase -from localized_fields import LocalizedField, LocalizedValue +from localized_fields.fields import LocalizedField +from localized_fields.value import LocalizedValue from .fake_model import get_fake_model @@ -33,7 +34,6 @@ class LocalizedModelTestCase(TestCase): assert isinstance(obj.title, LocalizedValue) - @classmethod def test_model_init_kwargs(cls): """Tests whether all :see:LocalizedField diff --git a/tests/test_localized_slug_fields.py b/tests/test_slug_fields.py similarity index 98% rename from tests/test_localized_slug_fields.py rename to tests/test_slug_fields.py index 51fac26..a2f25ad 100644 --- a/tests/test_localized_slug_fields.py +++ b/tests/test_slug_fields.py @@ -4,10 +4,14 @@ from django import forms from django.conf import settings from django.test import TestCase from django.db.utils import IntegrityError -from localized_fields import (LocalizedField, LocalizedAutoSlugField, - LocalizedUniqueSlugField) from django.utils.text import slugify +from localized_fields.fields import ( + LocalizedField, + LocalizedAutoSlugField, + LocalizedUniqueSlugField +) + from .fake_model import get_fake_model diff --git a/tests/test_value.py b/tests/test_value.py new file mode 100644 index 0000000..aaffd99 --- /dev/null +++ b/tests/test_value.py @@ -0,0 +1,164 @@ +from django.conf import settings +from django.test import TestCase +from django.utils import translation + +from localized_fields.value import LocalizedValue + +from .data import get_init_values + + +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 whether 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_init_array(): + """Tests whether the __init__ function + of :see:LocalizedValue properly handles an + array. + + Arrays can be passed to LocalizedValue as + a result of a ArrayAgg operation.""" + + value = LocalizedValue(['my value']) + assert value.get(settings.LANGUAGE_CODE) == 'my value' + + @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_default_language(): + """Tests whether the :see:LocalizedValue + class's see:get function properly + gets the value in the default language.""" + + keys = get_init_values() + localized_value = LocalizedValue(keys) + + for language, _ in keys.items(): + translation.activate(language) + assert localized_value.get() == keys[settings.LANGUAGE_CODE] + + @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_eq(): + """Tests whether the __eq__ operator + of :see:LocalizedValue works properly.""" + + a = LocalizedValue({'en': 'a', 'ar': 'b'}) + b = LocalizedValue({'en': 'a', 'ar': 'b'}) + + assert a == b + + b.en = 'b' + assert a != b + + @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 + + @staticmethod + def test_deconstruct(): + """Tests whether the :see:LocalizedValue + class's :see:deconstruct function works properly.""" + + keys = get_init_values() + value = LocalizedValue(keys) + + path, args, kwargs = value.deconstruct() + + assert args[0] == keys + + @staticmethod + def test_construct_string(): + """Tests whether the :see:LocalizedValue's constructor + assumes the primary language when passing a single string.""" + + value = LocalizedValue('beer') + assert value.get(settings.LANGUAGE_CODE) == 'beer' diff --git a/tests/test_localized_field_widget.py b/tests/test_widget.py similarity index 92% rename from tests/test_localized_field_widget.py rename to tests/test_widget.py index 2e65ac8..a1ed753 100644 --- a/tests/test_localized_field_widget.py +++ b/tests/test_widget.py @@ -1,7 +1,8 @@ from django.conf import settings from django.test import TestCase -from localized_fields import LocalizedFieldWidget, LocalizedValue +from localized_fields.value import LocalizedValue +from localized_fields.widgets import LocalizedFieldWidget class LocalizedFieldWidgetTestCase(TestCase):