Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • form_class, fields and ModelFormMixin
  • form_valid for setting the author automatically
  • get_success_url and reverse_lazy
  • Confirmation templates for deletion

Lesson notes

1. The two form views you wrote by hand

post_new and post_edit from the tutorial are almost the same function twice, and both contain the same four-line ritual: build the form, check the method, validate, redirect.

# blog/views.py - before
from django.shortcuts import get_object_or_404, redirect, render

from .forms import PostForm
from .models import Post


def post_new(request):
    if request.method == "POST":
        form = PostForm(request.POST)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.published_date = timezone.now()
            post.save()
            return redirect("post_detail", pk=post.pk)
    else:
        form = PostForm()
    return render(request, "blog/post_edit.html", {"form": form})


def post_edit(request, pk):
    post = get_object_or_404(Post, pk=pk)
    if request.method == "POST":
        form = PostForm(request.POST, instance=post)
        if form.is_valid():
            post = form.save(commit=False)
            post.author = request.user
            post.save()
            return redirect("post_detail", pk=post.pk)
    else:
        form = PostForm(instance=post)
    return render(request, "blog/post_edit.html", {"form": form})

CreateView and UpdateView already contain that ritual. What is left for you is the part that is actually about your blog: which form, which fields, who the author is, and where to go afterwards.

# blog/views.py - after
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, UpdateView

from .forms import PostForm
from .models import Post


class PostCreateView(CreateView):
    model = Post
    form_class = PostForm

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)


class PostUpdateView(UpdateView):
    model = Post
    form_class = PostForm


class PostDeleteView(DeleteView):
    model = Post
    success_url = reverse_lazy("blog:post_list")
# blog/urls.py
urlpatterns = [
    path("", views.PostListView.as_view(), name="post_list"),
    path("post/<int:pk>/", views.PostDetailView.as_view(), name="post_detail"),
    path("post/new/", views.PostCreateView.as_view(), name="post_new"),
    path("post/<int:pk>/edit/", views.PostUpdateView.as_view(), name="post_edit"),
    path("post/<int:pk>/delete/", views.PostDeleteView.as_view(), name="post_delete"),
]

2. What happens on POST

Both views define post() for you. It builds the form from request.POST (plus instance for updates), validates it, and calls one of two hooks. Those two hooks are where you plug in.

POST post/3/edit/ get_form() data + instance get_form_kwargs() is_valid()? yes no form_valid(form) form.save() -> self.object 302 to get_success_url() form_invalid(form) same template again 200 with form.errors

Two consequences worth remembering. First, a successful POST always ends in a redirect - the post/redirect/get pattern, which is why reloading the page never saves twice. Second, an invalid POST returns status 200 with the bound form, so the user sees their input and the errors next to it.

3. form_class versus fields

A model form view needs to know which fields to render. Give it exactly one of these:

OptionCodeWhen
fields fields = ["title", "text"] quick CRUD, no custom validation or widgets
form_class form_class = PostForm you have a real ModelForm - almost always

You already have PostForm, so keep it. All three views can share it, and both form views render blog/post_form.html by default. The tutorial called that file post_edit.html, so either rename it or say so:

class PostCreateView(CreateView):
    model = Post
    form_class = PostForm
    template_name = "blog/post_edit.html"

4. form_valid: the author, set once

form_valid() receives a validated form and is responsible for saving it. The clean way to fill in a field the user must not control is to touch form.instance before calling super():

class PostCreateView(CreateView):
    model = Post
    form_class = PostForm

    def form_valid(self, form):
        form.instance.author = self.request.user
        form.instance.published_date = timezone.now()
        return super().form_valid(form)  # saves and returns the redirect

super().form_valid(form) does two things: self.object = form.save() and return HttpResponseRedirect(self.get_success_url()). If you need the saved object for something else, do it after the save:

    def form_valid(self, form):
        form.instance.author = self.request.user
        response = super().form_valid(form)   # self.object now exists
        messages.success(self.request, f"Saved '{self.object.title}'.")
        return response

You can also add errors from the view, when the rule needs the request:

    def form_valid(self, form):
        if Post.objects.filter(author=self.request.user, title=form.instance.title).exists():
            form.add_error("title", "You already have a post with this title.")
            return self.form_invalid(form)
        form.instance.author = self.request.user
        return super().form_valid(form)

To pass the user into the form itself, extend get_form_kwargs():

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["user"] = self.request.user
        return kwargs

5. get_success_url and reverse_lazy

There are three ways to answer "where next", and they compose from most specific to least:

  1. get_success_url() on the view - use it when the target depends on the saved object.
  2. success_url on the view - a fixed destination.
  3. get_absolute_url() on the model - the default for CreateView and UpdateView, and the one to prefer.
# blog/models.py
from django.urls import reverse


class Post(models.Model):
    ...

    def get_absolute_url(self):
        return reverse("blog:post_detail", kwargs={"pk": self.pk})

With that method in place, PostCreateView and PostUpdateView need no success_url at all, and {{ post.get_absolute_url }} works in every template. When you do need an explicit URL:

class PostUpdateView(UpdateView):
    model = Post
    form_class = PostForm

    def get_success_url(self):
        return reverse("blog:post_detail", kwargs={"pk": self.object.pk})

6. DeleteView and its confirmation template

DeleteView answers a GET with a confirmation page and only deletes on POST - which is exactly right, because a link that deletes data will eventually be followed by a crawler.

class PostDeleteView(DeleteView):
    model = Post
    success_url = reverse_lazy("blog:post_list")
    # template: blog/post_confirm_delete.html
<!-- blog/templates/blog/post_confirm_delete.html -->
<h1>Delete post</h1>
<p>Really delete "{{ object.title }}"? This cannot be undone.</p>

<form method="post">
  {% csrf_token %}
  <button type="submit" class="btn btn-danger">Delete</button>
  <a href="{{ object.get_absolute_url }}" class="btn">Cancel</a>
</form>

A soft delete - hiding the row instead of removing it - is just another form_valid():

class PostDeleteView(DeleteView):
    model = Post
    success_url = reverse_lazy("blog:post_list")

    def form_valid(self, form):
        self.object = self.get_object()
        self.object.is_deleted = True
        self.object.save(update_fields=["is_deleted"])
        return HttpResponseRedirect(self.get_success_url())

Since Django 4.0 DeleteView is built on FormMixin, so the hook is form_valid() - older tutorials override delete(), which no longer runs.

7. One template for create and update

<!-- blog/templates/blog/post_form.html -->
<h1>{% if form.instance.pk %}Edit post{% else %}New post{% endif %}</h1>

<form method="post" novalidate>
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit" class="btn btn-primary">Save</button>
</form>

Note that nothing in the template knows whether a CreateView or an UpdateView rendered it - both provide form, and UpdateView additionally provides object.

8. Exercise

  1. Add get_absolute_url() to Post and delete every redirect("post_detail", pk=...) that becomes redundant.
  2. Replace post_new with a PostCreateView that sets the author in form_valid(), and confirm the author is correct even if you add author to the POST data by hand.
  3. Replace post_edit with an UpdateView reusing PostForm and the same template.
  4. Add a PostDeleteView with a confirmation template, then try to reach the delete URL with GET and verify nothing is deleted.
  5. Submit the form with an empty title and check that you get 200, your text back, and the error next to the field.

Further reading

After this lesson you will

  • Provide full CRUD for posts with generic views
  • Redirect users to the right page after each action