Skip to content

Commit 5890d28

Browse files
committed
Merge pull request #221 from spectras/feature/form_refactor
Refactor TranslatableModelForm
2 parents 523e460 + adc3c8a commit 5890d28

8 files changed

Lines changed: 917 additions & 436 deletions

File tree

docs/internal/forms.rst

Lines changed: 146 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
.. module:: hvad.forms
66

7-
87
*******************************
98
TranslatableModelFormMetaclass
109
*******************************
@@ -14,48 +13,169 @@ TranslatableModelFormMetaclass
1413
Metaclass of :class:`TranslatableModelForm`.
1514

1615
.. method:: __new__(cls, name, bases, attrs)
17-
18-
The main thing happening in this metaclass is that the declared and base
19-
fields on the form are built by calling
20-
:func:`django.forms.models.fields_for_model` using the correct model
21-
depending on whether the field is translated or not. This metaclass also
22-
enforces the translations accessor and the master foreign key to be
23-
excluded.
16+
17+
Uses Django's internal ``fields_for_model`` to get translated fields
18+
for model and fields declarations, then lets Django handle the other
19+
fields. Once it is done, it merges the translated fields, preserving order.
20+
21+
Special handling is done to:
22+
23+
* Prevent ``language_code`` from being used in any way by a field. This is
24+
because the form uses the ``language_code`` key in the ``cleaned_data``
25+
dictionary.
26+
* Prevent ``master`` from being recognized as a translated field. It is
27+
still a valid field name though.
28+
* Prevent the translations accessor from being used as a field.
2429

2530

2631
**********************
2732
TranslatableModelForm
2833
**********************
2934

30-
.. class:: TranslatableModelForm(ModelForm)
35+
.. class:: BaseTranslatableModelForm(BaseModelForm)
36+
37+
The actual class supporting the features and methods, but lacking metaclass
38+
sugar. Inherited by :class:`~TranslatableModelForm` to attach the metaclass.
39+
Details are documented on that class.
40+
41+
.. class:: TranslatableModelForm(BaseTranslatableModelForm)
42+
43+
Main form for editing :class:`~hvad.models.TranslatableModel` instances. As with
44+
regular django :class:`~django.forms.Form` classes, it can be used either
45+
directly or by passing it to :func:`~translatable_modelform_factory`.
46+
47+
As an extension to regular forms, it handles translation and can be bound
48+
to a language. Binding to a language is done by setting :attr:`language`
49+
on the class (not the instance), either by inheriting it manually or
50+
using the factory function. Once bound to a language, the form is in
51+
**enforce** mode: all manipulations will be done using that language
52+
exclusively.
3153

3254
.. attribute:: __metaclass__
33-
55+
3456
:class:`TranslatableModelFormMetaclass`
3557

58+
.. attribute:: language
59+
60+
The language the form is bound to. This is a class attribute. If present,
61+
the form is in **enforce** mode and will only deal with the specified
62+
language. See each method for the exact effects.
63+
3664
.. method:: __init__(self, data=None, files=None, auto_id='id_%s', prefix=None, initial=None, error_class=ErrorList, label_suffix=':', empty_permitted=False, instance=None)
3765

38-
If this class is initialized with an instance, it updates ``initial`` to
39-
also contain the data from the :term:`Translations Model` if it can be
40-
found.
66+
If this class is initialized with an instance, that has a translation
67+
loaded, it updates ``initial`` to also contain the data from the
68+
:term:`Translations Model`.
69+
70+
If the form is not bound to a language, it will use the data from the
71+
instance. If the instance has no translation loaded, an attempt will be
72+
made at loading the current language, and if that fails the fields will
73+
be blank.
74+
75+
If the form is in **enforce** mode and the instance does not have the
76+
correct translation loaded, then:
77+
78+
* it will attempt to load it from the database.
79+
* if that fails, it will try to use the loaded translation on the instance.
80+
* if that fails (instance is untranslated), it will use default values.
81+
82+
This process results in new translations being pre-populated with data
83+
from another language. Simply pass an instance in that language, or an
84+
untranslated instance if the behavior is not desired.
85+
86+
.. method:: clean(self)
87+
88+
If the form is in **enforce** mode, namely if it has a
89+
``language`` property, apply the it to ``cleaned_data``. As usual, the
90+
special value ``None`` is replaced by current language.
91+
92+
If the form is not bound to a language, this method does nothing. It is
93+
then possible to either use :meth:`save` in unbound mode or set the
94+
language code manually in ``cleaned_data['language_code']``.
95+
96+
.. note:: A missing language is not the same as ``None``. While ``None``
97+
will be replaced by current language and applied to ``cleaned_data``,
98+
a missing language will not apply any language at all.
99+
100+
.. method:: _post_clean(self)
101+
102+
Loads a translation appropriate to the form mode. It is the very same that
103+
will be loaded by :meth:`save`. Doing it twice is needed because:
104+
105+
* it must be done in ``_post_clean`` so that the correct translation is
106+
available for modifications. For instance, if the view updates some
107+
translated fields in between the call to ``is_valid()`` and ``save()``,
108+
or if a form defines a custom ``save()``.
109+
* it must also be done in ``save`` to ensure the language is correctly
110+
enforced when in **enforce** mode.
111+
112+
This double check has no cost: unless the instance is changed by the view,
113+
the ``save()`` check will see the translation is correct and do nothing.
41114

42115
.. method:: save(self, commit=True)
43-
116+
44117
Saves both the :term:`Shared Model` and :term:`Translations Model` and
45-
returns a combined model. The :term:`Translations Model` is either
46-
altered if it already exists on the :term:`Shared Model` for the current
47-
language (which is fetched from the ``language_code`` field on the form
48-
or the current active language) or newly created.
49-
50-
.. note:: Other than in a normal :class:`django.forms.ModelForm`, this
51-
method creates two queries instead of one.
118+
returns a combined model.
52119

53-
.. method:: _post_clean(self)
120+
The target language is determined as follows:
121+
122+
* If a language is defined in ``cleaned_data``, that language is used.
123+
* Else, if the instance has a translation loaded, its language is used.
124+
* Else, the current language is used.
125+
126+
Once the language is determined, the following happen:
127+
128+
* If the object does not exist, it is created.
129+
* If the object exists but not in the target language, its shared fields
130+
are updated and a new translation is created.
131+
* If the object exists in the target language, it is updated.
132+
133+
.. note:: The **enforce** mode has no direct impact on this method. Rather,
134+
it affects the behavior of :meth:`clean`, which places relevant
135+
language (or lack thereof) in ``cleaned_data``.
136+
137+
.. method:: _get_translation(self, instance, language, enforce)
138+
139+
The internal method :meth:`_post_clean` and :meth:`save` delegate
140+
translation checks to. Note that ``enforce`` here refers to the presence
141+
of a ``language_code`` in ``cleaned_data`` and not whether the form is
142+
in **enforce** mode or not.
143+
144+
This method must gurantee that calling it on its result is a no-op.
145+
Namely, in this example the second call must do nothing and return
146+
the translation as is::
147+
148+
translation = self._get_translation(instance, language, enforce)
149+
instance = combine(translation, instance.__class__)
150+
translation = self._get_translation(instance, language, enforce)
151+
152+
153+
.. function:: translatable_modelform_factory(language, model, form=TranslatableModelForm, **kwargs)
154+
155+
Attaches a language and a model class to the specified form and returns the
156+
resulting class. Additional arguments are any arguments accepted by Django's
157+
:func:`~django.forms.models.modelform_factory`, including ``fields`` and
158+
``exclude``.
159+
160+
Having a language attached, the returned form is in **enforce** mode.
161+
162+
.. function:: translatable_modelformset_factory(language, model, form=TranslatableModelForm, **kwargs)
163+
164+
Creates a formset class, allowing edition a collection of instances of ``model``,
165+
all of them in the specified ``language``. Additional arguments are any
166+
argument accepted by Django's :func:`~django.forms.models.modelformset_factory`.
167+
168+
Having a language attached, the returned formset is in **enforce** mode.
169+
170+
.. function:: translatable_inlineformset_factory(language, parent_model, model, form=TranslatableModelForm, **kwargs)
171+
172+
Creates an inline formset, allowing edition of a collection of instances of
173+
``model`` attached to an instance of ``parent_model``, all of those objects
174+
being in the specified ``language``. Additional arguments are any argument
175+
accepted by Django's :func:`~django.forms.models.inlineformset_factory`.
176+
177+
Having a language attached, the returned formset is in **enforce** mode.
54178

55-
Ensures the correct translation is loaded into **self.instance**.
56-
It tries to load the language specified in the form's **language_code**
57-
field from the database, and calls
58-
:meth:`~hvad.models.TranslatableModel.translate` if it does not exist yet.
59179

60180
**********************
61181
BaseTranslationFormSet

docs/public/forms.rst

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,11 @@ TranslatableModelForm
3131
*********************
3232

3333
TranslatableModelForms work like :class:`~django.forms.ModelForm`, but can
34-
display and edit translatable fields as well. There use is very similar,
34+
display and edit translatable fields as well. Their use is very similar,
3535
except the form must subclass :class:`~hvad.forms.TranslatableModelForm` instead of
3636
:class:`~django.forms.ModelForm`::
3737

3838
class ArticleForm(TranslatableModelForm):
39-
# language = 'en' # See below
4039
class Meta:
4140
model = Article
4241
fields = ['pub_date', 'headline', 'content', 'reporter']
@@ -46,23 +45,26 @@ There is none but for the parent class. This ``ArticleForm`` will allow editing
4645
of one ``Article`` in one language, correctly introspecting the model to know
4746
which fields are translatable.
4847

49-
The language the form uses is computed this way:
48+
The form can work in either normal mode, or **enforce** mode. This affects the
49+
way the form chooses a language for displaying and committing.
5050

51-
- if the form is given a model instance, it will use the language that instance
52-
was loaded with.
53-
- if this fails, it will look for a ``language`` attribute set on the form.
54-
- if this fails, it will use the current language, as returned by
55-
:func:`~django.utils.translation.get_language`. If a request is being
56-
processed, that will be the language of the request.
51+
* A form is in normal mode if it has no language set. This is the default. In
52+
this mode, it will use the language of the ``instance`` it is given, defaulting
53+
to current language if not ``instance`` is specified.
54+
* A form is in **enforce** mode if is has a language set. This is usually achieved
55+
by calling :ref:`translatable_modelform_factory <translatablemodelformfactory>`.
56+
When in **enforce** mode, the form will always use its language, disregarding
57+
current language and reloading the ``instance`` it is given if it has another
58+
language loaded.
59+
* The language can be overriden manually by providing a
60+
:meth:`custom clean() method <django.forms.Form.clean>`.
5761

58-
In all cases, any ``language_code`` field sent with form data will be ignored.
59-
It is the reponsibility of calling code to ensure the data matches the language
60-
of the form.
62+
In all cases, the language is not part of the form seen by the browser or sent
63+
in the POST request. If you need to change the language based on some user
64+
input, you must override the ``clean()`` method with your own logic, and set
65+
:attr:`~django.forms.Form.cleaned_data` ``['language_code']`` with it.
6166

62-
All features of Django's form work as usual. Just be careful while overriding
63-
the :meth:`~hvad.forms.TranslatableModelForm.save` or
64-
:meth:`~hvad.forms.TranslatableModelForm._post_clean` methods, as they are
65-
crucial parts for the form to work.
67+
All features of Django forms work as usual.
6668

6769
.. _translatablemodelformfactory:
6870

@@ -78,6 +80,11 @@ eases the generation of uncustomized forms by providing a factory::
7880
The translation-aware version works exactly the same way as the original one,
7981
except it takes the language the form should use as an additional argument.
8082

83+
The returned form class is in **enforce** mode.
84+
85+
.. note:: If using the ``form=`` parameter, the given form class must inherit
86+
:ref:`TranslatableModelForm <translatablemodelform>`.
87+
8188
.. _translatablemodelformset:
8289

8390
*************************
@@ -89,20 +96,28 @@ provides a factory to create formsets of translatable models::
8996

9097
AuthorFormSet = translatable_modelformset_factory('en', Author)
9198

92-
It is also possible to override the queryset, the same way you would do it for
93-
a regular formset. In fact, it is recommended, as the default will not prefetch
94-
translations::
99+
This formset allows edition a collection of ``Author`` instances, all of them
100+
being in English.
101+
102+
All arguments supported by Django's :func:`~django.forms.models.modelformset_factory`
103+
can be used.
104+
105+
For instance, it is possible to override the queryset, the same way it is done for
106+
a regular formset. In fact, it is recommended for performance, as the default
107+
queryset will not prefetch translations::
95108

96109
BookForm = translatable_modelformset_factory(
97110
'en', Book, fields=('author', 'title'),
98-
queryset=Book.objects.language().filter(name__startswith='O'),
111+
queryset=Book.objects.language('en').all(),
99112
)
100113

101-
Using :meth:`~hvad.manager.TranslationManager.language` ensures translations will
102-
be loaded at once, and allows filtering on translated fields.
114+
Here, using :meth:`~hvad.manager.TranslationManager.language` ensures translations
115+
will be loaded at once, and allows filtering on translated fields is needed.
116+
117+
The returned formset class is in **enforce** mode.
103118

104119
.. note:: To override the form by passing a ``form=`` argument to the factory,
105-
the custom form must inherit :class:`~hvad.forms.TranslatableModelForm`.
120+
the custom form must inherit :ref:`TranslatableModelForm <translatablemodelform>`.
106121

107122
.. _translatableinlineformset:
108123

@@ -115,8 +130,18 @@ provides a factory to create inline formsets of translatable models::
115130

116131
BookFormSet = translatable_inlineformset_factory('en', Author, Book)
117132

133+
This creates an inline formset, allowing edition of a collection of instances of
134+
``Book`` attached to a single instance of ``Author``, all of those objects
135+
being editted in English. It does not allow editting other languages; for this,
136+
please see :ref:`translationformset_factory <translationformset>`.
137+
138+
Any argument accepted by Django's :func:`~django.forms.models.inlineformset_factory`
139+
can be used with ``translatable_inlineformset_factory`` as well.
140+
141+
The returned formset class is in **enforce** mode.
142+
118143
.. note:: To override the form by passing a ``form=`` argument to the factory,
119-
the custom form must inherit :class:`~hvad.forms.TranslatableModelForm`.
144+
the custom form must inherit :ref:`TranslatableModelForm <translatablemodelform>`.
120145

121146
.. _translationformset:
122147

docs/public/release_notes.rst

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ Python and Django versions supported:
2020

2121
New features:
2222

23+
- :ref:`TranslatableModelForm <translatablemodelform>` has been refactored to make
24+
its behavior more consistent. As a result, it exposes two distinct language
25+
selection modes, *normal* and *enforce*, and has a clear API for manually
26+
overriding the language — :issue:`221`.
27+
- The new features of :func:`~django.forms.models.modelform_factory` introduced by
28+
Django 1.6 and 1.7 are now available on
29+
:ref:`translatable_modelform_factory <translatablemodelformfactory>` as
30+
well — :issue:`221`.
2331
- :ref:`TranslationQueryset <TranslationQueryset-public>` now has a
2432
:ref:`fallbacks() <fallbacks-public>` method when running on
2533
Django 1.6 or newer, allowing the queryset to use fallback languages while
@@ -29,6 +37,18 @@ New features:
2937
- It is now possible to use :ref:`TranslationQueryset <TranslationQueryset-public>`
3038
as default queryset for translatable models. — :issue:`207`.
3139

40+
Compatibility warnings:
41+
42+
- :ref:`TranslatableModelForm <translatablemodelform>` has been refactored to make
43+
its behavior more consistent. The core API has not changed, but edge cases are
44+
now clearly specified and some inconsistencies have disappeared, which could
45+
create issues, especially:
46+
47+
- Direct use of the form class, without passing through the
48+
:ref:`factory method <translatablemodelformfactory>`. This used to have an
49+
unspecified behavior regarding language selection. Behavior is now
50+
well-defined. Please ensure it works the way you expect it to.
51+
3252
Fixes:
3353

3454
- :ref:`TranslatableModelForm <translatablemodelform>`'s

0 commit comments

Comments
 (0)