diff --git a/docs/modeltranslation/usage.rst b/docs/modeltranslation/usage.rst
index 81ae5e0..12b6bc6 100644
--- a/docs/modeltranslation/usage.rst
+++ b/docs/modeltranslation/usage.rst
@@ -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
-------------------------------
diff --git a/modeltranslation/admin.py b/modeltranslation/admin.py
index 7b7dd76..b62feec 100644
--- a/modeltranslation/admin.py
+++ b/modeltranslation/admin.py
@@ -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])
diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py
index 9a4a195..838874a 100644
--- a/modeltranslation/fields.py
+++ b/modeltranslation/fields.py
@@ -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):
diff --git a/modeltranslation/forms.py b/modeltranslation/forms.py
index df9ebd8..8e05ce0 100644
--- a/modeltranslation/forms.py
+++ b/modeltranslation/forms.py
@@ -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)
diff --git a/modeltranslation/static/modeltranslation/js/clearable_inputs.js b/modeltranslation/static/modeltranslation/js/clearable_inputs.js
new file mode 100644
index 0000000..e31a9f3
--- /dev/null
+++ b/modeltranslation/static/modeltranslation/js/clearable_inputs.js
@@ -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);
+ });
+ });
+ });
+}());
diff --git a/modeltranslation/tests/__init__.py b/modeltranslation/tests/__init__.py
index 5ef5a7d..9b9264d 100644
--- a/modeltranslation/tests/__init__.py
+++ b/modeltranslation/tests/__init__.py
@@ -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):
diff --git a/modeltranslation/tests/models.py b/modeltranslation/tests/models.py
index e55a2fa..76ed9e0 100644
--- a/modeltranslation/tests/models.py
+++ b/modeltranslation/tests/models.py
@@ -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):
diff --git a/modeltranslation/tests/translation.py b/modeltranslation/tests/translation.py
index 1b8b6c8..e13805b 100644
--- a/modeltranslation/tests/translation.py
+++ b/modeltranslation/tests/translation.py
@@ -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)
diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py
index a116bd6..e99d950 100644
--- a/modeltranslation/translator.py
+++ b/modeltranslation/translator.py
@@ -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
diff --git a/modeltranslation/widgets.py b/modeltranslation/widgets.py
new file mode 100644
index 0000000..3d4c3bc
--- /dev/null
+++ b/modeltranslation/widgets.py
@@ -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 = '{0} {2} {3}'
+ # TODO: Label would be proper, but admin applies some hardly undoable
+ # styling to labels.
+ # template = '{} {}'
+
+ 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'