deschler / django-modeltranslation

Translates Django models using a registration approach.
BSD 3-Clause "New" or "Revised" License
1.34k stars 257 forks source link

How to properly dynamically add ModelForm fields? #696

Closed kunambi closed 11 months ago

kunambi commented 11 months ago

I'm attempting to dynamically add translatable fields for my forms.ModelForm, depending on whether the customer has enabled the language or not. However, the translated value isn't saved to the model.

from copy import deepcopy
from django import forms
from modeltranslation.fields import TranslationField

class DefaultModelForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        if user := self.request and self.request.user:
            company = user.company
            app_setting = company.settings
            default_lang = settings.MODELTRANSLATION_DEFAULT_LANGUAGE  # "en"
            default_search = f"_{default_lang}"
            to_add = []
            """Set field_order to the same order as set in Meta.fields, unless already set"""
            if self.field_order is None:
                self.field_order = list(self.Meta.fields)

            for modelfield in self._meta.model._meta.fields:
                """If we found a default translation field, add it to the list"""
                if (
                    (formfield := self.fields.get(modelfield.name, None))
                    and modelfield.name.endswith(default_search)
                    and isinstance(modelfield, TranslationField)
                ):
                    to_add.append(
                        {
                            "position": self.field_order.index(modelfield.name),
                            "formfield": deepcopy(formfield),
                            "name": modelfield.name.removesuffix(default_search),  # "description"
                            "languages": app_setting.get_additional_language_codes,  # ["es"]
                        }
                    )

            for addable in to_add:
                for lang in addable.get("languages"):
                    field_name = f"{addable.get('name')}_{lang}"  # "description_es"
                    formfield = addable.get("formfield")
                    formfield.label = formfield.label.replace(f"[{default_lang}]", f"[{lang}]")
                    formfield.required = False
                    formfield.initial = getattr(self.instance, field_name, "")

                    self.fields[field_name] = formfield
                    self.field_order.insert(addable.get("position", 0) + 1, field_name)

            self.order_fields(self.field_order)

This code allows me to render the fields accordingly. If the customer has selected to show e.g. "es" (Spanish), the translatable field ("description_en") will be copied and I create a new field ("description_es") in the right position inside the field_order. So far, all is well.

But when I POST the form, this is what happens inside my view:

    def form_valid(self, form):
        is_updating = True if form.instance.pk else False
        self.object = form.save()
        if is_updating:
            # Todo: print message
            pass
        breakpoint()
        """
        (Pdb++) form.is_valid()
        True
        (Pdb++) form.cleaned_data
        {'company': <Company: Test>, 'description_en': 'I have a library!', 'description_es': 'Tengo una biblioteca!'}
        (Pdb++) self.object = form.save()
        (Pdb++) self.object
        <Venue: TestVenue>
        (Pdb++) self.object.description_en
        'I have a library!'
        (Pdb++) self.object.description_es
        ''
        """
        return super().form_valid(form)

Here is what I don't understand: why isn't the description_es value saved to the object?

kunambi commented 11 months ago

I tried creating the field through

modelfield = getattr(self._meta.model, f"{addable.get('name')}_{default_lang}")
translated_field = create_translation_field(
    model=self._meta.model,
    field_name=addable.get("name"),
    lang=lang,
    empty_value=None if modelfield.field and modelfield.field.null else "",
)
formfield = translated_field.formfield()

But with the same erroneous result

last-partizan commented 11 months ago

Try looking into form.save to understand why field isnt' saved.

kunambi commented 11 months ago

form.save() is called on the django.forms.models.BaseModelForm, which only calls self.instance.save() (if no errors are found).

No errors are encountered, but the data of the instance isn't updated.

What am I missing?

last-partizan commented 11 months ago

Look at what's happening before that. I think it's BaseModelForm._post_clean method.

Check if all fields are available in cleaned_data.

last-partizan commented 11 months ago

But before we get too far, try using modelform_factory instead of adding fields in __init__. Maybe it will just work.

kunambi commented 11 months ago

Look at what's happening before that. I think it's BaseModelForm._post_clean method. Check if all fields are available in cleaned_data.

As I showed in the first post, the cleaned_data does indeed contain the value for description_es. The problem is that it doesn't seem to be saved.

But before we get too far, try using modelform_factory instead of adding fields in __init__. Maybe it will just work.

modelform_factory is not what I want to use in this scenario. I have other uses of it in other locations. Why do you think it will work?

It seems like there's some kind of magic happening when form.save() is run. django-modeltranslation is supposedly patching how the models are saved, hopefully I'll understand the source code

kunambi commented 11 months ago

I managed to find a solution. The problem was with django.forms.models.BaseModelForm which removes any fields which aren't defined in Meta.fields through the use of object_data = model_to_dict(instance, opts.fields, opts.exclude).

Solution became:

            # ...
            for addable in to_add:
                for lang in addable.get("languages"):
                    field_name = f"{addable.get('name')}_{lang}"  # "description_es"
                    formfield = addable.get("formfield")
                    formfield.label = formfield.label.replace(f"[{default_lang}]", f"[{lang}]")
                    formfield.required = False
                    formfield.initial = getattr(self.instance, field_name, "")

                    self.fields[field_name] = formfield
                    self.field_order.insert(addable.get("position", 0) + 1, field_name)
                    # these lines made the difference
                    if field_name not in self._meta.fields:
                        self._meta.fields = self._meta.fields + (field_name,)

            self.order_fields(self.field_order)