Merge pull request #15 from MELScience/extra-fields

Extra fields
This commit is contained in:
Swen Kooij 2017-06-21 11:33:30 +03:00 committed by GitHub
commit 06873afbda
13 changed files with 613 additions and 13 deletions

View File

@ -238,6 +238,60 @@ 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`` (``<input type="text">``) 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/<lang>/<id>_<filename>
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:

View File

@ -1,11 +1,16 @@
from django.contrib.admin import ModelAdmin
from . import widgets
from .fields import LocalizedField
from .fields import LocalizedField, LocalizedCharField, LocalizedTextField, \
LocalizedFileField
FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = {
LocalizedField: {'widget': widgets.AdminLocalizedFieldWidget},
LocalizedCharField: {'widget': widgets.AdminLocalizedCharFieldWidget},
LocalizedTextField: {'widget': widgets.AdminLocalizedFieldWidget},
LocalizedFileField: {'widget': widgets.AdminLocalizedFileFieldWidget},
}

View File

@ -1,12 +1,18 @@
from .field import LocalizedField
from .autoslug_field import LocalizedAutoSlugField
from .uniqueslug_field import LocalizedUniqueSlugField
from .char_field import LocalizedCharField
from .text_field import LocalizedTextField
from .file_field import LocalizedFileField
__all__ = [
'LocalizedField',
'LocalizedAutoSlugField',
'LocalizedUniqueSlugField',
'LocalizedCharField',
'LocalizedTextField',
'LocalizedFileField'
]
try:

View File

@ -0,0 +1,16 @@
from ..forms import LocalizedCharFieldForm
from .field import LocalizedField
from ..value import LocalizedStringValue
class LocalizedCharField(LocalizedField):
attr_class = LocalizedStringValue
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = {
'form_class': LocalizedCharFieldForm
}
defaults.update(kwargs)
return super().formfield(**defaults)

View File

@ -170,7 +170,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
@ -198,7 +201,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,

View File

@ -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.field import LocalizedValueDescriptor
from localized_fields.value import LocalizedValue
from ..value import LocalizedFileValue
from ..forms import LocalizedFileFieldForm
class LocalizedFieldFile(FieldFile):
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
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 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, 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, lang)
file_copy.file = file
file_copy._committed = False
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
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().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 file in value.__dict__.values():
if file and not file._committed:
file.save(file.name, file, 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.name, data)
def formfield(self, **kwargs):
defaults = {'form_class': LocalizedFileFieldForm}
if 'initial' in kwargs:
defaults['required'] = False
defaults.update(kwargs)
return super().formfield(**defaults)

View File

@ -0,0 +1,14 @@
from ..forms import LocalizedTextFieldForm
from .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)

View File

@ -2,9 +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 .value import LocalizedValue
from .widgets import LocalizedFieldWidget
from .value import LocalizedValue, LocalizedStringValue, \
LocalizedFileValue
from .widgets import LocalizedFieldWidget, LocalizedCharFieldWidget, \
LocalizedFileWidget
class LocalizedFieldForm(forms.MultiValueField):
@ -12,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):
@ -26,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,
@ -37,7 +42,7 @@ class LocalizedFieldForm(forms.MultiValueField):
for f, w in zip(self.fields, self.widget.widgets):
w.is_required = f.required
def compress(self, value: List[str]) -> LocalizedValue:
def compress(self, value: List[str]) -> value_class:
"""Compresses the values from individual fields
into a single :see:LocalizedValue instance.
@ -56,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 = LocalizedStringValue
class LocalizedTextFieldForm(LocalizedFieldForm):
"""Form for a localized text field, allows editing
the field in multiple languages."""
value_class = LocalizedStringValue
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

View File

@ -45,3 +45,7 @@
border-color: #79aec8;
opacity: 1;
}
.localized-fields-widget p.file-upload {
margin-left: 0;
}

View File

@ -1,11 +1,13 @@
import collections
from django.conf import settings
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.
@ -20,7 +22,7 @@ class LocalizedValue(dict):
super().__init__({})
self._interpret_value(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.
@ -35,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.
@ -60,7 +62,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 _interpret_value(self, value):
@ -83,14 +85,14 @@ class LocalizedValue(dict):
"""
for lang_code, _ in settings.LANGUAGES:
self.set(lang_code, None)
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) or None
lang_value = value.get(lang_code, self.default_value)
self.set(lang_code, lang_value)
elif isinstance(value, collections.Iterable):
@ -156,4 +158,28 @@ 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 LocalizedStringValue(LocalizedValue):
default_value = ''
class LocalizedFileValue(LocalizedValue):
def __getattr__(self, name: str):
"""Proxies access to attributes to attributes of LocalizedFile"""
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) -> str:
"""Returns string representation of value"""
return str(super().__str__())
def localized(self):
"""Returns value for current language"""
return self.get(translation.get_language())

View File

@ -45,6 +45,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

152
tests/test_file_field.py Normal file
View File

@ -0,0 +1,152 @@
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.fields import LocalizedFileField
from localized_fields.value import LocalizedValue
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
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

View File

@ -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