After upgrading a Django project, audit logging in a DeleteView's overridden delete() no longer runs on POST; why, and where should it move?
answer
- the 4.0 DeleteView rewrite
- POST now validates a form
- delete() only for one HTTP method
- an empty form that always passes
basics
~20 sSince Django 4.0 DeleteView handles POST through FormMixin: post() validates a confirmation form and form_valid() does the deletion. An overridden delete() now runs only on HTTP DELETE, so the logic belongs in form_valid() or a shared helper.
solid answer
~40 sBefore Django 4.0, `DeletionMixin.post()` just called `delete()`, so overriding `delete()` caught every browser submission. In 4.0 `DeleteView` gained `FormMixin`: `BaseDeleteView.post()` now fetches the object, builds `get_form()` and calls `form_valid()` or `form_invalid()`, and `form_valid()` computes the success URL, calls `self.object.delete()` and redirects. The overridden `delete()` is skipped on POST; it only runs if a client sends an HTTP `DELETE`, which bypasses the form entirely. Move the audit call into `form_valid()` (before `super()`), or into a helper called from both. As a bonus, `form_class` defaults to an empty, always-valid `Form`, so you can now require a confirmation checkbox and get `form_invalid()` re-rendering for free.
code
python · 28 linesfrom django import forms
from django.views.generic.edit import DeleteView
from .models import AuditEntry, Proposal
class WithdrawForm(forms.Form):
confirm = forms.BooleanField(required=True)
reason = forms.CharField(required=False, max_length=200)
class ProposalWithdrawView(DeleteView):
model = Proposal
form_class = WithdrawForm
success_url = '/conferences/{conference_id}/proposals/'
http_method_names = ['get', 'post'] # no DELETE path that skips the form
def get_queryset(self):
return Proposal.objects.filter(speaker=self.request.user)
def form_valid(self, form):
AuditEntry.objects.create(
actor=self.request.user,
action='withdraw',
target_id=self.object.pk,
note=form.cleaned_data['reason'],
)
return super().form_valid(form) # computes success_url, deletes, redirectsgo deeper
Recall that GET shows a confirmation page and only POST deletes, through the view's form_valid().
Explain BaseDeleteView.post(): fetch the object, validate the form, then form_valid() computes the URL, deletes and redirects.
Diagnose the upgrade bug: delete() overrides now run only on HTTP DELETE, which also bypasses the form. Move side effects into form_valid() and decide whether DELETE should be allowed at all.
Make release-note reading for behaviour changes part of the upgrade policy, and cover destructive views with tests that assert their side effects, not just the redirect.
## What changed in Django 4.0 Up to Django 3.2, `DeleteView` was `DeletionMixin` plus a detail view. `DeletionMixin.post()` simply called `self.delete()`, and `delete()` fetched the object, deleted it and redirected. Overriding `delete()` was therefore the standard way to add behaviour, such as writing an audit record when a speaker withdraws a talk proposal. Django 4.0 rebuilt `DeleteView` on **`FormMixin`**. The release notes say it directly: custom deletion logic in `delete()` handlers should move to `form_valid()` or a shared helper. Code that was never moved keeps working, silently without its side effect. ## The POST flow in current Django `BaseDeleteView` inherits from `DeletionMixin`, `FormMixin` and `BaseDetailView`, and overrides `post()`: 1. `self.object = self.get_object()` fetches the proposal (a 404 if it does not exist or is filtered out by `get_queryset()`). 2. `form = self.get_form()` builds the confirmation form from POST data. 3. If `form.is_valid()`, `form_valid(form)` runs; otherwise `form_invalid(form)` re-renders the confirmation page with errors. 4. `BaseDeleteView.form_valid()` computes `get_success_url()`, calls `self.object.delete()` and returns the redirect. `delete()` does not appear anywhere on this path. ## Where delete() still runs `DeletionMixin.delete()` still exists and is still dispatched by HTTP method name. A request with the method `DELETE`, sent by JavaScript or a test client, calls it directly: - it fetches the object, computes the URL, deletes and redirects; - it **does not** build or validate the confirmation form. So after the upgrade, the overridden `delete()` runs only for the rare `DELETE` request, and a required confirmation checkbox can be skipped by anyone who sends that method. If the view should only accept browser submissions, restrict its allowed methods to GET and POST. ## Moving the logic | Old override | New location | Note | |---|---|---| | `delete()` writing an audit row | `form_valid()` before `super().form_valid(form)` | `self.object` is set and still has its pk | | `delete()` refusing to delete accepted talks | `form_valid()` or the form's validation | or narrow `get_queryset()` so the object 404s | | logic needed for both POST and DELETE | a helper method called from both | keeps the two paths consistent | Call `super().form_valid(form)` after your own work so the success URL is still computed before the row disappears. ## The confirmation form `BaseDeleteView.form_class` is `django.forms.Form`: an empty form with no fields, which is always valid. The GET request renders `<app>/<model>_confirm_delete.html` with `object` and `form` in the context. Providing your own form class lets you: - require a "I understand this withdraws my talk" checkbox; - ask for a reason and store it in the audit record from `form.cleaned_data`; - get the invalid path for free: an unticked box re-renders the page with an error instead of deleting. Because deletion now happens in `form_valid()`, mixins that hook `form_valid()` also work on `DeleteView`, which was not possible before 4.0.
- What does the default form_class of a Django DeleteView validate?Nothing: it is `django.forms.Form` with no fields, so `is_valid()` is always true for a POST. It exists so the view follows the `FormMixin` flow and so you can swap in a form with a confirmation checkbox or a reason field.
- How can a client still delete without passing your confirmation form?By sending an HTTP `DELETE` request: `View.dispatch()` routes it to `DeletionMixin.delete()`, which deletes and redirects without building the form. Removing `delete` from the view's allowed methods, or guarding that method too, closes the gap.
- Why must your audit code in form_valid() run before super().form_valid(form)?`BaseDeleteView.form_valid()` deletes the object and returns the redirect. After it, `self.object` has a `None` primary key and the row is gone, so anything that needs the pk or related rows must run first.
saying these in an interview costs you the question
- DeleteView still calls delete() for every POST submission
- The default DeleteView form rejects submissions without a confirmation field
- A GET request to DeleteView deletes the object immediately
- Custom logic belongs in post() so it runs before the form is built
- An HTTP DELETE request goes through form_valid() like a POST