formset from a list of form?

Hello,

I have a model with the following fields:

  • value_string
  • value_number
  • value_boolean

I have custom code that creates a ModelForm and removes the fields that are not needed.
This works using CBV.

Now I want to build a list of those Forms and put them in a FormSet.
Using the formset_factory, I will not have the control to remove the fields depending on my needs.
I need a FormSet with, for example:

  • first form shows only the value_string
  • next form shows only the value_number
  • third form shows only the value_string

The base model and form are the same.
Is there a way to implement such a formset using already provided Django methods?
Like, passing a custom list of forms to formset_factory?

Thanks

Context: the goal is to have a dynamic app where I can create multiple fields and assign a value type the user is supposed to fill. Maybe you know another way to do it than using a model with all the fields types and only showing the one corresponding to the expect value type.

Welcome @dupontbenoit !

You have a couple different options here.

What might be the easiest way is to use the add_fields method to control the fields being generated for each form.

If you need even more control, you could create your own form class that dynamically identifies which fields to create in the __init__ method for the form. In this case, you would probably want to use the get_form_kwargs method for your formset to ensure that any necessary details are passed through to the form constructor.

You could even go so far as to create your own BaseFormSet class to override the forms method, giving you that ability to define a complete custom list of forms.

(I suggest you read the source to understand how formsets create forms.)

Thanks for your reply.

add_fields will add the same field to the whole formset. It’s not what I want.

Right now I have the following code. It looks like your second solution. But I don’t know how to make it a formset. To me a formset will always have the same fields when in my case I want a formset presenting different fields.

class ObservationForm(ModelForm):
    def __init__(self, *args, **kwargs):
        definition: ObservationDefinition = kwargs.get("definition", False)
        if definition:
            del kwargs["definition"]
        super(ObservationForm, self).__init__(*args, **kwargs)
        if definition:
            self.cleanup_fields(definition.permitted_data_type)
            self.fields[
                self.type_to_field(definition.permitted_data_type)
            ].label = definition.name

    def cleanup_fields(self, data_type_to_show: str):
        """
        Remove the not needed fields depending on the type of data. The user only needs to fill in the type of data from ObservationDefinition.permitted_data_type.
        """
        for t in PermittedDataType:
            if t.value != data_type_to_show:
                del self.fields[self.type_to_field(t.value)]

    def type_to_field(self, data_type: PermittedDataType):
        """
        Map a data type from  the ObservationDefinition to it's corresponding field in the Observation object
        """
        if data_type == PermittedDataType.NUMBER.value:
            return "value_number"
        elif data_type == PermittedDataType.STRING.value:
            return "value_string"
        elif data_type == PermittedDataType.BOOLEAN.value:
            return "value_boolean"

    class Meta:
        model = Observation
        fields = (
            "value_string",
            "value_boolean",
            "value_number",
            "defined_by",
            "from_profile",
            "subject",
            "encounter",
        )
        widgets = {
            "defined_by": HiddenInput(),
            "from_profile": HiddenInput(),
            "subject": HiddenInput(),
            "encounter": HiddenInput(),
        }

I guess the last option, using BaseFormSet is the only approach, then?

It does not need to.

The add_fields function receives the index of the form being generated as a parameter. You can use that to help determine what kind of field is to be added for each form.

Also, you didn’t mention that you’re actually working with a modelformset here.

That does change this quite a bit, as the ModelFormMetaclass is responsible for more than just creating the form fields. It might be more appropriate to call modelform_factory to create each form class rather than modifying the ModelForm class definition in the initializer method.

(You did mention that you are using model forms, but you’re also making reference to formset_factory. Are you actually using modelformset_factory?)

Currently i’m not using any factory. I’m trying to find out how to use them for my use case.

This is my current view code.
I was trying to replicate the build of forms with “dynamic” fields to display in the template.

class ObservationCreate(TemplateView):
    template_name = "observations/observation_form.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        form_list = []
        profile_id = 1
        element = ObservationProfile.objects.get(pk=profile_id).element

        for el in element.all():
            definition = el.definition
            initial = {
                "defined_by": definition.id,
                "from_profile": profile_id,
                "subject": 1,
                "encounter": 1,
            }

            form_list.append(ObservationForm(initial=initial, definition=definition))

        context["form_list"] = form_list
        return context

I tried to play with modelformset_factory, BaseModelFormSet and add_fields.
That could be a good solution but I have to pass a list of other objects at some point so the add_fields have some logic to add the right type of field.

I tried to add __init__ to the BaseModelFormSet but it’s not the right way to do it as I’m not the one that instantiate it, but the formset_factory does.

Is there a way to loop through all the forms in the view, so I can update the properties after the modelformset_factory has been called?

I tried to iterate over BaseModelFormSet or BaseModelFormSet.forms but it didn’t work.

You would need to iterate over the forms in your formset instance.

e.g. Working from the examples at Model formsets
where you have something like:

>>> from django.forms import modelformset_factory
>>> from myapp.models import Author
>>> AuthorFormSet = modelformset_factory(Author, fields=["name", "title"])
>>> formset = AuthorFormSet()

Then formset.forms is the list of forms in that formset.

However, the problem I see with doing it this way is that this iteration is going to occur on the POST after the data has been bound and validated. This means that you cannot make any of these fields “required”. They must all be optional.
If this is not going to be a problem, then this is likely going to be the easiest way to handle this.

I am looking at another approach, trying a couple things. I’ll report back once I answer some of my own questions about this.

Actually, I think this relates directly to another thread from just a couple days ago - Creating Model Form with python code in form - #8 by KenWhitesell

(Now, while I could hypothesize the possibility of creating a formset consisting of different forms, perhaps using the get_form_kwargs parameter to create a formset in an ABABABAB pattern, I’ve never seen it done and I have no idea what problems would be encountered along the way.)

This fits into the category of different forms in a formset, and clearly there is some “friction” involved here.

Now, in the simplified case, given a trivial model form:

class SomeModelForm(forms.ModelForm):
    class Meta:
        model = SomeModel
        fields = '__all__'
    def __init__(self, *args, **kwargs):
        instance = kwargs.get('instance', None)
        super().__init__(*args, **kwargs)

When this form is instantiated, the variable instance will have the instance of the model associated with it.

If you then modify self.fields after the super() call in __init__, you can make your desired changes.

This means if you then have:

SomeModelFormset = modelformset_factory(SomeModel, form=SomeModelForm, extra=0)
some_model_formset = SomeModelFormset(...)

You will have generated your desired forms.

Edit: I was wrong here.

Validation is not performed until something else happens causing the validation to be performed, such as calling is_valid. It does not happen during __init__.

Thank you so much for trying to help ! :slight_smile:

In the end, the following code is working properly.
The code needs to be cleaned a little bit, but I can display only the fields I need and save them.

class PermittedDataType(Enum):
    STRING = "STR"
    NUMBER = "NB"
    BOOLEAN = "BOOL"


class ObservationDefinition(CanBeDisabledModel):
    DATA_TYPE_CHOICES = [
        (PermittedDataType.STRING.value, "Texte"),
        (PermittedDataType.NUMBER.value, "Nombre"),
    ]

    name = models.CharField(max_length=250, verbose_name=_("nom"))
    permitted_data_type = models.CharField(
        max_length=4,
        choices=DATA_TYPE_CHOICES,
        null=True,
        blank=True,
    )


class ObservationProfile(models.Model):
    title = models.CharField(max_length=250)


class ObservationProfileElement(models.Model):
    profile = models.ForeignKey(ObservationProfile, on_delete=models.PROTECT, related_name="element")
    definition = models.ForeignKey(ObservationDefinition, on_delete=models.PROTECT)
    sort_order = models.IntegerField(default=1)


class Observation(models.Model):
    performer = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        blank=False,
        null=False,
    )
    defined_by = models.ForeignKey(
        ObservationDefinition,
        on_delete=models.PROTECT,
        blank=False,
        null=False,
        verbose_name=_("défini par"),
    )
    from_profile = models.ForeignKey(ObservationProfile, on_delete=models.PROTECT, null=True)
    value_string = models.TextField(blank=True, null=True)
    value_boolean = models.BooleanField(blank=True, null=True)
    value_number = models.FloatField(blank=True, null=True)


class ObservationCreateOnPost(FormView):
    model = Observation
    form_class = ObservationForm
    template_name = "observations/observation_form.html"

    def get_context_data(self, *args, **kwargs):
        context = super().get_context_data(*args, **kwargs)
        profile_id = 1
        elements = ObservationProfile.objects.get(pk=profile_id).element.all()

        if not context.get("formset"):
            formset_class = modelformset_factory(
                self.model, form=self.form_class, extra=len(elements)
            )
            formset = formset_class(queryset=Observation.objects.none())
            for element, form in zip(elements, formset):
                initial = {
                    "defined_by": element.definition.id,
                    "from_profile": profile_id,
                    "subject": 1,
                    "encounter": 1,
                }
                form.initial = initial
                self.cleanup_fields(form, element.definition.permitted_data_type)
            context["formset"] = formset
        return context

    def cleanup_fields(self, form, data_type_to_show: str):
        """
        Remove the not needed fields depending on the type of data. The user only needs to fill in the type of data from ObservationDefinition.permitted_data_type.
        """
        for t in PermittedDataType:
            if t.value != data_type_to_show:
                del form.fields[self.type_to_field(t.value)]

    def type_to_field(self, data_type: PermittedDataType):
        """
        Map a data type from  the ObservationDefinition to it's corresponding field in the Observation object
        """
        if data_type == PermittedDataType.NUMBER.value:
            return "value_number"
        elif data_type == PermittedDataType.STRING.value:
            return "value_string"
        elif data_type == PermittedDataType.BOOLEAN.value:
            return "value_boolean"

    def post(self, request, *args, **kwargs):
        print("--- in POST")
        print(request.POST)
        formset = modelformset_factory(self.model, form=self.form_class)(request.POST)
        for form in formset:
            # set the author
            setattr(form.instance, "performer", self.request.user)
        if formset.is_valid():
            return self.form_valid(formset)
        else:
            return self.form_invalid(formset)

    def form_invalid(self, formset):
        return self.render_to_response(self.get_context_data(formset=formset))

    def form_valid(self, formset):
        formset.save()
        return super().form_valid(formset)

    def get_success_url(self):
        return reverse_lazy("observation")