mirror of
https://github.com/Hopiu/django-modeltranslation.git
synced 2026-05-20 10:21:53 +00:00
Merge pull request #163 from wrwrwr/feature/no-empty-string-fallback-for-nullable
Fallback on field default rather than the empty string
This commit is contained in:
commit
74df71bdf2
10 changed files with 273 additions and 22 deletions
|
|
@ -290,6 +290,28 @@ Fallback values can be also customized per model field::
|
|||
If current language and all fallback languages yield no field value, and no fallback values are
|
||||
defined, then modeltranslation will use field's default value.
|
||||
|
||||
Fallback undefined
|
||||
******************
|
||||
|
||||
.. versionadded:: 0.7
|
||||
|
||||
Another question is what do we consider "no value", on what value should we fall back to other
|
||||
translations? For text fields the empty string can usually be considered as the undefined value,
|
||||
but other fields may have different concepts of empty or missing value.
|
||||
|
||||
Modeltranslation defaults to using the field's default value as the undefined value (the empty
|
||||
string for non-nullable ``CharFields``). This requires calling ``get_default`` for every field
|
||||
access, which in some cases may be expensive.
|
||||
|
||||
If you'd like to fallback on a different value or your default is expensive to calculate, provide
|
||||
a custom undefined value (for a field or model)::
|
||||
|
||||
class NewsTranslationOptions(TranslationOptions):
|
||||
fields = ('title', 'text',)
|
||||
fallback_undefined = {
|
||||
'title': 'no title',
|
||||
'text': None
|
||||
}
|
||||
|
||||
The State of the Original Field
|
||||
-------------------------------
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from copy import deepcopy
|
|||
from django.contrib import admin
|
||||
from django.contrib.admin.options import BaseModelAdmin, flatten_fieldsets, InlineModelAdmin
|
||||
from django.contrib.contenttypes import generic
|
||||
from django import forms
|
||||
|
||||
# Ensure that models are registered for translation before TranslationAdmin
|
||||
# runs. The import is supposed to resolve a race condition between model import
|
||||
|
|
@ -13,6 +14,7 @@ from modeltranslation.settings import DEFAULT_LANGUAGE, PREPOPULATE_LANGUAGE
|
|||
from modeltranslation.translator import translator
|
||||
from modeltranslation.utils import (
|
||||
get_translation_fields, build_css_class, build_localized_fieldname, get_language, unique)
|
||||
from modeltranslation.widgets import ClearableWidgetWrapper
|
||||
|
||||
|
||||
class TranslationBaseModelAdmin(BaseModelAdmin):
|
||||
|
|
@ -55,6 +57,8 @@ class TranslationBaseModelAdmin(BaseModelAdmin):
|
|||
else:
|
||||
orig_formfield = self.formfield_for_dbfield(orig_field, **kwargs)
|
||||
field.widget = deepcopy(orig_formfield.widget)
|
||||
if orig_field.null and isinstance(field.widget, (forms.TextInput, forms.Textarea)):
|
||||
field.widget = ClearableWidgetWrapper(field.widget)
|
||||
css_classes = field.widget.attrs.get('class', '').split(' ')
|
||||
css_classes.append('mt')
|
||||
# Add localized fieldname css class
|
||||
|
|
@ -90,6 +94,7 @@ class TranslationBaseModelAdmin(BaseModelAdmin):
|
|||
Returns a new list with replaced fields. If `option` contains no
|
||||
registered fields, it is returned unmodified.
|
||||
|
||||
>>> self = TranslationAdmin() # PyFlakes
|
||||
>>> print(self.trans_opts.fields.keys())
|
||||
['title',]
|
||||
>>> get_translation_fields(self.trans_opts.fields.keys()[0])
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from django import forms
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db.models import fields
|
||||
|
||||
from modeltranslation import settings as mt_settings
|
||||
from modeltranslation.utils import (
|
||||
get_language, build_localized_fieldname, build_localized_verbose_name, resolution_order)
|
||||
from modeltranslation.widgets import ClearableWidgetWrapper
|
||||
|
||||
|
||||
SUPPORTED_FIELDS = (
|
||||
|
|
@ -33,6 +35,15 @@ except AttributeError:
|
|||
pass
|
||||
|
||||
|
||||
class NONE:
|
||||
"""
|
||||
Used for fallback options when they are not provided (``None`` can be
|
||||
given as a fallback or undefined value) or to mark that a nullable value
|
||||
is not yet known and needs to be computed (e.g. field default).
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def create_translation_field(model, field_name, lang):
|
||||
"""
|
||||
Translation field factory. Returns a ``TranslationField`` based on a
|
||||
|
|
@ -152,6 +163,31 @@ class TranslationField(object):
|
|||
column = attname
|
||||
return attname, column
|
||||
|
||||
def formfield(self, *args, **kwargs):
|
||||
"""
|
||||
If the original field is nullable and uses ``forms.CharField`` subclass
|
||||
as its form input, we patch the form field, so it doesn't cast ``None``
|
||||
to anything.
|
||||
|
||||
The ``forms.CharField`` somewhat surprising behaviour is documented as a
|
||||
"won't fix": https://code.djangoproject.com/ticket/9590.
|
||||
|
||||
Textual widgets (subclassing ``TextInput`` or ``Textarea``) used for
|
||||
nullable fields are enriched with a clear checkbox, allowing ``None``
|
||||
values to be preserved rather than saved as empty strings.
|
||||
"""
|
||||
formfield = super(TranslationField, self).formfield(*args, **kwargs)
|
||||
if self.translated_field.null:
|
||||
if isinstance(formfield, forms.CharField):
|
||||
from modeltranslation.forms import NullableField
|
||||
form_class = formfield.__class__
|
||||
kwargs['form_class'] = type(
|
||||
'Nullable%s' % form_class.__name__, (NullableField, form_class), {})
|
||||
formfield = super(TranslationField, self).formfield(*args, **kwargs)
|
||||
if isinstance(formfield.widget, (forms.TextInput, forms.Textarea)):
|
||||
formfield.widget = ClearableWidgetWrapper(formfield.widget)
|
||||
return formfield
|
||||
|
||||
def save_form_data(self, instance, data):
|
||||
# Allow 3rd-party apps forms to be saved using only translated field name.
|
||||
# When translated field (e.g. 'name') is specified and translation field (e.g. 'name_en')
|
||||
|
|
@ -185,39 +221,53 @@ class TranslationFieldDescriptor(object):
|
|||
"""
|
||||
A descriptor used for the original translated field.
|
||||
"""
|
||||
def __init__(self, field, fallback_value=None, fallback_languages=None):
|
||||
def __init__(self, field, fallback_languages=None, fallback_value=NONE,
|
||||
fallback_undefined=NONE):
|
||||
"""
|
||||
The ``name`` is the name of the field (which is not available in the
|
||||
descriptor by default - this is Python behaviour).
|
||||
Stores fallback options and the original field, so we know it's name
|
||||
and default.
|
||||
"""
|
||||
self.field = field
|
||||
self.fallback_value = fallback_value
|
||||
self.fallback_languages = fallback_languages
|
||||
self.fallback_value = fallback_value
|
||||
self.fallback_undefined = fallback_undefined
|
||||
|
||||
def __set__(self, instance, value):
|
||||
"""
|
||||
Updates the translation field for the current language.
|
||||
"""
|
||||
if getattr(instance, '_mt_init', False):
|
||||
# When assignment takes place in model instance constructor, don't set value.
|
||||
# This is essential for only/defer to work, but I think it's sensible anyway.
|
||||
return
|
||||
lang = get_language()
|
||||
loc_field_name = build_localized_fieldname(self.field.name, lang)
|
||||
# also update the translation field of the current language
|
||||
loc_field_name = build_localized_fieldname(self.field.name, get_language())
|
||||
setattr(instance, loc_field_name, value)
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
"""
|
||||
Returns value from the translation field for the current language, or
|
||||
value for some another language according to fallback languages, or the
|
||||
custom fallback value, or field's default value.
|
||||
"""
|
||||
if instance is None:
|
||||
return self
|
||||
default = NONE
|
||||
undefined = self.fallback_undefined
|
||||
if undefined is NONE:
|
||||
default = self.field.get_default()
|
||||
undefined = default
|
||||
langs = resolution_order(get_language(), self.fallback_languages)
|
||||
for lang in langs:
|
||||
loc_field_name = build_localized_fieldname(self.field.name, lang)
|
||||
val = getattr(instance, loc_field_name, None)
|
||||
# Here we check only for None and '', because e.g. 0 should not fall back.
|
||||
if val is not None and val != '':
|
||||
if val is not None and val != undefined:
|
||||
return val
|
||||
if self.fallback_value is None or not mt_settings.ENABLE_FALLBACKS:
|
||||
return self.field.get_default()
|
||||
else:
|
||||
if mt_settings.ENABLE_FALLBACKS and self.fallback_value is not NONE:
|
||||
return self.fallback_value
|
||||
else:
|
||||
if default is NONE:
|
||||
default = self.field.get_default()
|
||||
return default
|
||||
|
||||
|
||||
class TranslatedRelationIdDescriptor(object):
|
||||
|
|
|
|||
|
|
@ -10,3 +10,14 @@ class TranslationModelForm(forms.ModelForm):
|
|||
for f in self._meta.model._meta.fields:
|
||||
if f.name in self.fields and isinstance(f, TranslationField):
|
||||
del self.fields[f.name]
|
||||
|
||||
|
||||
class NullableField(object):
|
||||
"""
|
||||
Form field mixin that ensures that ``None`` is not cast to anything (like
|
||||
the empty string with ``CharField`` and its derivatives).
|
||||
"""
|
||||
def to_python(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
return super(NullableField, self).to_python(value)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
var jQuery, $, django;
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
(jQuery || $ || django.jQuery)(function ($) {
|
||||
$('.clearable-input').each(function () {
|
||||
var clear = $(this).children().last();
|
||||
$(this).find('input, select, textarea').not(clear).change(function () {
|
||||
clear.prop('checked', false);
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
|
|
@ -548,6 +548,45 @@ class FallbackTests(ModeltranslationTestBase):
|
|||
with override('en'):
|
||||
self.assertEqual(m.title, '') # '' is the default
|
||||
|
||||
def test_fallback_undefined(self):
|
||||
"""
|
||||
Checks if a sensible value is considered undefined and triggers
|
||||
fallbacks. Tests if the value can be overridden as documented.
|
||||
"""
|
||||
with reload_override_settings(MODELTRANSLATION_FALLBACK_LANGUAGES=self.test_fallback):
|
||||
# Non-nullable CharField falls back on empty strings.
|
||||
m = models.FallbackModel(title_en='value', title_de='')
|
||||
with override('en'):
|
||||
self.assertEqual(m.title, 'value')
|
||||
with override('de'):
|
||||
self.assertEqual(m.title, 'value')
|
||||
|
||||
# Nullable CharField does not fall back on empty strings.
|
||||
m = models.FallbackModel(description_en='value', description_de='')
|
||||
with override('en'):
|
||||
self.assertEqual(m.description, 'value')
|
||||
with override('de'):
|
||||
self.assertEqual(m.description, '')
|
||||
|
||||
# Nullable CharField does fall back on None.
|
||||
m = models.FallbackModel(description_en='value', description_de=None)
|
||||
with override('en'):
|
||||
self.assertEqual(m.description, 'value')
|
||||
with override('de'):
|
||||
self.assertEqual(m.description, 'value')
|
||||
|
||||
# The undefined value may be overridden.
|
||||
m = models.FallbackModel2(title_en='value', title_de='')
|
||||
with override('en'):
|
||||
self.assertEqual(m.title, 'value')
|
||||
with override('de'):
|
||||
self.assertEqual(m.title, '')
|
||||
m = models.FallbackModel2(title_en='value', title_de='no title')
|
||||
with override('en'):
|
||||
self.assertEqual(m.title, 'value')
|
||||
with override('de'):
|
||||
self.assertEqual(m.title, 'value')
|
||||
|
||||
|
||||
class FileFieldsTest(ModeltranslationTestBase):
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class FallbackModel(models.Model):
|
|||
text = models.TextField(blank=True, null=True)
|
||||
url = models.URLField(blank=True, null=True)
|
||||
email = models.EmailField(blank=True, null=True)
|
||||
description = models.CharField(max_length=255, null=True)
|
||||
|
||||
|
||||
class FallbackModel2(models.Model):
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ translator.register(ProxyTestModel, ProxyTestTranslationOptions)
|
|||
########## Fallback values testing
|
||||
|
||||
class FallbackModelTranslationOptions(TranslationOptions):
|
||||
fields = ('title', 'text', 'url', 'email',)
|
||||
fields = ('title', 'text', 'url', 'email', 'description')
|
||||
fallback_values = "fallback"
|
||||
translator.register(FallbackModel, FallbackModelTranslationOptions)
|
||||
|
||||
|
|
@ -38,6 +38,7 @@ translator.register(FallbackModel, FallbackModelTranslationOptions)
|
|||
class FallbackModel2TranslationOptions(TranslationOptions):
|
||||
fields = ('title', 'text', 'url', 'email',)
|
||||
fallback_values = {'text': ugettext_lazy('Sorry, translation is not available.')}
|
||||
fallback_undefined = {'title': 'no title'}
|
||||
translator.register(FallbackModel2, FallbackModel2TranslationOptions)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ from django.db.models.signals import post_init
|
|||
from django.dispatch import receiver
|
||||
|
||||
from modeltranslation import settings as mt_settings
|
||||
from modeltranslation.fields import (TranslationFieldDescriptor, TranslatedRelationIdDescriptor,
|
||||
create_translation_field)
|
||||
from modeltranslation.fields import (NONE, create_translation_field, TranslationFieldDescriptor,
|
||||
TranslatedRelationIdDescriptor)
|
||||
from modeltranslation.manager import MultilingualManager, rewrite_lookup_key
|
||||
from modeltranslation.utils import build_localized_fieldname
|
||||
|
||||
|
|
@ -315,20 +315,24 @@ class Translator(object):
|
|||
patch_metaclass(model)
|
||||
|
||||
# Substitute original field with descriptor
|
||||
model_fallback_values = getattr(opts, 'fallback_values', None)
|
||||
model_fallback_languages = getattr(opts, 'fallback_languages', None)
|
||||
model_fallback_values = getattr(opts, 'fallback_values', NONE)
|
||||
model_fallback_undefined = getattr(opts, 'fallback_undefined', NONE)
|
||||
for field_name in opts.local_fields.keys():
|
||||
if model_fallback_values is None:
|
||||
field_fallback_value = None
|
||||
elif isinstance(model_fallback_values, dict):
|
||||
field_fallback_value = model_fallback_values.get(field_name, None)
|
||||
field = model._meta.get_field(field_name)
|
||||
if isinstance(model_fallback_values, dict):
|
||||
field_fallback_value = model_fallback_values.get(field_name, NONE)
|
||||
else:
|
||||
field_fallback_value = model_fallback_values
|
||||
field = model._meta.get_field(field_name)
|
||||
if isinstance(model_fallback_undefined, dict):
|
||||
field_fallback_undefined = model_fallback_undefined.get(field_name, NONE)
|
||||
else:
|
||||
field_fallback_undefined = model_fallback_undefined
|
||||
descriptor = TranslationFieldDescriptor(
|
||||
field,
|
||||
fallback_languages=model_fallback_languages,
|
||||
fallback_value=field_fallback_value,
|
||||
fallback_languages=model_fallback_languages)
|
||||
fallback_undefined=field_fallback_undefined)
|
||||
setattr(model, field_name, descriptor)
|
||||
if isinstance(field, ForeignKey):
|
||||
# We need to use a special descriptor so that
|
||||
|
|
|
|||
105
modeltranslation/widgets.py
Normal file
105
modeltranslation/widgets.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from django.forms.widgets import Media, Widget, CheckboxInput
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import ugettext
|
||||
|
||||
|
||||
class ClearableWidgetWrapper(Widget):
|
||||
"""
|
||||
Wraps another widget adding a clear checkbox, making it possible to
|
||||
reset the field to some empty value even if the original input doesn't
|
||||
have means to.
|
||||
|
||||
Useful for ``TextInput`` and ``Textarea`` based widgets used in combination
|
||||
with nullable text fields.
|
||||
|
||||
Use it in ``Field.formfield`` or ``ModelAdmin.formfield_for_dbfield``:
|
||||
|
||||
field.widget = ClearableWidgetWrapper(field.widget)
|
||||
|
||||
``None`` is assumed to be a proper choice for the empty value, but you may
|
||||
pass another one to the constructor.
|
||||
"""
|
||||
clear_checkbox_label = ugettext("None")
|
||||
template = '<span class="clearable-input">{0} <span>{2}</span> {3}</span>'
|
||||
# TODO: Label would be proper, but admin applies some hardly undoable
|
||||
# styling to labels.
|
||||
# template = '<span class="clearable-input">{} <label for="{}">{}</label> {}</span>'
|
||||
|
||||
class Media:
|
||||
js = ('modeltranslation/js/clearable_inputs.js',)
|
||||
|
||||
def __init__(self, widget, empty_value=None):
|
||||
"""
|
||||
Remebers the widget we are wrapping and precreates a checkbox input.
|
||||
Allows overriding the empty value.
|
||||
"""
|
||||
self.widget = widget
|
||||
self.checkbox = CheckboxInput()
|
||||
self.empty_value = empty_value
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""
|
||||
If we don't have a property or a method, chances are the wrapped
|
||||
widget does.
|
||||
"""
|
||||
if name != 'widget':
|
||||
return getattr(self.widget, name)
|
||||
raise AttributeError
|
||||
|
||||
@property
|
||||
def media(self):
|
||||
"""
|
||||
Combines media of both components and adds a small script that unchecks
|
||||
the clear box, when a value in any wrapped input is modified.
|
||||
"""
|
||||
return self.widget.media + self.checkbox.media + Media(self.Media)
|
||||
|
||||
def render(self, name, value, attrs=None):
|
||||
"""
|
||||
Appends a checkbox for clearing the value (that is, setting the field
|
||||
with the ``empty_value``).
|
||||
"""
|
||||
wrapped = self.widget.render(name, value, attrs)
|
||||
checkbox_name = self.clear_checkbox_name(name)
|
||||
checkbox_id = self.clear_checkbox_id(checkbox_name)
|
||||
checkbox_label = self.clear_checkbox_label
|
||||
checkbox = self.checkbox.render(
|
||||
checkbox_name, value == self.empty_value, attrs={'id': checkbox_id})
|
||||
return mark_safe(self.template.format(
|
||||
conditional_escape(wrapped),
|
||||
conditional_escape(checkbox_id),
|
||||
conditional_escape(checkbox_label),
|
||||
conditional_escape(checkbox)))
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""
|
||||
If the clear checkbox is checked returns the configured empty value,
|
||||
completely ignoring the original input.
|
||||
"""
|
||||
clear = self.checkbox.value_from_datadict(data, files, self.clear_checkbox_name(name))
|
||||
if clear:
|
||||
return self.empty_value
|
||||
return self.widget.value_from_datadict(data, files, name)
|
||||
|
||||
def _has_changed(self, initial, data):
|
||||
"""
|
||||
Widget implementation equates ``None``s with empty strings.
|
||||
"""
|
||||
if (initial is None and data is not None) or (initial is not None and data is None):
|
||||
return True
|
||||
return self.widget._has_changed(initial, data)
|
||||
|
||||
def clear_checkbox_name(self, name):
|
||||
"""
|
||||
Given the name of the input, returns the name of the clear checkbox.
|
||||
"""
|
||||
return name + '-clear'
|
||||
|
||||
def clear_checkbox_id(self, name):
|
||||
"""
|
||||
Given the name of the clear checkbox input, returns the HTML id for it.
|
||||
"""
|
||||
return name + '_id'
|
||||
Loading…
Reference in a new issue