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'