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:
Jacek Tomaszewski 2013-09-01 10:52:00 -07:00
commit 74df71bdf2
10 changed files with 273 additions and 22 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
});
});
});
}());

View file

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

View file

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

View file

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

View file

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