File: forms.py

package info (click to toggle)
python-django-parler 2.3-4
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 1,032 kB
  • sloc: python: 4,293; makefile: 164; sh: 6
file content (398 lines) | stat: -rw-r--r-- 16,355 bytes parent folder | download | duplicates (2)
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