The recording for this lesson has not been published yet.
When a form submits successfully you must not render a page in response to the POST. You redirect. Otherwise the browser remembers a POST for that URL, and a refresh or a back button re-submits it - a second comment, a second payment.
302 to a page the user can safely reload.But a redirect throws away your context: the new page has no idea that something just succeeded. That is exactly the gap the messages framework fills - it stores one short notice in the session and shows it on the very next request.
startproject wires it up for you. Check that all three pieces are present:
# mysite/settings.py
INSTALLED_APPS = [
...
"django.contrib.messages",
]
MIDDLEWARE = [
...
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
TEMPLATES = [
{
...
"OPTIONS": {
"context_processors": [
...
"django.contrib.messages.context_processors.messages",
],
},
},
]
| Shortcut | Level | Tag | Use it for |
|---|---|---|---|
messages.debug() | 10 | debug | Development noise - hidden by default |
messages.info() | 20 | info | Neutral facts: "your draft was autosaved" |
messages.success() | 25 | success | The action worked |
messages.warning() | 30 | warning | It worked, but read this |
messages.error() | 40 | error | It did not work |
from django.contrib import messages
def post_new(request):
form = PostForm(request.POST or None)
if request.method == "POST":
if form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
messages.success(request, f"'{post.title}' was published.")
return redirect(post.get_absolute_url())
messages.error(request, "The post was not saved - see the errors below.")
return render(request, "blog/post_edit.html", {"form": form})
The default level is INFO, so debug messages are dropped. Change
it globally, or make Bootstrap and Tabler happy by mapping the error tag to the
CSS class you actually use:
# mysite/settings.py
from django.contrib.messages import constants as message_constants
MESSAGE_LEVEL = message_constants.DEBUG if DEBUG else message_constants.INFO
MESSAGE_TAGS = {
message_constants.DEBUG: "secondary",
message_constants.INFO: "info",
message_constants.SUCCESS: "success",
message_constants.WARNING: "warning",
message_constants.ERROR: "danger", # Bootstrap has no "error" class
}
Put this in base.html and every page in the project gets feedback for free.
{% if messages %}
<div class="container-xl mt-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible" role="alert">
<div>{{ message }}</div>
<a class="btn-close" data-bs-dismiss="alert" aria-label="close"></a>
</div>
{% endfor %}
</div>
{% endif %}
The storage is a one-shot queue: iterating it marks the messages as read and they are
cleared at the end of the response. So loop over messages exactly once, in
one place. A second loop comes out empty, and messages you never render at all reappear
on the following page.
You have typed {% csrf_token %} in every form
without being told why. Here is the attack it stops.
Your session cookie is sent by the browser on every request to your domain,
including requests started by somebody else's page. So evil.example can contain
a hidden form that posts to yourblog.example/post/5/delete/, and if you are
logged in, the browser attaches your cookie and the delete succeeds. You never clicked
anything on your own site. That is cross-site request forgery.
The fix is to require a second proof that the request came from a page you served. Django uses the double-submit pattern: the same secret has to arrive in a cookie and in the POST body. The attacker's page can make the browser send your cookies, but it cannot read them, so it cannot put the matching value in the body.
CsrfViewMiddleware is in MIDDLEWARE by default. Leave it there.<form method="post"> that points at your own site needs
{% csrf_token %}, including delete and logout
forms.<a href="/post/5/delete/">
is not CSRF-protected, and a link prefetcher or an email client will eventually click it
for you. Use a small POST form with a button.CSRF_COOKIE_SECURE = True and list your real
domains in CSRF_TRUSTED_ORIGINS (with the scheme:
["https://myblog.example"]).<form method="post" action="{% url 'blog:post_delete' post.pk %}">
{% csrf_token %}
<button class="btn btn-danger">Delete</button>
</form>
A JSON request has no hidden input, so send the token in the
X-CSRFToken header. Read it from the cookie:
function getCookie(name) {
return document.cookie.split("; ")
.find((row) => row.startsWith(name + "="))
?.split("=")[1];
}
await fetch("/comments/42/like/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken"),
},
body: JSON.stringify({ value: 1 }),
});
Two prerequisites. The cookie must exist, which means the page was rendered by a view that
used the token - add @ensure_csrf_cookie to the view if it renders no form. And
it must be readable from JavaScript, so leave CSRF_COOKIE_HTTPONLY at its
default of False. HTMX sends the header for you if you configure it once:
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
When a POST returns 403, the temptation is to decorate the view with
@csrf_exempt. That does not fix the bug, it removes the protection and
re-opens the attack above for that one view. The only legitimate uses are endpoints that
no browser session can reach - a signed webhook from Stripe or GitHub, where you verify
the provider's own signature instead. Everything else: send the token.
from django.contrib.messages import get_messages
from django.test import Client, TestCase
class CommentFlowTests(TestCase):
def test_success_message_and_redirect(self):
response = self.client.post(f"/post/{self.post.pk}/", {"body": "Nice one"})
self.assertRedirects(response, f"/post/{self.post.pk}/#comments")
texts = [str(m) for m in get_messages(response.wsgi_request)]
self.assertIn("Thanks!", texts[0])
def test_post_without_csrf_token_is_rejected(self):
client = Client(enforce_csrf_checks=True) # off by default in tests
response = client.post(f"/post/{self.post.pk}/", {"body": "Nope"})
self.assertEqual(response.status_code, 403)
The test client skips CSRF checks so your tests stay readable;
enforce_csrf_checks=True turns them back on for the one test where that is the
point.
base.html and the MESSAGE_TAGS
mapping so that errors render as alert-danger.messages twice in the base template and watch the second loop
come out empty. Undo it.{% csrf_token %} from the comment
form, submit, and read the 403 debug page top to bottom. Put it back.enforce_csrf_checks=True.