1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398
|
from django import forms
from django.core.exceptions import NON_FIELD_ERRORS, ObjectDoesNotExist, ValidationError
from django.forms import BoundField
from django.forms.models import BaseInlineFormSet, ModelFormMetaclass
from django.utils.functional import cached_property
from django.utils.translation import get_language
from django.utils.translation.trans_real import get_supported_language_variant
from parler.models import TranslationDoesNotExist
__all__ = (
"TranslatableModelForm",
"TranslatedField",
"BaseTranslatableModelForm",
#'TranslatableModelFormMetaclass',
)
class TranslatedField:
"""
A wrapper for a translated form field.
This wrapper can be used to declare translated fields on the form, e.g.
.. code-block:: python
class MyForm(TranslatableModelForm):
title = TranslatedField()
slug = TranslatedField()
description = TranslatedField(form_class=forms.CharField, widget=TinyMCE)
"""
def __init__(self, **kwargs):
# The metaclass performs the magic replacement with the actual formfield.
self.kwargs = kwargs
class BaseTranslatableModelForm(forms.BaseModelForm):
"""
The base methods added to :class:`TranslatableModelForm` to fetch and store translated fields.
"""
language_code = None # Set by TranslatableAdmin.get_form() on the constructed subclass.
def __init__(self, *args, **kwargs):
current_language = kwargs.pop("_current_language", None) # Used for TranslatableViewMixin
super().__init__(*args, **kwargs)
# Load the initial values for the translated fields
instance = kwargs.get("instance", None)
if instance:
for meta in instance._parler_meta:
try:
# By not auto creating a model, any template code that reads the fields
# will continue to see one of the other translations.
# This also causes admin inlines to show the fallback title in __unicode__.
translation = instance._get_translated_model(meta=meta)
except TranslationDoesNotExist:
pass
else:
for field in meta.get_translated_fields():
try:
model_field = translation._meta.get_field(field)
self.initial.setdefault(
field, model_field.value_from_object(translation)
)
except ObjectDoesNotExist:
# This occurs when a ForeignKey field is part of the translation,
# but it's value is still not yet, and the field has null=False.
pass
# Typically already set by admin
if self.language_code is None:
if instance:
self.language_code = instance.get_current_language()
else:
self.language_code = current_language or get_language()
try:
get_supported_language_variant(self.language_code)
except LookupError:
# Instead of raising a ValidationError
raise ValueError(
"Translatable forms can't be initialized for the language '{}', "
"that option does not exist in the 'LANGUAGES' setting.".format(self.language_code)
)
def _get_translation_validation_exclusions(self, translation):
exclude = ["master"]
if "language_code" not in self.fields:
exclude.append("language_code")
# This is the same logic as Django's _get_validation_exclusions(),
# only using the translation model instead of the master instance.
for field_name in translation.get_translated_fields():
if field_name not in self.fields:
# Exclude fields that aren't on the form.
exclude.append(field_name)
elif self._meta.fields and field_name not in self._meta.fields:
# Field might be added manually at the form,
# but wasn't part of the ModelForm's meta.
exclude.append(field_name)
elif self._meta.exclude and field_name in self._meta.exclude:
# Same for exclude.
exclude.append(field_name)
elif field_name in self._errors.keys():
# No need to validate fields that already failed.
exclude.append(field_name)
else:
# Exclude fields that are not required in the form, while the model requires them.
# See _get_validation_exclusions() for the detailed bits of this logic.
form_field = self.fields[field_name]
model_field = translation._meta.get_field(field_name)
field_value = self.cleaned_data.get(field_name)
if (
not model_field.blank
and not form_field.required
and field_value in form_field.empty_values
):
exclude.append(field_name)
return exclude
def _post_clean(self):
# Copy the translated fields into the model
# Make sure the language code is set as early as possible (so it's active during most clean() methods)
self.instance.set_current_language(self.language_code)
self.save_translated_fields()
# Perform the regular clean checks, this also updates self.instance
super()._post_clean()
def save_translated_fields(self):
"""
Save all translated fields.
"""
fields = {}
# Collect all translated fields {'name': 'value'}
for field in self._translated_fields:
try:
value = self.cleaned_data[field]
except KeyError: # Field has a ValidationError
continue
fields[field] = value
# Set the field values on their relevant models
translations = self.instance._set_translated_fields(**fields)
# Perform full clean on models
non_translated_fields = {"id", "master_id", "language_code"}
for translation in translations:
self._post_clean_translation(translation)
# Assign translated fields to the model (using the TranslatedAttribute descriptor)
for field in translation._get_field_names():
if field in non_translated_fields:
continue
setattr(self.instance, field, getattr(translation, field))
def _post_clean_translation(self, translation):
exclude = self._get_translation_validation_exclusions(translation)
try:
translation.full_clean(exclude=exclude, validate_unique=False)
except ValidationError as e:
self._update_errors(e)
# Validate uniqueness if needed.
if self._validate_unique:
try:
translation.validate_unique()
except ValidationError as e:
self._update_errors(e)
@cached_property
def _translated_fields(self):
field_names = self._meta.model._parler_meta.get_all_fields()
return [f_name for f_name in field_names if f_name in self.fields]
def __getitem__(self, name):
"""
Return a :class:`TranslatableBoundField` for translated models.
This extends the default ``form[field]`` interface that produces the BoundField for HTML templates.
"""
boundfield = super().__getitem__(name)
if name in self._translated_fields:
# Oh the wonders of Python :)
boundfield.__class__ = _upgrade_boundfield_class(boundfield.__class__)
return boundfield
UPGRADED_CLASSES = {}
def _upgrade_boundfield_class(cls):
if cls is BoundField:
return TranslatableBoundField
elif issubclass(cls, TranslatableBoundField):
return cls
# When some other package also performs this same trick,
# combine both classes on the fly. Avoid having to do that each time.
# This is needed for django-slug-preview
try:
return UPGRADED_CLASSES[cls]
except KeyError:
# Create once
new_cls = type(f"Translatable{cls.__name__}", (cls, TranslatableBoundField), {})
UPGRADED_CLASSES[cls] = new_cls
return new_cls
class TranslatableBoundField(BoundField):
"""
Decorating the regular BoundField to distinguish translatable fields in the admin.
"""
#: A tagging attribute, making it easy for templates to identify these fields
is_translatable = True
def label_tag(
self, contents=None, attrs=None, *args, **kwargs
): # extra args differ per Django version
if attrs is None:
attrs = {}
attrs["class"] = (attrs.get("class", "") + " translatable-field").strip()
return super().label_tag(contents, attrs, *args, **kwargs)
# The as_widget() won't be overwritten to add a 'class' attr,
# because it will overwrite what AdminTextInputWidget and fields have as default.
class TranslatableModelFormMetaclass(ModelFormMetaclass):
"""
Meta class to add translated form fields to the form.
"""
def __new__(mcs, name, bases, attrs):
# Before constructing class, fetch attributes from bases list.
form_meta = _get_mro_attribute(bases, "_meta")
form_base_fields = _get_mro_attribute(
bases, "base_fields", {}
) # set by previous class level.
if form_meta:
# Not declaring the base class itself, this is a subclass.
# Read the model from the 'Meta' attribute. This even works in the admin,
# as `modelform_factory()` includes a 'Meta' attribute.
# The other options can be read from the base classes.
form_new_meta = attrs.get("Meta", form_meta)
form_model = form_new_meta.model if form_new_meta else form_meta.model
# Detect all placeholders at this class level.
placeholder_fields = [
f_name
for f_name, attr_value in attrs.items()
if isinstance(attr_value, TranslatedField)
]
# Include the translated fields as attributes, pretend that these exist on the form.
# This also works when assigning `form = TranslatableModelForm` in the admin,
# since the admin always uses modelform_factory() on the form class, and therefore triggering this metaclass.
if form_model:
for translations_model in form_model._parler_meta.get_all_models():
fields = getattr(form_new_meta, "fields", form_meta.fields)
exclude = getattr(form_new_meta, "exclude", form_meta.exclude) or ()
widgets = getattr(form_new_meta, "widgets", form_meta.widgets) or ()
labels = getattr(form_new_meta, "labels", form_meta.labels) or ()
help_texts = getattr(form_new_meta, "help_texts", form_meta.help_texts) or ()
error_messages = (
getattr(form_new_meta, "error_messages", form_meta.error_messages) or ()
)
formfield_callback = attrs.get("formfield_callback", None)
if fields == "__all__":
fields = None
for f_name in translations_model.get_translated_fields():
# Add translated field if not already added, and respect exclude options.
if f_name in placeholder_fields:
# The TranslatedField placeholder can be replaced directly with actual field, so do that.
attrs[f_name] = _get_model_form_field(
translations_model,
f_name,
formfield_callback=formfield_callback,
**attrs[f_name].kwargs,
)
# The next code holds the same logic as fields_for_model()
# The f.editable check happens in _get_model_form_field()
elif (
f_name not in form_base_fields
and (fields is None or f_name in fields)
and f_name not in exclude
and not f_name in attrs
):
# Get declared widget kwargs
if f_name in widgets:
# Not combined with declared fields (e.g. the TranslatedField placeholder)
kwargs = {"widget": widgets[f_name]}
else:
kwargs = {}
if f_name in help_texts:
kwargs["help_text"] = help_texts[f_name]
if f_name in labels:
kwargs["label"] = labels[f_name]
if f_name in error_messages:
kwargs["error_messages"] = error_messages[f_name]
# See if this formfield was previously defined using a TranslatedField placeholder.
placeholder = _get_mro_attribute(bases, f_name)
if placeholder and isinstance(placeholder, TranslatedField):
kwargs.update(placeholder.kwargs)
# Add the form field as attribute to the class.
formfield = _get_model_form_field(
translations_model,
f_name,
formfield_callback=formfield_callback,
**kwargs,
)
if formfield is not None:
attrs[f_name] = formfield
# Call the super class with updated `attrs` dict.
return super().__new__(mcs, name, bases, attrs)
def _get_mro_attribute(bases, name, default=None):
for base in bases:
try:
return getattr(base, name)
except AttributeError:
continue
return default
def _get_model_form_field(model, name, formfield_callback=None, **kwargs):
"""
Utility to create the formfield from a model field.
When a field is not editable, a ``None`` will be returned.
"""
field = model._meta.get_field(name)
if not field.editable: # see fields_for_model() logic in Django.
return None
# Apply admin formfield_overrides
if formfield_callback is None:
formfield = field.formfield(**kwargs)
elif not callable(formfield_callback):
raise TypeError("formfield_callback must be a function or callable")
else:
formfield = formfield_callback(field, **kwargs)
return formfield
class TranslatableModelForm(
BaseTranslatableModelForm, forms.ModelForm, metaclass=TranslatableModelFormMetaclass
):
"""
The model form to use for translated models.
"""
# The multiple parent classes are needed in django 1.7 to pass check admin.E016:
# "The value of 'form' must inherit from 'BaseModelForm'"
# so we use our copied version in parler.utils.compat
#
# Also, the class must inherit from ModelForm,
# or the ModelFormMetaclass will skip initialization.
# It only adds the _meta from anything that extends ModelForm.
class TranslatableBaseInlineFormSet(BaseInlineFormSet):
"""
The formset base for creating inlines with translatable models.
"""
language_code = None
def _construct_form(self, i, **kwargs):
form = super()._construct_form(i, **kwargs)
form.language_code = self.language_code # Pass the language code for new objects!
return form
def save_new(self, form, commit=True):
obj = super().save_new(form, commit)
return obj
# Backwards compatibility
TranslatableModelFormMixin = BaseTranslatableModelForm
|