inlineformset creates a new parent_model instead of updating model

I have a form for a Challenge model + inline formsets for each hint. Something like this:

# forms.py
class CreateHintInlineFormSet(BaseInlineFormSet):
    def clean(self, *args, **kwargs):
        super(CreateHintInlineFormSet, self).clean(*args, **kwargs)
        total_penalization = 0
        for form in self.forms:
            if form.cleaned_data.get("DELETE", False):
                continue
            if (
                form.cleaned_data.get("text", None) is None
                or form.cleaned_data.get("penalization", None) is None
            ):
                raise ValidationError(
                    _("You must specify a text and a penalization for each hint")
                )
            else:
                total_penalization += int(form.cleaned_data["penalization"])

        if total_penalization > self.instance.points:
            raise ValidationError(
                _(
                    "The total penalization of the hints cannot be greater than the challenge points"
                )
            )


class CreateHintForm(ModelForm):
    class Meta:
        model = Hint
        fields = ("text", "penalization")
        widgets = {
            "text": TextInput(attrs={"rows": 1}),
        }


HintFormSet = inlineformset_factory(
    model=Hint,
    parent_model=Challenge,
    formset=CreateHintInlineFormSet,
    form=CreateHintForm,
    extra=0,
    can_delete=True,
)

Then my view is as follows:

class CreateEditChallengeView(
    LoginRequiredMixin,
    SingleObjectTemplateResponseMixin,
    ModelFormMixin,
    ProcessFormView,
    AdminInstructorRoleRequired,
):
    model = Challenge
    template_name = "challenges/create-edit-challenge.html"
    form_class = CreateChallengeForm
    success_url = reverse_lazy("challenges-list")

    def get_context_data(self, **kwargs):
        context = super(CreateEditChallengeView, self).get_context_data(**kwargs)
        context["title"] = (
            _("New challenge") if self.object is None else _("Edit challenge")
        )
        context["scenarios_table"] = ScenarioInfoTable(self.get_available_scenarios())
        context["mode"] = "edit" if self.object else "create"
        context["formset"] = HintFormSet(
            self.request.POST or None, instance=self.object or None
        )
        return context

    def get_available_scenarios(self):
        return Scenario.objects.for_organization(self.request.user)

    def get_object(self, queryset=None):
        try:
            if "challenge_id" in self.kwargs:
                pk = object_pretty_id_to_db_identifier(self.kwargs.get("challenge_id"))
                return Challenge.objects.get(pk=pk)
            return super(CreateEditChallengeView, self).get_object(queryset)
        except AttributeError:
            return None

    def get(self, request, *args, **kwargs):
        self.object = self.get_object()  # noqa
        return super(CreateEditChallengeView, self).get(request, *args, **kwargs)

    def form_invalid(self, form, formset=None):
        context = self.get_context_data()
        context["form"] = form
        if formset is not None:
            context["formset"] = formset
        return self.render_to_response(context)

    def form_valid(self, form):
        context = self.get_context_data()
        formset = context["formset"]

        formset.instance = form.save(commit=False)

        if not formset.is_valid():
            return self.form_invalid(form, formset=formset)

        self.object = form.save()  # noqa
        formset.save()
        return HttpResponseRedirect(self.get_success_url())

    def post(self, request, *args, **kwargs):
        self.object = self.get_object()  # noqa
        return super(CreateEditChallengeView, self).post(request, *args, **kwargs)

Challenge model and its corresponding Hints are created just fine, but when trying to update any of the Hint objects, it creates a new Challenge object with no Hints associated.

I tried to print if the inline formset is recieving the correct values and indeed it is, but it does not update the Hint model.

{'text': 'Hi', 'penalization': 7, 'challenge': <Challenge: First challenge>, 'id': <Hint: Hi>, 'DELETE': False}
{'text': 'Bye', 'penalization': 7, 'challenge': <Challenge: Second challenge>, 'id': <Hint: Bye>, 'DELETE': False}

Here I’m trying to update each Hint penalization from 1 (value I set when I created them) to 7. Formset gets the values, but won’t update the objects.

What could be the problem?

I think the first issue you may want to address is the attempt to mix a CreateView with an UpdateView.

You’ve written a lot of code to handle the differences between the two, when the solution (two separate views) is so much easier to work with.

While I can’t prove it definitively, I believe this may be one of the issues here. My first suggestion would be to refactor this back out into separate views.

Side note: Since you are using an inlineformset, you may also find it easier to make that formset an additional field within the model form, and adding the form-related processing of the formset in the form instead of in the class.

How would I do this? Any hint? That formset is dynamic, I can add/remove forms from it using JS.

My apologies, I was thinking of some custom code that we had written to inject inlineformsets into a form. We had created a custom subclass of ModelForm with a bit of added functionality. Overall, adding that code to what you have/are looking to do is not going to simplify it any. Please ignore that side note.

I tried making the formset an additional field within the model form, but I can’t get it to work.

# forms.py
class CreateChallengeForm(ModelForm):
    # @TODO: Hints!

    # Regex used to validate the maximum time of a challenge (HH:MM format)
    C_MAX_TIME_REGEX = "^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$"

    c_scenario = CharField(initial="", widget=HiddenInput(), required=False)

    # We assume that this field is always filled, since we populate it
    # manually in the template.
    c_topology_data = CharField(initial="", widget=HiddenInput(), required=False)

    # Custom field to handle Challenge duration, since 'max_time' field in
    # Challenge model is a PositiveIntegerField (seconds) and we want to
    # show it as HH:MM in the form.
    c_max_time = CharField(
        label=_("Challenge maximum time"),
        help_text=_("Challenge maximum time help text"),
        initial="",
        widget=TextInput(),
    )

    def clean(self):
        self.hints_formset.clean()
        super(CreateChallengeForm, self).clean()
        return self.cleaned_data

    def is_valid(self):
        is_valid = True
        is_valid &= self.hints_formset.is_valid()
        is_valid &= super(CreateChallengeForm, self).is_valid()
        return is_valid

    def has_changed(self):
        has_changed = False
        has_changed &= self.hints_formset.has_changed()
        has_changed &= super(CreateChallengeForm, self).has_changed()
        return has_changed

    def clean_c_max_time(self: "CreateChallengeForm", *args, **kwargs) -> int:
        c_max_time = self.cleaned_data.get("c_max_time")
        if c_max_time == "":
            self.add_error(
                "c_max_time",
                _("You must specify how much time the challenge will last"),
            )
        if not type(c_max_time) == str or not re.match(
            self.C_MAX_TIME_REGEX, c_max_time
        ):
            self.add_error(
                "c_max_time",
                _("The time that the challenge will last must be in HH:MM format"),
            )
        return c_max_time

    def clean_c_scenario(self: "CreateChallengeForm", *args, **kwargs) -> int:
        cscenario_pk = self.cleaned_data.get("c_scenario")
        if cscenario_pk == "":
            self.add_error("c_scenario", _("You must select a scenario"))
        return cscenario_pk

    def save(self: "CreateChallengeForm", **kwargs) -> Challenge:
        """
        Override the save method to populate challenge fields that are
        not present in the form since they need some manipulation before.
        """
        with transaction.atomic():
            # If any operations fail, we rollback.
            challenge = super(CreateChallengeForm, self).save(**kwargs)

            # Add the scenario to the challenge
            cscenario_pk = int(self.cleaned_data["c_scenario"])
            cscenario = Scenario.objects.get(pk=cscenario_pk)
            challenge.scenario = cscenario

            # Parse the topology data to add the hidden and canConnect fields
            ctopology_data = json.loads(self.cleaned_data["c_topology_data"])
            cscenario_nodes = json.loads(cscenario.topology_nodes)
            cscenario_edges = json.loads(cscenario.topology_edges)

            for node_dict in cscenario_nodes:
                for node_id, node_data in ctopology_data.items():
                    if node_dict["id"] == node_id:
                        node_dict["_hidden"] = (
                            "true" if node_data["visible"] == "no" else "false"
                        )
                        node_dict["_canConnect"] = (
                            "true" if node_data["access"] == "yes" else "false"
                        )

            challenge.topology_data = json.dumps(
                {"nodes": json.dumps(cscenario_nodes), "edges": json.dumps(cscenario_edges)}
            )

            # Add the difficulty to the challenge
            c_points = self.cleaned_data["points"]
            challenge.difficulty, _ = Challenge.get_difficulty_based_on_points(c_points)

            # Add the duration to the challenge
            hours, mins = int(self.cleaned_data["c_max_time"].split(":")[0]), int(
                self.cleaned_data["c_max_time"].split(":")[1]
            )
            challenge.max_time = hours * 3600 + mins * 60
            challenge.save()

            self.hints_formset.instance = challenge
            self.hints_formset.save()
            return challenge

    def __init__(self: "CreateChallengeForm", *args: Any, **kwargs: Any) -> None:
        super(CreateChallengeForm, self).__init__(*args, **kwargs)

        self.hints_formset = inlineformset_factory(Challenge, Hint, fields=("text", "penalization"), extra=1, can_delete=True)()

        # Exclude the Misc competence from the queryset
        self.fields["competence"].queryset = Competence.objects.all().exclude(
            name=COMPETENCE_MISC_NAME
        )
        # Make the difficulty field optional, since we only use it for the
        # label in the frontend.
        self.fields["difficulty"].required = False

        instance = kwargs.get("instance")
        if instance:
            # Populate the form with the data from the instance
            self.fields["c_scenario"].initial = instance.scenario.pk
            self.fields["c_topology_data"].initial = instance.topology_data
            self.fields["c_max_time"].initial = (
                str(instance.max_time // 3600).zfill(2)
                + ":"
                + str((instance.max_time % 3600) // 60).zfill(2)
            )

    class Meta:
        model = Challenge
        fields = (
            "name",
            "competence",
            "difficulty",
            "points",
            "description",
            "flag",
            "solution",
            "max_tries",
        )
        exclude = (
            "scenario",
            "topology_data",
            "max_time",
        )
        widgets = {
            "description": CKEditorWidget(),
            "solution": CKEditorWidget(),
            "points": Select(
                choices=[("", "---------")]
                + [(points, points) for points in range(10, 110, 10)],
                attrs={"class": "form-control"},
            ),
        }

And this is my views.py

class CreateChallengeView(
    LoginRequiredMixin,
    AdminInstructorRoleRequired,
    CreateView
):
    template_name = "challenges/create-edit-challenge.html"
    form_class = CreateChallengeForm
    model = Challenge

    def get_context_data(self, **kwargs):
        context = super(CreateChallengeView, self).get_context_data(**kwargs)
        context["title"] = _("Create challenge")
        context["scenarios_table"] = ScenarioInfoTable(self.get_available_scenarios())
        context["mode"] = "create"
        return context

    def get_available_scenarios(self):
        """Return scenarios for the user current organization."""
        return Scenario.objects.for_organization(self.request.user)

    def get_success_url(self):
        return reverse_lazy("challenges-list")

Before creating a Challenge and its Hints, I need to ensure that the sum of hint penalizations in not bigger than field ‘points’ in Challenge, but I do not know how to get this working.

Again, my apologies - I was getting mixed up with some code that I had written for a project. (See my previous response)

The formset wouldn’t be another field in the form, just an attribute of the form class. That means you would still want to initialize it - perhaps in the __init__ method of the form. But it’s going to behave like any “typical” data element of a class, and not like a form field.

Couple things here. First, you probably want to call super to clean this form before calling clean on the formset. Then, after both forms have been processed, you can get the data you need from each to perform your comparison. In other words, your logic would go after cleaning the individual form and formset and before returning the cleaned data. See the examples at https://docs.djangoproject.com/en/4.1/ref/forms/validation/#cleaning-and-validating-fields-that-depend-on-each-other for some ideas.

I did, but I’m unable to get any data from formsets.

Since the formset in this case is just another attribute of the form class, you will need to bind the data from the POST to the formset. I’d probably suggest doing this in a custom get_form method that calls super to initialize the form itself, and then create the instance of the formset.

(Also, since you’re combining two forms in the same submit, you’ll want to use the prefix attribute in one or the other.)

Okay, now I’m able to create to create Challenges with their Hints from my CreateView, following ur advice.

    def get_form(self, form_class=None):
        form = super(CreateChallengeView, self).get_form(
            form_class
        )
        formset = inlineformset_factory(
            Challenge, Hint, fields=("text", "penalization"), extra=1, can_delete=True
        )
        form.hints_formset = formset(self.request.POST, prefix="hints_formset")
        return form

Now I’m struggling to implement an update view.

class EditChallengeView(LoginRequiredMixin, AdminInstructorRoleRequired, UpdateView):
    template_name = "challenges/create-edit-challenge.html"
    form_class = CreateChallengeForm
    model = Challenge

    def get_context_data(self, **kwargs):
        context = super(EditChallengeView, self).get_context_data(**kwargs)
        context["title"] = _("Edit challenge")
        context["scenarios_table"] = ScenarioInfoTable(self.get_available_scenarios())
        context["mode"] = "edit"
        return context

    def get_available_scenarios(self):
        """Return the scenarios available for the user organization."""
        return Scenario.objects.for_organization(self.request.user)

    def get_object(self, queryset=None):
        try:
            if "challenge_id" in self.kwargs:
                pk = object_pretty_id_to_db_identifier(self.kwargs["challenge_id"])
                return Challenge.objects.get(pk=pk)
            return super(EditChallengeView, self).get_object(queryset)
        except AttributeError:
            return None

    def get_form(self, form_class=None):
        form = super(EditChallengeView, self).get_form(form_class)
        formset = inlineformset_factory(
            Challenge, Hint, fields=("text", "penalization")
        )
        form.hints = formset(instance=self.object, prefix="hints_formset")
        return form

What specifically isn’t working for you here?

I’m getting a “the inline value did not match the parent instance” when I try to submit the form to update my object.

You’re not binding the post data when you’re building the formset in get_form.

Beyond that, I don’t see anything obviously wrong. You will probably want to check all the fundamentals.
Walk it through starting from the very beginning - what URL are you posting to? Check the data that you’re posting to ensure that you’re sending everything back that you need to.
Maybe drop some print statements in get_form to see what ends up happening there.

I did, but still getting the same error :confused:

def get_form(self, form_class=None):
    form = super(EditChallengeView, self).get_form(form_class)
    formset = inlineformset_factory(
        Challenge, Hint, fields=("text", "penalization")
    )
    if self.request.method == "POST":
        form.hints_formset = formset(
            self.request.POST, instance=self.object, prefix="hints_formset"
        )
    else:
        form.hints_formset = formset(instance=self.object, prefix="hints_formset")
    return form

Formsets are populated when I access update view, but on form submission I’m getting the error “he inline value did not match the parent instance”.

Side note: There’s no need to have the if in get_form. If the request is a get, then request.POST will be None.

This comes back to my previous reply. I don’t see anything specifically wrong, which could mean that the error is here, in your template or JavaScript, or somewhere else.
The only suggestion I can make is to debug the process that this is going through. Visually verify everything along the way, starting from the html that was rendered and sent to the browser.

This is my form init method where I’m able to see formset data if I print ‘kwargs’ when I make a POST request to update my object.

def __init__(self: "CreateChallengeForm", *args: Any, **kwargs: Any) -> None:
    super(CreateChallengeForm, self).__init__(*args, **kwargs)

    # Exclude the Misc competence from the queryset
    self.fields["competence"].queryset = Competence.objects.all().exclude(
        name=COMPETENCE_MISC_NAME
    )
    # Make the difficulty field optional, since we only use it for the
    # label in the frontend.
    self.fields["difficulty"].required = False

    # Embed the hints formset in the challenge form.
    formset = inlineformset_factory(
        Challenge, Hint, fields=("text", "penalization"), extra=1,
        can_delete=True
    )

    self.hints_formset = formset(*args, **kwargs)

    instance = kwargs.get("instance")
    if instance:
        # Populate the form with the data from the instance
        self.fields["c_scenario"].initial = instance.scenario.pk
        self.fields["c_topology_data"].initial = instance.topology_data
        self.fields["c_max_time"].initial = (
            str(instance.max_time // 3600).zfill(2)
            + ":"
            + str((instance.max_time % 3600) // 60).zfill(2)
        )

But I’m still getting the same error, I don’t know if it is related to this piece of code.

Turns out I was making the POST request to the wrong URL, now I got it working.

Thank you for your time and kindly help :slight_smile: