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 😇)