From 817c7e13feadb83597faeb7cc13a5781bb1bcf48 Mon Sep 17 00:00:00 2001 From: seroy Date: Wed, 12 Apr 2017 21:32:30 +0300 Subject: [PATCH 01/10] 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 From 5b93c5ec8f58eaf3925cd217e9d44c4f2a5d0d37 Mon Sep 17 00:00:00 2001 From: seroy Date: Wed, 12 Apr 2017 22:10:12 +0300 Subject: [PATCH 02/10] added style for AdminLocalizedFileFieldWidget --- .../static/localized_fields/localized-fields-admin.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/localized_fields/static/localized_fields/localized-fields-admin.css b/localized_fields/static/localized_fields/localized-fields-admin.css index df66ca6..6413fe0 100644 --- a/localized_fields/static/localized_fields/localized-fields-admin.css +++ b/localized_fields/static/localized_fields/localized-fields-admin.css @@ -45,3 +45,7 @@ border-color: #79aec8; opacity: 1; } + +.localized-fields-widget p.file-upload { + margin-left: 0; +} From 0f4c74a9b20ce3bb3d7096ebebb2232676d47f9f Mon Sep 17 00:00:00 2001 From: seroy Date: Thu, 13 Apr 2017 11:07:40 +0300 Subject: [PATCH 03/10] added comments and deleted extra code --- localized_fields/fields/localized_file_field.py | 8 +------- localized_fields/localized_value.py | 7 +++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/localized_fields/fields/localized_file_field.py b/localized_fields/fields/localized_file_field.py index d781471..b1dfb4b 100644 --- a/localized_fields/fields/localized_file_field.py +++ b/localized_fields/fields/localized_file_field.py @@ -16,12 +16,6 @@ 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) @@ -96,7 +90,7 @@ class LocalizedFileField(LocalizedField): super().__init__(verbose_name, name, **kwargs) def deconstruct(self): - name, path, args, kwargs = super(LocalizedFileField, self).deconstruct() + name, path, args, kwargs = super().deconstruct() kwargs['upload_to'] = self.upload_to if self.storage is not default_storage: kwargs['storage'] = self.storage diff --git a/localized_fields/localized_value.py b/localized_fields/localized_value.py index c47ee33..895e3a9 100644 --- a/localized_fields/localized_value.py +++ b/localized_fields/localized_value.py @@ -137,16 +137,19 @@ class LocalizedStingValue(LocalizedValue): class LocalizedFileValue(LocalizedValue): + def __getattr__(self, name: str): + """Proxies access to attributes to attributes of LocalizedFile""" - 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): + def __str__(self) -> str: + """Returns string representation of value""" return str(super().__str__()) def localized(self): + """Returns value for current language""" return self.get(translation.get_language()) From 24079a2fcba7bf22f67b08c6876ffb153d2d1ef5 Mon Sep 17 00:00:00 2001 From: seroy Date: Thu, 13 Apr 2017 11:11:52 +0300 Subject: [PATCH 04/10] added description of LocalizedCharField, LocalizedTextField and LocalizedFileField --- README.rst | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README.rst b/README.rst index bf92714..9feee37 100644 --- a/README.rst +++ b/README.rst @@ -240,6 +240,61 @@ Besides ``LocalizedField``, there's also: title = LocalizedField() description = LocalizedBleachField() +* ``LocalizedCharField`` and ``LocalizedTextField`` + This fields following the Django convention for string-based fields use + the empty string as value for “no data”, not NULL. + ``LocalizedCharField`` uses ``TextInput`` (````) widget for render. + + Example usage: + + .. code-block:: python + + from localized_fields import (LocalizedCharField, + LocalizedTextField) + + class MyModel(models.Model): + title = LocalizedCharField() + description = LocalizedTextField() + +* ``LocalizedFileField`` + A file-upload field + + Parameter ``upload_to`` supports ``lang`` parameter for string formatting or as function argument (in case if ``upload_to`` is callable). + + Example usage: + + .. code-block:: python + + from localized_fields import LocalizedFileField + + def my_directory_path(instance, filename, lang): + # file will be uploaded to MEDIA_ROOT//_ + return '{0}/{0}_{1}'.format(lang, instance.id, filename) + + class MyModel(models.Model): + file1 = LocalizedFileField(upload_to='uploads/{lang}/') + file2 = LocalizedFileField(upload_to=my_directory_path) + + In template you can access to file attributes: + + .. code-block:: django + + {# For current active language: #} + + {{ model.file.url }} {# output file url #} + {{ model.file.name }} {# output file name #} + + {# Or get it in a specific language: #} + + {{ model.file.ro.url }} {# output file url for romanian language #} + {{ model.file.ro.name }} {# output file name for romanian language #} + + To get access to file instance for current active language use ``localized`` method: + + .. code-block:: python + + model.file.localized() + Experimental feature ^^^^^^^^^^^^^^^^^^^^ Enables the following experimental features: From 8ba08c389c59e7baa80367ff0b75ed144284d85d Mon Sep 17 00:00:00 2001 From: seroy Date: Thu, 13 Apr 2017 16:15:13 +0300 Subject: [PATCH 05/10] changed indentation --- README.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 9feee37..081fe12 100644 --- a/README.rst +++ b/README.rst @@ -241,11 +241,10 @@ Besides ``LocalizedField``, there's also: description = LocalizedBleachField() * ``LocalizedCharField`` and ``LocalizedTextField`` - This fields following the Django convention for string-based fields use - the empty string as value for “no data”, not NULL. + This fields following the Django convention for string-based fields use the empty string as value for “no data”, not NULL. ``LocalizedCharField`` uses ``TextInput`` (````) widget for render. - Example usage: + Example usage: .. code-block:: python From a35238824333b108853fa4f6cd129699c0b6481a Mon Sep 17 00:00:00 2001 From: seroy Date: Mon, 24 Apr 2017 20:29:09 +0300 Subject: [PATCH 06/10] refactored LocalizedFieldFile.save method --- .../fields/localized_file_field.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/localized_fields/fields/localized_file_field.py b/localized_fields/fields/localized_file_field.py index b1dfb4b..05c5af6 100644 --- a/localized_fields/fields/localized_file_field.py +++ b/localized_fields/fields/localized_file_field.py @@ -17,8 +17,12 @@ from ..forms import LocalizedFileFieldForm class LocalizedFieldFile(FieldFile): - def save(self, name, content, lang, save=True): - name = self.field.generate_filename(self.instance, name, lang) + def __init__(self, instance, field, name, lang): + super().__init__(instance, field, name) + self.lang = lang + + def save(self, name, content, save=True): + name = self.field.generate_filename(self.instance, name, self.lang) self.name = self.storage.save(name, content, max_length=self.field.max_length) self._committed = True @@ -50,29 +54,31 @@ class LocalizedFieldFile(FieldFile): class LocalizedFileValueDescriptor(LocalizedValueDescriptor): def __get__(self, instance, cls=None): value = super().__get__(instance, cls) - for k, file in value.__dict__.items(): + for lang, 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) + file = self.field.value_class(instance, self.field, file, lang) + value.set(lang, file) elif isinstance(file, File) and \ not isinstance(file, LocalizedFieldFile): file_copy = self.field.value_class(instance, self.field, - file.name) + file.name, lang) file_copy.file = file file_copy._committed = False - value.set(k, file_copy) + value.set(lang, file_copy) elif isinstance(file, LocalizedFieldFile) and \ not hasattr(file, 'field'): file.instance = instance file.field = self.field file.storage = self.field.storage + file.lang = lang # Make sure that the instance is correct. elif isinstance(file, LocalizedFieldFile) \ and instance is not file.instance: file.instance = instance + file.lang = lang return value @@ -115,9 +121,9 @@ class LocalizedFileField(LocalizedField): """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(): + for file in value.__dict__.values(): if file and not file._committed: - file.save(file.name, file, lang, save=False) + file.save(file.name, file, save=False) return value def generate_filename(self, instance, filename, lang): @@ -135,7 +141,7 @@ class LocalizedFileField(LocalizedField): for k, v in data.__dict__.items(): if v is not None and not v: data.set(k, '') - setattr(instance, self.attname, data) + setattr(instance, self.name, data) def formfield(self, **kwargs): defaults = {'form_class': LocalizedFileFieldForm} From db93b93046310842813cc70da566a9c6b9e1fb02 Mon Sep 17 00:00:00 2001 From: seroy Date: Mon, 24 Apr 2017 20:30:44 +0300 Subject: [PATCH 07/10] added test for LocalizedFileField and LocalizedFileFieldForm --- tests/test_localized_file_field.py | 151 ++++++++++++++++++++++++ tests/test_localized_file_field_form.py | 41 +++++++ 2 files changed, 192 insertions(+) create mode 100644 tests/test_localized_file_field.py create mode 100644 tests/test_localized_file_field_form.py diff --git a/tests/test_localized_file_field.py b/tests/test_localized_file_field.py new file mode 100644 index 0000000..6dcecc2 --- /dev/null +++ b/tests/test_localized_file_field.py @@ -0,0 +1,151 @@ +import os +import shutil +import tempfile as sys_tempfile +import pickle + +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.localized_file_field import LocalizedFieldFile +from localized_fields.forms import LocalizedFileFieldForm +from localized_fields.localized_value import LocalizedFileValue +from localized_fields.widgets import LocalizedFileWidget +from .fake_model import get_fake_model + + +MEDIA_ROOT = sys_tempfile.mkdtemp() + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class LocalizedFileFieldTestCase(TestCase): + """Tests the localized slug classes.""" + + @classmethod + def setUpClass(cls): + """Creates the test models in the database.""" + + super().setUpClass() + + cls.FileFieldModel = get_fake_model( + 'LocalizedFileFieldTestModel', + { + 'file': LocalizedFileField(), + } + ) + if not os.path.isdir(MEDIA_ROOT): + os.makedirs(MEDIA_ROOT) + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + shutil.rmtree(MEDIA_ROOT) + + @classmethod + def test_assign(cls): + """Tests whether the :see:LocalizedFileValueDescriptor works properly""" + + temp_file = tempfile.NamedTemporaryFile(dir=MEDIA_ROOT) + instance = cls.FileFieldModel() + instance.file = {'en': temp_file.name} + assert isinstance(instance.file.en, LocalizedFieldFile) + assert instance.file.en.name == temp_file.name + + field_dump = pickle.dumps(instance.file) + instance = cls.FileFieldModel() + instance.file = pickle.loads(field_dump) + assert instance.file.en.field == instance._meta.get_field('file') + assert instance.file.en.instance == instance + assert isinstance(instance.file.en, LocalizedFieldFile) + + instance = cls.FileFieldModel() + instance.file = {'en': ContentFile("test", "testfilename")} + assert isinstance(instance.file.en, LocalizedFieldFile) + assert instance.file.en.name == "testfilename" + + another_instance = cls.FileFieldModel() + another_instance.file = {'ro': instance.file.en} + assert another_instance == another_instance.file.ro.instance + assert another_instance.file.ro.lang == 'ro' + + @classmethod + def test_save_form_data(cls): + """Tests whether the :see:save_form_data function correctly set + a valid value.""" + + instance = cls.FileFieldModel() + data = LocalizedFileValue({'en': False}) + instance._meta.get_field('file').save_form_data(instance, data) + assert instance.file.en == '' + + @classmethod + def test_pre_save(cls): + """Tests whether the :see:pre_save function works properly.""" + + instance = cls.FileFieldModel() + instance.file = {'en': ContentFile("test", "testfilename")} + instance._meta.get_field('file').pre_save(instance, False) + assert instance.file.en._committed == True + + @classmethod + def test_file_methods(cls): + """Tests whether the :see:LocalizedFieldFile.delete method works + correctly.""" + + temp_file = File(tempfile.NamedTemporaryFile()) + instance = cls.FileFieldModel() + # Calling delete on an unset FileField should not call the file deletion + # process, but fail silently + instance.file.en.delete() + instance.file.en.save('testfilename', temp_file) + assert instance.file.en.name == 'testfilename' + instance.file.en.delete() + assert instance.file.en.name is None + + @classmethod + def test_generate_filename(cls): + """Tests whether the :see:LocalizedFieldFile.generate_filename method + works correctly.""" + + instance = cls.FileFieldModel() + field = instance._meta.get_field('file') + field.upload_to = '{lang}/' + filename = field.generate_filename(instance, 'test', 'en') + assert filename == 'en/test' + field.upload_to = lambda instance, filename, lang: \ + '%s_%s' % (lang, filename) + filename = field.generate_filename(instance, 'test', 'en') + assert filename == 'en_test' + + @staticmethod + def test_get_prep_value(): + """Tests whether the :see:get_prep_value function returns correctly + value.""" + + value = LocalizedValue({'en': None}) + assert LocalizedFileField().get_prep_value(None) == None + assert isinstance(LocalizedFileField().get_prep_value(value), dict) + assert LocalizedFileField().get_prep_value(value)['en'] == '' + + @staticmethod + def test_formfield(): + """Tests whether the :see:formfield function correctly returns + a valid form.""" + + form_field = LocalizedFileField().formfield() + assert isinstance(form_field, LocalizedFileFieldForm) + assert isinstance(form_field, forms.FileField) + assert isinstance(form_field.widget, LocalizedFileWidget) + + @staticmethod + def test_deconstruct(): + """Tests whether the :see:LocalizedFileField + class's :see:deconstruct function works properly.""" + + name, path, args, kwargs = LocalizedFileField().deconstruct() + assert 'upload_to' in kwargs + assert 'storage' not in kwargs + name, path, \ + args, kwargs = LocalizedFileField(storage='test').deconstruct() + assert 'storage' in kwargs diff --git a/tests/test_localized_file_field_form.py b/tests/test_localized_file_field_form.py new file mode 100644 index 0000000..3b33ba4 --- /dev/null +++ b/tests/test_localized_file_field_form.py @@ -0,0 +1,41 @@ +from django.conf import settings +from django.core.exceptions import ValidationError +from django.forms.widgets import FILE_INPUT_CONTRADICTION +from django.test import TestCase + +from localized_fields.forms import LocalizedFileFieldForm + + +class LocalizedFileFieldFormTestCase(TestCase): + """Tests the workings of the :see:LocalizedFileFieldForm class.""" + + def test_clean(self): + """Tests whether the :see:clean function is working properly.""" + + formfield = LocalizedFileFieldForm(required=True) + with self.assertRaises(ValidationError): + formfield.clean([]) + with self.assertRaises(ValidationError): + formfield.clean([], {'en': None}) + with self.assertRaises(ValidationError): + formfield.clean("badvalue") + with self.assertRaises(ValidationError): + value = [FILE_INPUT_CONTRADICTION] * len(settings.LANGUAGES) + formfield.clean(value) + + formfield = LocalizedFileFieldForm(required=False) + formfield.clean([''] * len(settings.LANGUAGES)) + formfield.clean(['', ''], ['', '']) + + def test_bound_data(self): + """Tests whether the :see:bound_data function is returns correctly + value""" + + formfield = LocalizedFileFieldForm() + assert formfield.bound_data([''], None) == [''] + + initial = dict([(lang, '') for lang, _ in settings.LANGUAGES]) + value = [None] * len(settings.LANGUAGES) + expected_value = [''] * len(settings.LANGUAGES) + assert formfield.bound_data(value, initial) == expected_value + From d1790f1fc1713222f3adde3efd1d5249910772be Mon Sep 17 00:00:00 2001 From: seroy Date: Mon, 19 Jun 2017 17:17:24 +0300 Subject: [PATCH 08/10] added missing 'r' in type LocalizedStringValue --- localized_fields/fields/localized_char_field.py | 4 ++-- localized_fields/forms.py | 6 +++--- localized_fields/localized_value.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/localized_fields/fields/localized_char_field.py b/localized_fields/fields/localized_char_field.py index 047ab0a..1438af7 100644 --- a/localized_fields/fields/localized_char_field.py +++ b/localized_fields/fields/localized_char_field.py @@ -1,10 +1,10 @@ from ..forms import LocalizedCharFieldForm from .localized_field import LocalizedField -from ..localized_value import LocalizedStingValue +from ..localized_value import LocalizedStringValue class LocalizedCharField(LocalizedField): - attr_class = LocalizedStingValue + attr_class = LocalizedStringValue def formfield(self, **kwargs): """Gets the form field associated with this field.""" diff --git a/localized_fields/forms.py b/localized_fields/forms.py index 7126b70..b7588ef 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, LocalizedStingValue, \ +from .localized_value import LocalizedValue, LocalizedStringValue, \ LocalizedFileValue from .widgets import LocalizedFieldWidget, LocalizedCharFieldWidget, \ LocalizedFileWidget @@ -68,14 +68,14 @@ class LocalizedCharFieldForm(LocalizedFieldForm): the field in multiple languages.""" widget = LocalizedCharFieldWidget - value_class = LocalizedStingValue + value_class = LocalizedStringValue class LocalizedTextFieldForm(LocalizedFieldForm): """Form for a localized text field, allows editing the field in multiple languages.""" - value_class = LocalizedStingValue + value_class = LocalizedStringValue class LocalizedFileFieldForm(LocalizedFieldForm, forms.FileField): diff --git a/localized_fields/localized_value.py b/localized_fields/localized_value.py index 895e3a9..40d2dfe 100644 --- a/localized_fields/localized_value.py +++ b/localized_fields/localized_value.py @@ -132,7 +132,7 @@ class LocalizedValue(dict): self.__dict__, id(self)) -class LocalizedStingValue(LocalizedValue): +class LocalizedStringValue(LocalizedValue): default_value = '' From 236ce1648c798fe2cbd4d53334d7b326fb96653c Mon Sep 17 00:00:00 2001 From: seroy Date: Mon, 19 Jun 2017 21:49:24 +0300 Subject: [PATCH 09/10] Upgrade django-postgres-extra to 1.11 --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index b5d4f95..57f2734 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1 +1 @@ -django-postgres-extra==1.4 +django-postgres-extra==1.11 From e5d7cd25e296a93ad11d97ac27ec640229513583 Mon Sep 17 00:00:00 2001 From: seroy Date: Mon, 19 Jun 2017 21:58:48 +0300 Subject: [PATCH 10/10] Shorten names for everything --- localized_fields/fields/__init__.py | 6 +++--- .../fields/{localized_char_field.py => char_field.py} | 0 .../fields/{localized_file_field.py => file_field.py} | 0 .../fields/{localized_text_field.py => text_field.py} | 2 +- tests/{test_localized_file_field.py => test_file_field.py} | 2 +- ...localized_file_field_form.py => test_file_field_form.py} | 0 6 files changed, 5 insertions(+), 5 deletions(-) rename localized_fields/fields/{localized_char_field.py => char_field.py} (100%) rename localized_fields/fields/{localized_file_field.py => file_field.py} (100%) rename localized_fields/fields/{localized_text_field.py => text_field.py} (86%) rename tests/{test_localized_file_field.py => test_file_field.py} (98%) rename tests/{test_localized_file_field_form.py => test_file_field_form.py} (100%) diff --git a/localized_fields/fields/__init__.py b/localized_fields/fields/__init__.py index 8d5ffc3..9c9889c 100644 --- a/localized_fields/fields/__init__.py +++ b/localized_fields/fields/__init__.py @@ -1,9 +1,9 @@ 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 +from .char_field import LocalizedCharField +from .text_field import LocalizedTextField +from .file_field import LocalizedFileField __all__ = [ diff --git a/localized_fields/fields/localized_char_field.py b/localized_fields/fields/char_field.py similarity index 100% rename from localized_fields/fields/localized_char_field.py rename to localized_fields/fields/char_field.py diff --git a/localized_fields/fields/localized_file_field.py b/localized_fields/fields/file_field.py similarity index 100% rename from localized_fields/fields/localized_file_field.py rename to localized_fields/fields/file_field.py diff --git a/localized_fields/fields/localized_text_field.py b/localized_fields/fields/text_field.py similarity index 86% rename from localized_fields/fields/localized_text_field.py rename to localized_fields/fields/text_field.py index c76d4de..72dd984 100644 --- a/localized_fields/fields/localized_text_field.py +++ b/localized_fields/fields/text_field.py @@ -1,5 +1,5 @@ from ..forms import LocalizedTextFieldForm -from .localized_char_field import LocalizedCharField +from .char_field import LocalizedCharField class LocalizedTextField(LocalizedCharField): diff --git a/tests/test_localized_file_field.py b/tests/test_file_field.py similarity index 98% rename from tests/test_localized_file_field.py rename to tests/test_file_field.py index 890bcc2..7bab6d7 100644 --- a/tests/test_localized_file_field.py +++ b/tests/test_file_field.py @@ -9,7 +9,7 @@ from django.core.files.base import File, ContentFile from django.core.files import temp as tempfile from localized_fields.fields import LocalizedFileField from localized_fields.value import LocalizedValue -from localized_fields.fields.localized_file_field import LocalizedFieldFile +from localized_fields.fields.file_field import LocalizedFieldFile from localized_fields.forms import LocalizedFileFieldForm from localized_fields.value import LocalizedFileValue from localized_fields.widgets import LocalizedFileWidget diff --git a/tests/test_localized_file_field_form.py b/tests/test_file_field_form.py similarity index 100% rename from tests/test_localized_file_field_form.py rename to tests/test_file_field_form.py