From 7d209ae4e39e35b4093609f9a51fc06e49559f94 Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Wed, 20 Feb 2013 17:26:44 +0100 Subject: [PATCH 01/11] Only fallback when there is no value for a given translation or the value is the field's default. For CharField(null=True) the default is None, so empty string becomes a proper value (it's sometimes useful to treat it differently than None). --- modeltranslation/fields.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py index fcbfc0d..120be66 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -190,15 +190,17 @@ class TranslationFieldDescriptor(object): def __get__(self, instance, owner): if instance is None: return self + default = self.field.get_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 there is no value or it's the field's default fall back to the next language. + # Note: Unless you pass null=True to a CharField it's default is ''. + if val is not None and val != default: return val if self.fallback_value is None or not mt_settings.ENABLE_FALLBACKS: - return self.field.get_default() + return default else: return self.fallback_value From 740cfea50d71e8e120f5e7097f894ac56b487f88 Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Wed, 20 Feb 2013 17:31:08 +0100 Subject: [PATCH 02/11] Test for the nullable CharField case. --- modeltranslation/tests/__init__.py | 10 ++++++++++ modeltranslation/tests/models.py | 1 + modeltranslation/tests/translation.py | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/modeltranslation/tests/__init__.py b/modeltranslation/tests/__init__.py index eda0266..d151899 100644 --- a/modeltranslation/tests/__init__.py +++ b/modeltranslation/tests/__init__.py @@ -543,6 +543,16 @@ class FallbackTests(ModeltranslationTestBase): with override('en'): self.assertEqual(m.title, '') # '' is the default + def test_nullable(self): + with reload_override_settings(MODELTRANSLATION_FALLBACK_LANGUAGES=self.test_fallback): + m = models.FallbackModel(description_en='en', description_de='') + self.assertEqual(m.description_en, 'en') + self.assertEqual(m.description_de, '') + with override('en'): + self.assertEqual(m.description, 'en') + with override('de'): + self.assertEqual(m.description, '') + class FileFieldsTest(ModeltranslationTestBase): diff --git a/modeltranslation/tests/models.py b/modeltranslation/tests/models.py index 26bfde7..686582d 100644 --- a/modeltranslation/tests/models.py +++ b/modeltranslation/tests/models.py @@ -18,6 +18,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 fc4eab6..48932cd 100644 --- a/modeltranslation/tests/translation.py +++ b/modeltranslation/tests/translation.py @@ -17,7 +17,7 @@ translator.register(TestModel, TestTranslationOptions) ########## Fallback values testing class FallbackModelTranslationOptions(TranslationOptions): - fields = ('title', 'text', 'url', 'email',) + fields = ('title', 'text', 'url', 'email', 'description') fallback_values = "fallback" translator.register(FallbackModel, FallbackModelTranslationOptions) From 627c2b55e93226424ae00781cb502ba63c955ada Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Sun, 17 Mar 2013 17:34:05 +0100 Subject: [PATCH 03/11] Added a fallback_undefined option to allow customizing the value that triggers falling back. --- docs/modeltranslation/usage.rst | 22 ++++++++++++ modeltranslation/fields.py | 49 +++++++++++++++++++-------- modeltranslation/tests/__init__.py | 39 ++++++++++++++++++--- modeltranslation/tests/translation.py | 1 + modeltranslation/translator.py | 22 +++++++----- 5 files changed, 105 insertions(+), 28 deletions(-) diff --git a/docs/modeltranslation/usage.rst b/docs/modeltranslation/usage.rst index 6c2246d..a803235 100644 --- a/docs/modeltranslation/usage.rst +++ b/docs/modeltranslation/usage.rst @@ -283,6 +283,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/fields.py b/modeltranslation/fields.py index 120be66..29855b3 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -33,6 +33,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 @@ -172,37 +181,49 @@ 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): - lang = get_language() - loc_field_name = build_localized_fieldname(self.field.name, lang) - # also update the translation field of the current language + """ + Updates the translation field for 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 = self.field.get_default() + 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) - # If there is no value or it's the field's default fall back to the next language. - # Note: Unless you pass null=True to a CharField it's default is ''. - if val is not None and val != default: + if val is not None and val != undefined: return val - if self.fallback_value is None or not mt_settings.ENABLE_FALLBACKS: - return 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/tests/__init__.py b/modeltranslation/tests/__init__.py index d151899..d0d03ef 100644 --- a/modeltranslation/tests/__init__.py +++ b/modeltranslation/tests/__init__.py @@ -543,16 +543,45 @@ class FallbackTests(ModeltranslationTestBase): with override('en'): self.assertEqual(m.title, '') # '' is the default - def test_nullable(self): + 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): - m = models.FallbackModel(description_en='en', description_de='') - self.assertEqual(m.description_en, 'en') - self.assertEqual(m.description_de, '') + # Non-nullable CharField falls back on empty strings. + m = models.FallbackModel(title_en='value', title_de='') with override('en'): - self.assertEqual(m.description, '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/translation.py b/modeltranslation/tests/translation.py index 48932cd..b881b42 100644 --- a/modeltranslation/tests/translation.py +++ b/modeltranslation/tests/translation.py @@ -25,6 +25,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 3acc55c..69cf965 100644 --- a/modeltranslation/translator.py +++ b/modeltranslation/translator.py @@ -4,8 +4,8 @@ from django.db.models import Manager, ForeignKey from django.db.models.base import ModelBase 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 @@ -281,20 +281,24 @@ class Translator(object): patch_constructor(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.iterkeys(): - 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 From b00a1044c67553179d49894b5a982ce7cbad9af8 Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Sun, 17 Mar 2013 17:43:24 +0100 Subject: [PATCH 04/11] Flake8, overindented continuation line. --- modeltranslation/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py index 29855b3..ea99722 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -182,7 +182,7 @@ class TranslationFieldDescriptor(object): A descriptor used for the original translated field. """ def __init__(self, field, fallback_languages=None, fallback_value=NONE, - fallback_undefined=NONE): + fallback_undefined=NONE): """ Stores fallback options and the original field, so we know it's name and default. From 97d966244d0b4f8c4880f0268b44e5bd7531661e Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Sun, 24 Mar 2013 16:25:15 +0100 Subject: [PATCH 05/11] Implemented a widget that shows a clear checkbox for any non-required form field. Overriden admin widgets of CharFields and TextFields and patched forms.CharField for nullable fields, so undefined translations don't get filled with empty strings when an admin form is saved (thus becoming defined in the case of a nullable field). --- modeltranslation/admin.py | 12 ++++++ modeltranslation/fields.py | 31 ++++++++++++++++ modeltranslation/widgets.py | 73 +++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 modeltranslation/widgets.py diff --git a/modeltranslation/admin.py b/modeltranslation/admin.py index 837888d..a95bacc 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.db import models # Ensure that models are registered for translation before TranslationAdmin # runs. The import is supposed to resolve a race condition between model import @@ -13,12 +14,23 @@ from modeltranslation.settings import DEFAULT_LANGUAGE from modeltranslation.translator import translator from modeltranslation.utils import ( get_translation_fields, build_css_class, build_localized_fieldname, get_language) +from modeltranslation.widgets import (ClearableAdminTextInputWidget, + ClearableAdminTextareaWidget) + + +FORMFIELD_FOR_DBFIELD_DEFAULTS = { + models.CharField: {'widget': ClearableAdminTextInputWidget}, + models.TextField: {'widget': ClearableAdminTextareaWidget}, +} class TranslationBaseModelAdmin(BaseModelAdmin): _orig_was_required = {} def __init__(self, *args, **kwargs): + overrides = FORMFIELD_FOR_DBFIELD_DEFAULTS.copy() + overrides.update(self.formfield_overrides) + self.formfield_overrides = overrides super(TranslationBaseModelAdmin, self).__init__(*args, **kwargs) self.trans_opts = translator.get_options_for_model(self.model) self._patch_prepopulated_fields() diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py index ea99722..0b30b9c 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from django import forms from django.core.exceptions import ImproperlyConfigured from django.db.models import fields @@ -74,6 +75,20 @@ def field_factory(baseclass): return TranslationFieldSpecific +def create_nullable_formfield(form_class): + """ + Creates a form class subclass that ensures that ``None`` is not cast to + anything (like the empty string with ``CharField`` and its derivatives). + """ + class NullableField(form_class): + def to_python(self, value): + if value is None: + return value + return super(NullableField, self).to_python(value) + NullableField.__name__ = 'Nullable%s' % form_class.__name__ + return NullableField + + class TranslationField(object): """ The translation field functions as a proxy to the original field which is @@ -160,6 +175,22 @@ 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. + """ + formfield = super(TranslationField, self).formfield(*args, **kwargs) + if (self.translated_field.null and + issubclass(formfield.__class__, forms.CharField)): + kwargs['form_class'] = create_nullable_formfield(formfield.__class__) + formfield = super(TranslationField, self).formfield(*args, **kwargs) + return formfield + def south_field_triple(self): """ Returns a suitable description of this field for South. diff --git a/modeltranslation/widgets.py b/modeltranslation/widgets.py new file mode 100644 index 0000000..ec615b0 --- /dev/null +++ b/modeltranslation/widgets.py @@ -0,0 +1,73 @@ +from django.contrib.admin.widgets import AdminTextInputWidget, AdminTextareaWidget +from django.forms.widgets import Widget, TextInput, Textarea, CheckboxInput +from django.utils.html import format_html +from django.utils.translation import ugettext + + +class ClearableInput(Widget): + clear_checkbox_label = ugettext("None") + template = '{0} {2} {3}' + # TODO: Label would be proper, but admin applies some hardly undoable + # styling to labels. + # template = '{} {}' + + def __init__(self, *args, **kwargs): + """ + Allows overriding the empty value. + """ + self.empty_value = kwargs.get('empty_value', None) + super(ClearableInput, self).__init__(*args, **kwargs) + + 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' + + def render(self, name, value, attrs=None): + """ + If the field is not required, appends a checkbox that clears the value. + """ + original = super(ClearableInput, self).render(name, value, attrs) + if self.is_required: + return original + else: + checkbox_name = self.clear_checkbox_name(name) + checkbox_id = self.clear_checkbox_id(checkbox_name) + checkbox_label = self.clear_checkbox_label + checkbox = CheckboxInput().render( + checkbox_name, value == self.empty_value, attrs={'id': checkbox_id}) + return format_html(self.template, original, checkbox_id, checkbox_label, checkbox) + + def value_from_datadict(self, data, files, name): + """ + If the clear checkbox is checked returns the empty value, completely + ignoring the original input. + """ + clear = CheckboxInput().value_from_datadict(data, files, self.clear_checkbox_name(name)) + if not self.is_required and clear: + return self.empty_value + else: + return super(ClearableInput, self).value_from_datadict(data, files, name) + + +class ClearableTextInput(ClearableInput, TextInput): + pass + + +class ClearableTextarea(ClearableInput, Textarea): + pass + + +class ClearableAdminTextInputWidget(ClearableInput, AdminTextInputWidget): + pass + + +class ClearableAdminTextareaWidget(ClearableInput, AdminTextareaWidget): + pass From befae736c0ef89d1ecada1c0e698fc600bdfd273 Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Sun, 24 Mar 2013 18:31:45 +0100 Subject: [PATCH 06/11] Replaced format_html with mark_safe/conditional_escape for Django < 1.5 compatibility. --- modeltranslation/widgets.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/modeltranslation/widgets.py b/modeltranslation/widgets.py index ec615b0..f17164c 100644 --- a/modeltranslation/widgets.py +++ b/modeltranslation/widgets.py @@ -1,6 +1,7 @@ from django.contrib.admin.widgets import AdminTextInputWidget, AdminTextareaWidget from django.forms.widgets import Widget, TextInput, Textarea, CheckboxInput -from django.utils.html import format_html +from django.utils.html import conditional_escape +from django.utils.safestring import mark_safe from django.utils.translation import ugettext @@ -43,7 +44,11 @@ class ClearableInput(Widget): checkbox_label = self.clear_checkbox_label checkbox = CheckboxInput().render( checkbox_name, value == self.empty_value, attrs={'id': checkbox_id}) - return format_html(self.template, original, checkbox_id, checkbox_label, checkbox) + return mark_safe(self.template.format( + conditional_escape(original), + conditional_escape(checkbox_id), + conditional_escape(checkbox_label), + conditional_escape(checkbox))) def value_from_datadict(self, data, files, name): """ From 59199bb2825e926e8cab47cd5a4c958faab597f1 Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Wed, 3 Apr 2013 18:12:14 +0200 Subject: [PATCH 07/11] Made the widget template for nullable fields unicode, so the widget renders with non-ascii characters in input value. --- modeltranslation/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modeltranslation/widgets.py b/modeltranslation/widgets.py index f17164c..68d03db 100644 --- a/modeltranslation/widgets.py +++ b/modeltranslation/widgets.py @@ -7,7 +7,7 @@ from django.utils.translation import ugettext class ClearableInput(Widget): clear_checkbox_label = ugettext("None") - template = '{0} {2} {3}' + template = u'{0} {2} {3}' # TODO: Label would be proper, but admin applies some hardly undoable # styling to labels. # template = '{} {}' From 22faea9646443a6878c980b55c2304020e524d27 Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Tue, 14 May 2013 22:21:03 +0200 Subject: [PATCH 08/11] Reworked the solution by replacing clearable widgets with a widget wrapper. This makes it possible to wrap only widgets of nullable fields, and thus not add the clear checkbox when it's not needed. The implementation is loosely based on RelatedFieldWidgetWrapper from contrib.admin, but uses a bit more Python magic; tested only with TextInput and Textarea widgets and their admin counterparts, but with a bit of luck should work with other widgets too. --- modeltranslation/admin.py | 14 ++--- modeltranslation/fields.py | 34 +++++------ modeltranslation/forms.py | 11 ++++ modeltranslation/widgets.py | 109 ++++++++++++++++++------------------ 4 files changed, 84 insertions(+), 84 deletions(-) diff --git a/modeltranslation/admin.py b/modeltranslation/admin.py index bbb96ee..dd54965 100644 --- a/modeltranslation/admin.py +++ b/modeltranslation/admin.py @@ -5,6 +5,7 @@ from django.contrib import admin from django.contrib.admin.options import BaseModelAdmin, flatten_fieldsets, InlineModelAdmin from django.contrib.contenttypes import generic from django.db import models +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 @@ -14,22 +15,13 @@ from modeltranslation.settings import DEFAULT_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 ClearableAdminTextInputWidget, ClearableAdminTextareaWidget - - -FORMFIELD_FOR_DBFIELD_DEFAULTS = { - models.CharField: {'widget': ClearableAdminTextInputWidget}, - models.TextField: {'widget': ClearableAdminTextareaWidget}, -} +from modeltranslation.widgets import ClearableWidgetWrapper class TranslationBaseModelAdmin(BaseModelAdmin): _orig_was_required = {} def __init__(self, *args, **kwargs): - overrides = FORMFIELD_FOR_DBFIELD_DEFAULTS.copy() - overrides.update(self.formfield_overrides) - self.formfield_overrides = overrides super(TranslationBaseModelAdmin, self).__init__(*args, **kwargs) self.trans_opts = translator.get_options_for_model(self.model) self._patch_prepopulated_fields() @@ -66,6 +58,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 diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py index db76ad3..838874a 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -6,6 +6,7 @@ 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 = ( @@ -75,20 +76,6 @@ def field_factory(baseclass): return TranslationFieldSpecific -def create_nullable_formfield(form_class): - """ - Creates a form class subclass that ensures that ``None`` is not cast to - anything (like the empty string with ``CharField`` and its derivatives). - """ - class NullableField(form_class): - def to_python(self, value): - if value is None: - return value - return super(NullableField, self).to_python(value) - NullableField.__name__ = 'Nullable%s' % form_class.__name__ - return NullableField - - class TranslationField(object): """ The translation field functions as a proxy to the original field which is @@ -184,12 +171,21 @@ class TranslationField(object): 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 and - issubclass(formfield.__class__, forms.CharField)): - kwargs['form_class'] = create_nullable_formfield(formfield.__class__) - 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): @@ -238,7 +234,7 @@ class TranslationFieldDescriptor(object): def __set__(self, instance, value): """ - Updates translation field for the current language. + 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. 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/widgets.py b/modeltranslation/widgets.py index 68d03db..460bf2f 100644 --- a/modeltranslation/widgets.py +++ b/modeltranslation/widgets.py @@ -1,23 +1,68 @@ -from django.contrib.admin.widgets import AdminTextInputWidget, AdminTextareaWidget -from django.forms.widgets import Widget, TextInput, Textarea, CheckboxInput +import copy + +from django.forms.widgets import Widget, CheckboxInput from django.utils.html import conditional_escape from django.utils.safestring import mark_safe from django.utils.translation import ugettext -class ClearableInput(Widget): +class ClearableWidgetWrapper(Widget): clear_checkbox_label = ugettext("None") - template = u'{0} {2} {3}' + template = u'{0} {2} {3}' # TODO: Label would be proper, but admin applies some hardly undoable - # styling to labels. - # template = '{} {}' + # styling to labels. + # template = '{} {}' - def __init__(self, *args, **kwargs): + def __init__(self, widget, empty_value=None): """ + Remebers the widget we are wrapping and precreates a checkbox input. Allows overriding the empty value. """ - self.empty_value = kwargs.get('empty_value', None) - super(ClearableInput, self).__init__(*args, **kwargs) + 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. + """ + return getattr(self.widget, name) + + 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 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): """ @@ -30,49 +75,3 @@ class ClearableInput(Widget): Given the name of the clear checkbox input, returns the HTML id for it. """ return name + '_id' - - def render(self, name, value, attrs=None): - """ - If the field is not required, appends a checkbox that clears the value. - """ - original = super(ClearableInput, self).render(name, value, attrs) - if self.is_required: - return original - else: - checkbox_name = self.clear_checkbox_name(name) - checkbox_id = self.clear_checkbox_id(checkbox_name) - checkbox_label = self.clear_checkbox_label - checkbox = CheckboxInput().render( - checkbox_name, value == self.empty_value, attrs={'id': checkbox_id}) - return mark_safe(self.template.format( - conditional_escape(original), - 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 empty value, completely - ignoring the original input. - """ - clear = CheckboxInput().value_from_datadict(data, files, self.clear_checkbox_name(name)) - if not self.is_required and clear: - return self.empty_value - else: - return super(ClearableInput, self).value_from_datadict(data, files, name) - - -class ClearableTextInput(ClearableInput, TextInput): - pass - - -class ClearableTextarea(ClearableInput, Textarea): - pass - - -class ClearableAdminTextInputWidget(ClearableInput, AdminTextInputWidget): - pass - - -class ClearableAdminTextareaWidget(ClearableInput, AdminTextareaWidget): - pass From 7cd60aa9771914f970b45fe701f74b2ec471b156 Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Wed, 15 May 2013 12:01:46 +0200 Subject: [PATCH 09/11] Added a small script unchecking the clear box after a wrapped input is edited. Note: Starting global declaration, and the somewhat ugly jQuery call are for JSLint. Also written a class comment for the widget wrapper and cleaned up some Flake8 errors. --- modeltranslation/admin.py | 1 - .../modeltranslation/js/clearable_inputs.js | 13 +++++++ modeltranslation/widgets.py | 36 +++++++++++++++---- 3 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 modeltranslation/static/modeltranslation/js/clearable_inputs.js diff --git a/modeltranslation/admin.py b/modeltranslation/admin.py index dd54965..f48571b 100644 --- a/modeltranslation/admin.py +++ b/modeltranslation/admin.py @@ -4,7 +4,6 @@ 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.db import models from django import forms # Ensure that models are registered for translation before TranslationAdmin 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/widgets.py b/modeltranslation/widgets.py index 460bf2f..6f45e40 100644 --- a/modeltranslation/widgets.py +++ b/modeltranslation/widgets.py @@ -1,18 +1,34 @@ -import copy - -from django.forms.widgets import Widget, CheckboxInput +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 = u'{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. @@ -29,9 +45,17 @@ class ClearableWidgetWrapper(Widget): """ return getattr(self.widget, name) + @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 + Appends a checkbox for clearing the value (that is, setting the field with the ``empty_value``). """ wrapped = self.widget.render(name, value, attrs) @@ -48,8 +72,8 @@ class ClearableWidgetWrapper(Widget): def value_from_datadict(self, data, files, name): """ - If the clear checkbox is checked returns the empty value, completely - ignoring the original input. + 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: From 027349bd9abf7fc6a559c827c969d015db9a6bac Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Wed, 15 May 2013 12:37:14 +0200 Subject: [PATCH 10/11] Set self to something in the replace_orig_field doctest, so PyFlakes doesn't complain. --- modeltranslation/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modeltranslation/admin.py b/modeltranslation/admin.py index f48571b..a4493c7 100644 --- a/modeltranslation/admin.py +++ b/modeltranslation/admin.py @@ -94,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]) From 4cc0d2b25ecc7957ea4d8938580070e17b8c7697 Mon Sep 17 00:00:00 2001 From: wrwrwr Date: Wed, 15 May 2013 16:50:14 +0200 Subject: [PATCH 11/11] Python 3 compatiblity: no unicode literals for 3.2 and safeguard against loop during copy operation. --- modeltranslation/widgets.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/modeltranslation/widgets.py b/modeltranslation/widgets.py index 6f45e40..3d4c3bc 100644 --- a/modeltranslation/widgets.py +++ b/modeltranslation/widgets.py @@ -1,3 +1,5 @@ +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 @@ -21,7 +23,7 @@ class ClearableWidgetWrapper(Widget): pass another one to the constructor. """ clear_checkbox_label = ugettext("None") - template = u'{0} {2} {3}' + template = '{0} {2} {3}' # TODO: Label would be proper, but admin applies some hardly undoable # styling to labels. # template = '{} {}' @@ -43,7 +45,9 @@ class ClearableWidgetWrapper(Widget): If we don't have a property or a method, chances are the wrapped widget does. """ - return getattr(self.widget, name) + if name != 'widget': + return getattr(self.widget, name) + raise AttributeError @property def media(self):