Add a set of 'sub-forms' (probably form set) to a ModelForm

I’m making some progress in solving this. What I want to model is this:

I have a model for materials, a model for projects, and an intermediary model to specify more data to the many-to-many relationship between them. The objective is to allow a project not only to have multiple materials but also to specify a quantity for each.

These are my models:

class Material(models.Model):
    id = models.AutoField(primary_key=True)
    name = models.CharField(max_length=200)
    description = models.CharField(max_length=3000, blank=True)
    unit = models.CharField(max_length=20)
    price = models.DecimalField(max_digits=9, decimal_places=2, validators=[MinValueValidator(0)])
    created_by = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name='materials')
    created_on = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f'{self.name} (${self.price}/{self.unit})'


class Project(models.Model):
    id = models.AutoField(primary_key=True)
    name = models.CharField(max_length=200)
    description = models.CharField(max_length=3000, blank=True)
    materials = models.ManyToManyField(to=Material, through='ProjectMaterialSet', related_name='projects')
    created_by = models.ForeignKey(to=User, on_delete=models.CASCADE)
    created_on = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f'{self.name}'

class ProjectMaterialSet(models.Model):
    id = models.AutoField(primary_key=True)
    project = models.ForeignKey(to=Project, on_delete=models.CASCADE)
    material = models.ForeignKey(to=Material, on_delete=models.CASCADE)
    material_qty = models.PositiveIntegerField(default=1)

Now, I have successfully rendered the project form containing a checkbox list of the materials (without the quantity feature), another form listing just one material as a checkbox, and a field for the quantity (a form of the ProjectMaterialSet model).

enter image description here

enter image description here

Now, the part I’m struggling with is how to combine both. So far, I think what I need to do is have a query set of the available materials in the ProjectForm, to then add to it a series of sub-forms (here is where I think I should use formsets, however, I’m unsure how) that passes each material pk so I can render each form as a ModelMultipleChoiceField so I get to use the checkbox widget for each material.

My natural instinct as someone not related with formsets is to for-loop over the materials and create form instances that save to a dict or something around those lines. However, I read on the Django forum a good phrase about using Django features instead of hacking a solution, and I do feel the formsets can be used for this, I’m just unsure how.

Clarification: I need to pass each material pk to the ProjectMaterialSetForm so I can get the checkbox for each material (as I’m getting a query set with just one result since I’m filtering by pk), and link a quantity input to that single checkbox.

I feel I have most of it done, I’m just not sure how to add the sub-forms, and if someone could also help about the custom save and validation required, that’d be amazing!

Here are my forms:

class ProjectForm(forms.ModelForm):

    class Meta:
        model = Project
        fields = ('name', 'description')

        widgets = {
            'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Enter the project name'}),
            'description': forms.Textarea(attrs={'class': 'form-control', 'placeholder': 'Enter the project description', 'rows': '5'})
        }

    def __init__(self, *args, **kwargs):

        # Store the user object and materials QuerySet
        self.user = kwargs.pop('user') if 'user' in kwargs else None
        self.materials = Material.objects.filter(created_by=self.user) if self.user is not None else None # This is a QuerySet

        # Call parent __init__ method, which we are modifying
        super(ProjectForm, self).__init__(*args, **kwargs)

        # Somehow create a set of forms, one for each material
            # Perhaps using formsets is the ideal solution, however, I keep finding
            # myself going to the route of a dict with forms and adding them as I
            # iterate over the materials query set. 

            # Since formsets are a thing, I'm pretty sure I can somehow create a batch
            # of forms (probably the extra argument is the key here), to then somehow
            # add the material_id to each of them.


class ProjectMaterialSetForm(forms.ModelForm):

    class Meta:
        model = ProjectMaterialSet
        fields = ('material', 'material_qty')

        material = forms.ModelMultipleChoiceField(queryset=None)
        
        widgets = {
            'material': forms.CheckboxSelectMultiple(attrs={'class':'form-check-input'}),
            'material_qty': forms.NumberInput(attrs={'class':'form-control'})
        }

    def __init__(self, *args, **kwargs):
        material_id = kwargs.pop('material_id') if 'material_id' in kwargs else None
        super(ProjectMaterialSetForm, self).__init__(*args, **kwargs)
        self.fields['materials'].queryset = Material.objects.filter(pk=material_id)

Trying to make sure I understand what you’re asking for here:

  • This is a “create” situation, as opposed to an “edit” situation.

    • The two may be slightly different situations, depending upon whether you consider the “create” case to be a special case of the “edit” case where there are 0 elements currently selected.
  • You want a formset consisting of all Material, each with a checkbox to indicate selection (and that it should be added to the Project.materials field), and a quantity box to indicate the value to be added to material_qty.

    • If this is a situation where the “create” is a special case of “edit”, then you still want all Material displayed in a formset, but any existing relationships should include the appropriate value.

Would this be an accurate summary of what you’re trying to achieve?

Hey!

Thanks for taking the time to review the question :smiley:

  • This is a “create” situation, as opposed to an “edit” situation.
    • The two may be slightly different situations, depending upon whether you consider the “create” case to be a special case of the “edit” case where there are 0 elements currently selected.

I’m not sure I understand clearly what you mean by ‘create’ or ‘edit’ situation. If you refer to creating data or editing existing data, I’d agree and say it’s a creating data situation, since I’m creating a project by making use of the existing entries on the Material model.

  • You want a formset consisting of all Material, each with a checkbox to indicate selection (and that it should be added to the Project.materials field), and a quantity box to indicate the value to be added to material_qty.
    • If this is a situation where the “create” is a special case of “edit”, then you still want all Material displayed in a formset, but any existing relationships should include the appropriate value.

Would this be an accurate summary of what you’re trying to achieve?

Yes! That’s an accurate summary. Essentially, the form I want to present to create a project should list the fields to add a project name and description, and after that, a list of all the materials created by the user (that’s why I’m modifying the __init__ method of the ProjectForm so I can query for the proper QuerySet of materials) in the form of a checkbox list to indicate if the material is used in the project alongside an input field to specify how much of that material is used. I have the ProjectMaterialSet model as an intermediary model to store the quantity used of the material and to which project that ‘material-quantity’ pair corresponds.

This is where I feel form sets might be the proper choice to achieve this, as each checkbox-quantity pair can fit in the mental model of an independent form, and then the ‘list’ of materials available would essentially be a list of forms.

I got an answer on stack overflow with an implementation that seems to render what I’m looking for, but I’d personally prefer to actually understand how to get to that implementation rather than just copy-pasting it into my project because it works but not understanding why that’s the way to go, so if you could help me build my own implementation of the solution, that would be amazing:D