From 817c7e13feadb83597faeb7cc13a5781bb1bcf48 Mon Sep 17 00:00:00 2001 From: seroy Date: Wed, 12 Apr 2017 21:32:30 +0300 Subject: [PATCH] added new LocalizedCharField, LocalizedTextField and LocalizedFileField fields --- localized_fields/__init__.py | 6 +- localized_fields/admin.py | 6 +- localized_fields/fields/__init__.py | 6 + .../fields/localized_char_field.py | 16 ++ localized_fields/fields/localized_field.py | 9 +- .../fields/localized_file_field.py | 151 ++++++++++++++++++ .../fields/localized_text_field.py | 14 ++ localized_fields/forms.py | 115 ++++++++++++- localized_fields/localized_value.py | 37 ++++- localized_fields/widgets.py | 18 +++ 10 files changed, 364 insertions(+), 14 deletions(-) create mode 100644 localized_fields/fields/localized_char_field.py create mode 100644 localized_fields/fields/localized_file_field.py create mode 100644 localized_fields/fields/localized_text_field.py diff --git a/localized_fields/__init__.py b/localized_fields/__init__.py index f9325cd..b6b1714 100644 --- a/localized_fields/__init__.py +++ b/localized_fields/__init__.py @@ -1,6 +1,7 @@ from .forms import LocalizedFieldForm, LocalizedFieldWidget from .fields import (LocalizedAutoSlugField, LocalizedField, - LocalizedUniqueSlugField) + LocalizedUniqueSlugField, LocalizedCharField, + LocalizedTextField, LocalizedFileField) from .localized_value import LocalizedValue from .mixins import AtomicSlugRetryMixin from .models import LocalizedModel @@ -14,6 +15,9 @@ __all__ = [ 'LocalizedAutoSlugField', 'LocalizedUniqueSlugField', 'LocalizedBleachField', + 'LocalizedCharField', + 'LocalizedTextField', + 'LocalizedFileField', 'LocalizedFieldWidget', 'LocalizedFieldForm', 'AtomicSlugRetryMixin' diff --git a/localized_fields/admin.py b/localized_fields/admin.py index fdb2227..d6615fa 100644 --- a/localized_fields/admin.py +++ b/localized_fields/admin.py @@ -1,11 +1,15 @@ from django.contrib.admin import ModelAdmin -from .fields import LocalizedField +from .fields import LocalizedField, LocalizedCharField, LocalizedTextField, \ + LocalizedFileField from . import widgets FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = { LocalizedField: {'widget': widgets.AdminLocalizedFieldWidget}, + LocalizedCharField: {'widget': widgets.AdminLocalizedCharFieldWidget}, + LocalizedTextField: {'widget': widgets.AdminLocalizedFieldWidget}, + LocalizedFileField: {'widget': widgets.AdminLocalizedFileFieldWidget}, } diff --git a/localized_fields/fields/__init__.py b/localized_fields/fields/__init__.py index a634894..0f61e82 100644 --- a/localized_fields/fields/__init__.py +++ b/localized_fields/fields/__init__.py @@ -1,12 +1,18 @@ from .localized_field import LocalizedField from .localized_autoslug_field import LocalizedAutoSlugField from .localized_uniqueslug_field import LocalizedUniqueSlugField +from .localized_char_field import LocalizedCharField +from .localized_text_field import LocalizedTextField +from .localized_file_field import LocalizedFileField __all__ = [ 'LocalizedField', 'LocalizedAutoSlugField', 'LocalizedUniqueSlugField', + 'LocalizedCharField', + 'LocalizedTextField', + 'LocalizedFileField' ] try: diff --git a/localized_fields/fields/localized_char_field.py b/localized_fields/fields/localized_char_field.py new file mode 100644 index 0000000..047ab0a --- /dev/null +++ b/localized_fields/fields/localized_char_field.py @@ -0,0 +1,16 @@ +from ..forms import LocalizedCharFieldForm +from .localized_field import LocalizedField +from ..localized_value import LocalizedStingValue + + +class LocalizedCharField(LocalizedField): + attr_class = LocalizedStingValue + + def formfield(self, **kwargs): + """Gets the form field associated with this field.""" + defaults = { + 'form_class': LocalizedCharFieldForm + } + + defaults.update(kwargs) + return super().formfield(**defaults) diff --git a/localized_fields/fields/localized_field.py b/localized_fields/fields/localized_field.py index 1f3d205..05b80a9 100644 --- a/localized_fields/fields/localized_field.py +++ b/localized_fields/fields/localized_field.py @@ -187,7 +187,10 @@ class LocalizedField(HStoreField): # are any of the language fiels None/empty? is_all_null = True for lang_code, _ in settings.LANGUAGES: - if value.get(lang_code): + # NOTE(seroy): use check for None, instead of + # `bool(value.get(lang_code))==True` condition, cause in this way + # we can not save '' value + if value.get(lang_code) is not None: is_all_null = False break @@ -215,7 +218,9 @@ class LocalizedField(HStoreField): primary_lang_val = getattr(value, settings.LANGUAGE_CODE) - if not primary_lang_val: + # NOTE(seroy): use check for None, instead of `not primary_lang_val` + # condition, cause in this way we can not save '' value + if primary_lang_val is None: raise IntegrityError( 'null value in column "%s.%s" violates not-null constraint' % ( self.name, diff --git a/localized_fields/fields/localized_file_field.py b/localized_fields/fields/localized_file_field.py new file mode 100644 index 0000000..d781471 --- /dev/null +++ b/localized_fields/fields/localized_file_field.py @@ -0,0 +1,151 @@ +import datetime +import posixpath + +from django.core.files import File +from django.db.models.fields.files import FieldFile +from django.utils import six +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_value import LocalizedFileValue +from ..forms import LocalizedFileFieldForm + + +class LocalizedFieldFile(FieldFile): + def __init__(self, instance, field, name): + super(FieldFile, self).__init__(None, name) + self.instance = instance + self.field = field + self.storage = field.storage + self._committed = True + + def save(self, name, content, lang, save=True): + name = self.field.generate_filename(self.instance, name, lang) + self.name = self.storage.save(name, content, + max_length=self.field.max_length) + self._committed = True + + if save: + self.instance.save() + + save.alters_data = True + + def delete(self, save=True): + if not self: + return + + if hasattr(self, '_file'): + self.close() + del self.file + + self.storage.delete(self.name) + + self.name = None + self._committed = False + + if save: + self.instance.save() + + delete.alters_data = True + + +class LocalizedFileValueDescriptor(LocalizedValueDescriptor): + def __get__(self, instance, cls=None): + value = super().__get__(instance, cls) + for k, file in value.__dict__.items(): + if isinstance(file, six.string_types) or file is None: + file = self.field.value_class(instance, self.field, file) + value.set(k, file) + + elif isinstance(file, File) and \ + not isinstance(file, LocalizedFieldFile): + file_copy = self.field.value_class(instance, self.field, + file.name) + file_copy.file = file + file_copy._committed = False + value.set(k, file_copy) + + elif isinstance(file, LocalizedFieldFile) and \ + not hasattr(file, 'field'): + file.instance = instance + file.field = self.field + file.storage = self.field.storage + + # Make sure that the instance is correct. + elif isinstance(file, LocalizedFieldFile) \ + and instance is not file.instance: + file.instance = instance + return value + + +class LocalizedFileField(LocalizedField): + descriptor_class = LocalizedFileValueDescriptor + attr_class = LocalizedFileValue + value_class = LocalizedFieldFile + + def __init__(self, verbose_name=None, name=None, upload_to='', storage=None, + **kwargs): + + self.storage = storage or default_storage + self.upload_to = upload_to + + super().__init__(verbose_name, name, **kwargs) + + def deconstruct(self): + name, path, args, kwargs = super(LocalizedFileField, self).deconstruct() + kwargs['upload_to'] = self.upload_to + if self.storage is not default_storage: + kwargs['storage'] = self.storage + return name, path, args, kwargs + + def get_prep_value(self, value): + """Returns field's value prepared for saving into a database.""" + + if isinstance(value, LocalizedValue): + prep_value = LocalizedValue() + for k, v in value.__dict__.items(): + if v is None: + prep_value.set(k, '') + else: + # Need to convert File objects provided via a form to + # unicode for database insertion + prep_value.set(k, six.text_type(v)) + return super().get_prep_value(prep_value) + return super().get_prep_value(value) + + def pre_save(self, model_instance, add): + """Returns field's value just before saving.""" + value = super().pre_save(model_instance, add) + if isinstance(value, LocalizedValue): + for lang, file in value.__dict__.items(): + if file and not file._committed: + file.save(file.name, file, lang, save=False) + return value + + def generate_filename(self, instance, filename, lang): + if callable(self.upload_to): + filename = self.upload_to(instance, filename, lang) + else: + now = datetime.datetime.now() + dirname = force_text(now.strftime(force_str(self.upload_to))) + dirname = dirname.format(lang=lang) + filename = posixpath.join(dirname, filename) + return self.storage.generate_filename(filename) + + def save_form_data(self, instance, data): + if isinstance(data, LocalizedValue): + for k, v in data.__dict__.items(): + if v is not None and not v: + data.set(k, '') + setattr(instance, self.attname, data) + + def formfield(self, **kwargs): + defaults = {'form_class': LocalizedFileFieldForm} + if 'initial' in kwargs: + defaults['required'] = False + defaults.update(kwargs) + return super().formfield(**defaults) diff --git a/localized_fields/fields/localized_text_field.py b/localized_fields/fields/localized_text_field.py new file mode 100644 index 0000000..c76d4de --- /dev/null +++ b/localized_fields/fields/localized_text_field.py @@ -0,0 +1,14 @@ +from ..forms import LocalizedTextFieldForm +from .localized_char_field import LocalizedCharField + + +class LocalizedTextField(LocalizedCharField): + def formfield(self, **kwargs): + """Gets the form field associated with this field.""" + + defaults = { + 'form_class': LocalizedTextFieldForm + } + + defaults.update(kwargs) + return super().formfield(**defaults) diff --git a/localized_fields/forms.py b/localized_fields/forms.py index 2475673..7126b70 100644 --- a/localized_fields/forms.py +++ b/localized_fields/forms.py @@ -2,10 +2,13 @@ from typing import List from django import forms from django.conf import settings +from django.core.exceptions import ValidationError +from django.forms.widgets import FILE_INPUT_CONTRADICTION -from .localized_value import LocalizedValue -from .widgets import LocalizedFieldWidget - +from .localized_value import LocalizedValue, LocalizedStingValue, \ + LocalizedFileValue +from .widgets import LocalizedFieldWidget, LocalizedCharFieldWidget, \ + LocalizedFileWidget class LocalizedFieldForm(forms.MultiValueField): @@ -13,6 +16,7 @@ class LocalizedFieldForm(forms.MultiValueField): the field in multiple languages.""" widget = LocalizedFieldWidget + field_class = forms.fields.CharField value_class = LocalizedValue def __init__(self, *args, **kwargs): @@ -27,7 +31,7 @@ class LocalizedFieldForm(forms.MultiValueField): field_options['required'] = kwargs.get('required', True) field_options['label'] = lang_code - fields.append(forms.fields.CharField(**field_options)) + fields.append(self.field_class(**field_options)) super(LocalizedFieldForm, self).__init__( fields, @@ -57,3 +61,106 @@ class LocalizedFieldForm(forms.MultiValueField): localized_value.set(lang_code, value) return localized_value + + +class LocalizedCharFieldForm(LocalizedFieldForm): + """Form for a localized char field, allows editing + the field in multiple languages.""" + + widget = LocalizedCharFieldWidget + value_class = LocalizedStingValue + + +class LocalizedTextFieldForm(LocalizedFieldForm): + """Form for a localized text field, allows editing + the field in multiple languages.""" + + value_class = LocalizedStingValue + + +class LocalizedFileFieldForm(LocalizedFieldForm, forms.FileField): + """Form for a localized file field, allows editing + the field in multiple languages.""" + + widget = LocalizedFileWidget + field_class = forms.fields.FileField + value_class = LocalizedFileValue + + def clean(self, value, initial=None): + """ + Most part of this method is a copy of + django.forms.MultiValueField.clean, with the exception of initial + value handling (this need for correct processing FileField's). + All original comments saved. + """ + if initial is None: + initial = [None for x in range(0, len(value))] + else: + if not isinstance(initial, list): + initial = self.widget.decompress(initial) + + clean_data = [] + errors = [] + if not value or isinstance(value, (list, tuple)): + if (not value or not [v for v in value if + v not in self.empty_values]) \ + and (not initial or not [v for v in initial if + v not in self.empty_values]): + if self.required: + raise ValidationError(self.error_messages['required'], + code='required') + else: + raise ValidationError(self.error_messages['invalid'], + code='invalid') + for i, field in enumerate(self.fields): + try: + field_value = value[i] + except IndexError: + field_value = None + try: + field_initial = initial[i] + except IndexError: + field_initial = None + if field_value in self.empty_values and \ + field_initial in self.empty_values: + if self.require_all_fields: + # Raise a 'required' error if the MultiValueField is + # required and any field is empty. + if self.required: + raise ValidationError(self.error_messages['required'], + code='required') + elif field.required: + # Otherwise, add an 'incomplete' error to the list of + # collected errors and skip field cleaning, if a required + # field is empty. + if field.error_messages['incomplete'] not in errors: + errors.append(field.error_messages['incomplete']) + continue + try: + clean_data.append(field.clean(field_value, field_initial)) + except ValidationError as e: + # Collect all validation errors in a single list, which we'll + # raise at the end of clean(), rather than raising a single + # exception for the first error we encounter. Skip duplicates. + errors.extend(m for m in e.error_list if m not in errors) + if errors: + raise ValidationError(errors) + + out = self.compress(clean_data) + self.validate(out) + self.run_validators(out) + return out + + def bound_data(self, data, initial): + bound_data = [] + if initial is None: + initial = [None for x in range(0, len(data))] + else: + if not isinstance(initial, list): + initial = self.widget.decompress(initial) + for d, i in zip(data, initial): + if d in (None, FILE_INPUT_CONTRADICTION): + bound_data.append(i) + else: + bound_data.append(d) + return bound_data diff --git a/localized_fields/localized_value.py b/localized_fields/localized_value.py index 6bea382..c47ee33 100644 --- a/localized_fields/localized_value.py +++ b/localized_fields/localized_value.py @@ -4,6 +4,7 @@ from django.utils import translation class LocalizedValue(dict): """Represents the value of a :see:LocalizedField.""" + default_value = None def __init__(self, keys: dict=None): """Initializes a new instance of :see:LocalizedValue. @@ -15,12 +16,15 @@ 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) + if isinstance(keys, str): setattr(self, settings.LANGUAGE_CODE, keys) - else: - for lang_code, _ in settings.LANGUAGES: - value = keys.get(lang_code) if keys else None - self.set(lang_code, value) def get(self, language: str=None) -> str: """Gets the underlying value in the specified or @@ -62,7 +66,7 @@ class LocalizedValue(dict): contained in this instance. """ - path = 'localized_fields.fields.LocalizedValue' + path = 'localized_fields.localized_value.%s' % self.__class__.__name__ return path, [self.__dict__], {} def __str__(self) -> str: @@ -124,4 +128,25 @@ class LocalizedValue(dict): def __repr__(self): # pragma: no cover """Gets a textual representation of this object.""" - return 'LocalizedValue<%s> 0x%s' % (dict(self), id(self)) + return '%s<%s> 0x%s' % (self.__class__.__name__, + self.__dict__, id(self)) + + +class LocalizedStingValue(LocalizedValue): + default_value = '' + + +class LocalizedFileValue(LocalizedValue): + + def __getattr__(self, name): + value = self.get(translation.get_language()) + if hasattr(value, name): + return getattr(value, name) + raise AttributeError("'{}' object has no attribute '{}'". + format(self.__class__.__name__, name)) + + def __str__(self): + return str(super().__str__()) + + def localized(self): + return self.get(translation.get_language()) diff --git a/localized_fields/widgets.py b/localized_fields/widgets.py index 36e9280..d321ace 100644 --- a/localized_fields/widgets.py +++ b/localized_fields/widgets.py @@ -46,6 +46,16 @@ class LocalizedFieldWidget(forms.MultiWidget): return result +class LocalizedCharFieldWidget(LocalizedFieldWidget): + """Widget that has an input box for every language.""" + widget = forms.TextInput + + +class LocalizedFileWidget(LocalizedFieldWidget): + """Widget that has an file input box for every language.""" + widget = forms.ClearableFileInput + + class AdminLocalizedFieldWidget(LocalizedFieldWidget): widget = widgets.AdminTextareaWidget template = 'localized_fields/admin/widget.html' @@ -84,3 +94,11 @@ class AdminLocalizedFieldWidget(LocalizedFieldWidget): and 'required' in attrs: del attrs['required'] return attrs + + +class AdminLocalizedCharFieldWidget(AdminLocalizedFieldWidget): + widget = widgets.AdminTextInputWidget + + +class AdminLocalizedFileFieldWidget(AdminLocalizedFieldWidget): + widget = widgets.AdminFileWidget