Nov. 4, 2024

Manually setting a field when saving a ModelForm

Manually setting a field when saving a ModelForm

Sometimes when using ModelForms, you want to set some fields manually on the instance. Consider for example the case where you have a blog app with an Article model, and you want to automatically set the current request's user as the article's author.

The easy case

If an article has a single author, then doing this is pretty straightforwad. Let's say we have this model/modelform setup:

class Article(models.Model):
    ...  # Fields like title, body, ...
    author = models.ForeignKey("auth.User", ...)

class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = [...]

In that case, we can make the ArticleForm accept a new request argument, and override the save() method to set the author manually just before actually saving the object:

class ArticleForm(forms.ModelForm):
    def __init__(self, *args, request, **kwargs):
        self.request = request
        super().__init__(*args, **kwargs)

    def save(self, *args, **kwargs):
        self.instance.author = request.user
        return super().save(*args, **kwargs)

This works well, but there's actually a shorter way to write this that takes advantage of the fact that Django automatically creates an instance attribute on the form as part of the init, and will use that instance when saving too (after applying the user-submitted data):

class ArticleForm(forms.ModelForm):
    def __init__(self, *args, request, **kwargs):
        super().__init__(*args, **kwargs)
        self.instance.author = request.user

That's it. A single method override and now the author will automatically be set to the request's user. Note that this will happen both when creating a new article, but also when updating one. If that's not desired then you should either add some logic to handle that, or use a separate form for creation and update.

The more complicated case

But what if we wanted to support multiple authors per article? The model is easy to write:

class Article(models.Model):
    ...
    authors = models.ManyToManyField("auth.User", ...)

But how would the form look like? A naive approach that copies the previous example could look like this:

class ArticleForm(forms.ModelForm):
    def __init__(self, *args, request, **kwargs):
        super().__init__(*args, **kwargs)
        self.instance.authors.add(request.user)

Unfortunately, this code has some severe issues, including the fact that it raises an exception when trying to use this form for creating a new article:

Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File ".../django/db/models/fields/related_descriptors.py", line 656, in __get__
    return self.related_manager_cls(instance)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../django/db/models/fields/related_descriptors.py", line 1076, in __init__
    raise ValueError(
ValueError: "<Article: Article object (None)>" needs to have a value for field "id" before this many-to-many relationship can be used.

Moving the logic to save() helps:

class ArticleForm(forms.ModelForm):
    def __init__(self, *args, request, **kwargs):
        self.request = request
        super().__init__(*args, **kwargs)

    def save(self, *args, **kwargs):
        instance = super().save(*args, **kwargs)
        instance.authors.add(self.request.user)
        return instance

This version works quite well, and depending on the project it's possible we could stop here and call it a day. But it turns out there's still an issue, and it has to do with save()'s arguments (which I've conveniently been hiding behind this *args, **kwargs all this time): commit [see documentation].

Supporting save(commit=False)

I'm not sure how widely used (or even known) it is, but ModelForm.save() can take in commit=True/False and that will change the behavior of the method significantly. When using form.save(commit=True) (the default), the form will be saved to the database and the new/updated instance will be returned. On the other hand, using save(commit=False) will only return the instance with all its fields updated with the user-submitted data, but nothing saved in the database.

Our custom save() method above breaks that commit=False feature since the m2m is saved in the database regardless of whether the instance itself was. When using commit=False, Django will add a callable attribute on the form called save_m2m, which you're supposed to call yourself after you've manually saved your model to the database. I find that a bit clunky, but that's the documented behavior, so we can hook into it by creating a new save_m2m callable that will both call the old one and also save our authors field:

class ArticleForm(forms.ModelForm):
    def __init__(self, *args, request, **kwargs):
        self.request = request
        super().__init__(*args, **kwargs)

    def save(self, *args, **kwargs):
        instance = super().save(*args, **kwargs)

        if (old_save_m2m := getattr(self, "save_m2m", None)) is not None:
            def new_save_m2m():
                old_save_m2m()
                instance.authors.add(self.request.user)
            self.save_m2m = new_save_m2m
        else:
            instance.authors.add(self.request.user)

        return instance

And if you don't mind using an undocumented API, there's even a shorter way to write this:

class ArticleForm(forms.ModelForm):
    def __init__(self, *args, request, **kwargs):
        self.request = request
        super().__init__(*args, **kwargs)

    def _save_m2m(self):
        super()._save_m2m()
        self.instance.authors.add(self.request.user)

(Not that I'd encourage using undocumented APIs 😇)