diff --git a/modeltranslation/admin.py b/modeltranslation/admin.py index bd97166..61a3989 100644 --- a/modeltranslation/admin.py +++ b/modeltranslation/admin.py @@ -19,6 +19,7 @@ from modeltranslation.widgets import ClearableWidgetWrapper class TranslationBaseModelAdmin(BaseModelAdmin): _orig_was_required = {} + both_empty_values_fields = () def __init__(self, *args, **kwargs): super(TranslationBaseModelAdmin, self).__init__(*args, **kwargs) @@ -57,7 +58,17 @@ class TranslationBaseModelAdmin(BaseModelAdmin): else: orig_formfield = self.formfield_for_dbfield(orig_field, **kwargs) field.widget = deepcopy(orig_formfield.widget) - if db_field.null and isinstance(field.widget, (forms.TextInput, forms.Textarea)): + if orig_field.name in self.both_empty_values_fields: + from modeltranslation.forms import NullableField, NullCharField + form_class = field.__class__ + if issubclass(form_class, NullCharField): + # NullableField don't work with NullCharField + form_class.__bases__ = tuple( + b for b in form_class.__bases__ if b != NullCharField) + field.__class__ = type( + 'Nullable%s' % form_class.__name__, (NullableField, form_class), {}) + if ((db_field.empty_value == 'both' or orig_field.name in self.both_empty_values_fields) + 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') diff --git a/modeltranslation/fields.py b/modeltranslation/fields.py index e339d9e..f3ae8fb 100644 --- a/modeltranslation/fields.py +++ b/modeltranslation/fields.py @@ -45,7 +45,7 @@ class NONE: pass -def create_translation_field(model, field_name, lang): +def create_translation_field(model, field_name, lang, empty_value): """ Translation field factory. Returns a ``TranslationField`` based on a fieldname and a language. @@ -58,13 +58,15 @@ def create_translation_field(model, field_name, lang): If the class is neither a subclass of fields in ``SUPPORTED_FIELDS``, nor in ``CUSTOM_FIELDS`` an ``ImproperlyConfigured`` exception will be raised. """ + if empty_value not in ('', 'both', None, NONE): + raise ImproperlyConfigured('%s is not a valid empty_value.' % empty_value) field = model._meta.get_field(field_name) cls_name = field.__class__.__name__ if not (isinstance(field, SUPPORTED_FIELDS) or cls_name in mt_settings.CUSTOM_FIELDS): raise ImproperlyConfigured( '%s is not supported by modeltranslation.' % cls_name) translation_class = field_factory(field.__class__) - return translation_class(translated_field=field, language=lang) + return translation_class(translated_field=field, language=lang, empty_value=empty_value) def field_factory(baseclass): @@ -95,7 +97,7 @@ class TranslationField(object): The translation field needs to know which language it contains therefore that needs to be specified when the field is created. """ - def __init__(self, translated_field, language, *args, **kwargs): + def __init__(self, translated_field, language, empty_value, *args, **kwargs): # Update the dict of this field with the content of the original one # This might be a bit radical?! Seems to work though... self.__dict__.update(translated_field.__dict__) @@ -103,6 +105,9 @@ class TranslationField(object): # Store the originally wrapped field for later self.translated_field = translated_field self.language = language + self.empty_value = empty_value + if empty_value is NONE: + self.empty_value = None if translated_field.null else '' # Translation are always optional (for now - maybe add some parameters # to the translation options for configuring this) @@ -166,27 +171,43 @@ class TranslationField(object): 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. + Returns proper formfield, according to empty_values setting + (only for ``forms.CharField`` subclasses). - The ``forms.CharField`` somewhat surprising behaviour is documented as a - "won't fix": https://code.djangoproject.com/ticket/9590. + There are 3 different formfields: + - CharField that stores all empty values as empty strings; + - NullCharField that stores all empty values as None (Null); + - NullableField that can store both None and empty_string. + By default, if no empty_values was specified in model's translation options, + NullCharField would be used if the original field is nullable, CharField otherwise. + + This can be overridden by setting empty_values to '' or None. + + Setting 'both' will result in NullableField being used. 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. + + 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.null: - if isinstance(formfield, forms.CharField): + if isinstance(formfield, forms.CharField): + if self.empty_value is None: + from modeltranslation.forms import NullCharField + form_class = formfield.__class__ + kwargs['form_class'] = type( + 'Null%s' % form_class.__name__, (NullCharField, form_class), {}) + formfield = super(TranslationField, self).formfield(*args, **kwargs) + elif self.empty_value == 'both': 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) + if isinstance(formfield.widget, (forms.TextInput, forms.Textarea)): + formfield.widget = ClearableWidgetWrapper(formfield.widget) return formfield def save_form_data(self, instance, data): diff --git a/modeltranslation/forms.py b/modeltranslation/forms.py index 8c8d855..f74c2c9 100644 --- a/modeltranslation/forms.py +++ b/modeltranslation/forms.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from django import forms +from django.core import validators from modeltranslation.fields import TranslationField @@ -12,7 +13,17 @@ class TranslationModelForm(forms.ModelForm): del self.fields[f.name] -class NullableField(object): +class NullCharField(forms.CharField): + """ + CharField subclass that returns ``None`` when ``CharField`` would return empty string. + """ + def to_python(self, value): + if value in validators.EMPTY_VALUES: + return None + return super(NullCharField, self).to_python(value) + + +class NullableField(forms.Field): """ Form field mixin that ensures that ``None`` is not cast to anything (like the empty string with ``CharField`` and its derivatives). diff --git a/modeltranslation/tests/translation.py b/modeltranslation/tests/translation.py index 41a1c52..60180b4 100644 --- a/modeltranslation/tests/translation.py +++ b/modeltranslation/tests/translation.py @@ -12,6 +12,7 @@ from modeltranslation.tests.models import ( class TestTranslationOptions(TranslationOptions): fields = ('title', 'text', 'url', 'email',) + empty_values = '' translator.register(TestModel, TestTranslationOptions) diff --git a/modeltranslation/translator.py b/modeltranslation/translator.py index fac1c56..bafdecd 100644 --- a/modeltranslation/translator.py +++ b/modeltranslation/translator.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -from django.conf import settings from django.utils.six import with_metaclass from django.db.models import Manager, ForeignKey, OneToOneField from django.db.models.base import ModelBase @@ -11,7 +10,7 @@ from modeltranslation.fields import (NONE, create_translation_field, Translation TranslatedRelationIdDescriptor, LanguageCacheSingleObjectDescriptor) from modeltranslation.manager import MultilingualManager, rewrite_lookup_key -from modeltranslation.utils import build_localized_fieldname +from modeltranslation.utils import build_localized_fieldname, parse_field class AlreadyRegistered(Exception): @@ -105,13 +104,15 @@ def add_translation_fields(model, opts): Adds newly created translation fields to the given translation options. """ + model_empty_values = getattr(opts, 'empty_values', NONE) for field_name in opts.local_fields.keys(): - for l in settings.LANGUAGES: + field_empty_value = parse_field(model_empty_values, field_name, NONE) + for l in mt_settings.AVAILABLE_LANGUAGES: # Create a dynamic translation field translation_field = create_translation_field( - model=model, field_name=field_name, lang=l[0]) + model=model, field_name=field_name, lang=l, empty_value=field_empty_value) # Construct the name for the localized field - localized_field_name = build_localized_fieldname(field_name, l[0]) + localized_field_name = build_localized_fieldname(field_name, l) # Check if the model already has a field by that name if hasattr(model, localized_field_name): raise ValueError( @@ -356,14 +357,8 @@ class Translator(object): model_fallback_undefined = getattr(opts, 'fallback_undefined', NONE) for field_name in opts.local_fields.keys(): 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 - if isinstance(model_fallback_undefined, dict): - field_fallback_undefined = model_fallback_undefined.get(field_name, NONE) - else: - field_fallback_undefined = model_fallback_undefined + field_fallback_value = parse_field(model_fallback_values, field_name, NONE) + field_fallback_undefined = parse_field(model_fallback_undefined, field_name, NONE) descriptor = TranslationFieldDescriptor( field, fallback_languages=model_fallback_languages, diff --git a/modeltranslation/utils.py b/modeltranslation/utils.py index 1c7ba8e..3a1eb64 100644 --- a/modeltranslation/utils.py +++ b/modeltranslation/utils.py @@ -148,3 +148,13 @@ def fallbacks(enable=True): yield finally: settings.ENABLE_FALLBACKS = current_enable_fallbacks + + +def parse_field(setting, field_name, default): + """ + Extract result from single-value or dict-type setting like fallback_values. + """ + if isinstance(setting, dict): + return setting.get(field_name, default) + else: + return setting