Flash Messages¶
Server-side messages that survive a single redirect. Used for success/error feedback after form submissions, CRUD operations, and other actions that redirect.
Overview¶
Flash messages follow the Post/Redirect/Get (PRG) pattern:
- User submits a form (POST)
- Server processes the form and adds a flash message
- Server redirects to a new page (GET)
- The new page displays the message
- Message is consumed -- it does not appear again on subsequent page loads
Quick Start¶
from hyperdjango.messages import add_message, get_messages
@app.post("/items/create")
async def create_item(request):
data = request.form_data()
item = await Item.objects.create(name=data["name"])
add_message(request, "success", "Item created successfully!")
return redirect("/items/")
@app.get("/items/")
async def list_items(request):
messages = get_messages(request)
items = await Item.objects.all()
return render(request, "items/list.html", {
"items": items,
"messages": messages,
})
add_message()¶
| Parameter | Type | Description |
|---|---|---|
request |
Request |
The current request object |
level |
str |
Message level: "info", "success", "warning", "error", "debug" |
text |
str |
The message text to display |
Messages are stored in the session (or per-request storage if no session middleware is active).
get_messages()¶
| Parameter | Type | Default | Description |
|---|---|---|---|
request |
Request |
required | The current request object |
clear |
bool |
True |
Whether to clear messages after retrieval |
Returns a list of message dicts:
[
{"level": "success", "text": "Item created successfully!"},
{"level": "warning", "text": "Your subscription expires soon."},
]
By default, messages are cleared after retrieval. Pass clear=False to peek without consuming:
# Peek at messages without clearing
messages = get_messages(request, clear=False)
# Later, consume them
messages = get_messages(request) # cleared now
Message Levels¶
| Level | Constant | Use Case | CSS Class (Bootstrap) |
|---|---|---|---|
"info" |
INFO |
Neutral information | alert-info |
"success" |
SUCCESS |
Action completed successfully | alert-success |
"warning" |
WARNING |
Something needs attention | alert-warning |
"error" |
ERROR |
Action failed | alert-danger |
"debug" |
DEBUG |
Debug information (dev only) | alert-secondary |
Convenience Functions¶
Instead of add_message(request, "success", text), use the convenience shortcuts:
from hyperdjango.messages import success, error, info, warning
success(request, "Order placed!")
error(request, "Payment failed. Please try again.")
info(request, "Your profile was updated.")
warning(request, "Your subscription expires soon.")
These are equivalent to calling add_message() with the corresponding level.
Template Rendering¶
Basic Alert Divs¶
{% if messages %}
<div class="messages">
{% for msg in messages %}
<div class="alert alert-{{ msg.level }}">{{ msg.text }}</div>
{% endfor %}
</div>
{% endif %}
Bootstrap 5 Alerts¶
{% if messages %}
<div class="container mt-3">
{% for msg in messages %}
<div
class="alert alert-{{ msg.level }} alert-dismissible fade show"
role="alert"
>
{{ msg.text }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endfor %}
</div>
{% endif %}
Tailwind CSS Alerts¶
{% if messages %}
<div class="space-y-2 mb-4">
{% for msg in messages %}
<div
class="p-4 rounded-lg
{% if msg.level == 'success' %}bg-green-100 text-green-800 border border-green-200
{% elif msg.level == 'error' %}bg-red-100 text-red-800 border border-red-200
{% elif msg.level == 'warning' %}bg-yellow-100 text-yellow-800 border border-yellow-200
{% else %}bg-blue-100 text-blue-800 border border-blue-200{% endif %}"
>
{{ msg.text }}
</div>
{% endfor %}
</div>
{% endif %}
Icon-Enhanced Alerts¶
{% if messages %}
<div class="messages">
{% for msg in messages %}
<div class="alert alert-{{ msg.level }}">
{% if msg.level == 'success' %}✔ {% elif msg.level == 'error'
%}✖ {% elif msg.level == 'warning' %}⚠ {% else %}ℹ{%
endif %} {{ msg.text }}
</div>
{% endfor %}
</div>
{% endif %}
Session Requirement¶
Flash messages require session middleware to persist across redirects:
from hyperdjango.auth.sessions import SessionAuth
from hyperdjango.auth.db_sessions import DatabaseSessionStore
session_store = DatabaseSessionStore(app.db)
await session_store.ensure_table()
app.use(SessionAuth(session_store))
Without session middleware, messages are stored per-request in request._flash_messages and will not survive redirects. This is useful for testing or for displaying messages on the same page (no redirect).
How Session Storage Works¶
add_message()stores messages inrequest.session["_messages"]- On redirect, the session is saved to the database
- On the next request, the session is loaded and
get_messages()reads from it - After retrieval with
clear=True, the messages are removed from the session
Message Consumption¶
Messages are consumed (cleared) after get_messages() is called with clear=True (the default). This ensures each message is displayed exactly once:
# First call: returns messages and clears them
messages = get_messages(request) # [{"level": "success", "text": "Done!"}]
# Second call: returns empty list
messages = get_messages(request) # []
This single-use behavior is essential for the PRG pattern. Without it, messages would reappear on every page load.
Multiple Messages¶
You can add multiple messages to a single request:
@app.post("/bulk-delete")
async def bulk_delete(request):
ids = request.form_data().getlist("ids")
deleted = 0
errors = 0
for item_id in ids:
try:
item = await Item.objects.get(id=int(item_id))
await item.delete()
deleted += 1
except Item.DoesNotExist:
errors += 1
warning(request, f"Item {item_id} not found")
except PermissionError:
errors += 1
error(request, f"No permission to delete item {item_id}")
if deleted:
success(request, f"Deleted {deleted} items")
if errors:
warning(request, f"{errors} items could not be deleted")
return redirect("/items/")
All messages are stored in order and displayed together on the next page.
MessageMiddleware¶
For automatic message loading into request context, use the MessageMiddleware:
The middleware loads pending messages into request._pending_messages before the handler runs. This makes messages available to templates without explicitly calling get_messages() in every view:
# With MessageMiddleware, templates can access messages directly
@app.get("/items/")
async def list_items(request):
items = await Item.objects.all()
return render(request, "items/list.html", {
"items": items,
"messages": request._pending_messages,
})
Auto-Dismiss Pattern¶
Make messages automatically disappear after a few seconds using CSS animation or JavaScript:
CSS Animation¶
<style>
.alert {
animation: fadeOut 0.5s ease-in 4s forwards;
}
@keyframes fadeOut {
from {
opacity: 1;
max-height: 100px;
margin-bottom: 1rem;
padding: 1rem;
}
to {
opacity: 0;
max-height: 0;
margin-bottom: 0;
padding: 0;
overflow: hidden;
}
}
</style>
{% for msg in messages %}
<div class="alert alert-{{ msg.level }}">{{ msg.text }}</div>
{% endfor %}
JavaScript¶
{% for msg in messages %}
<div class="alert alert-{{ msg.level }}" data-auto-dismiss="5000">
{{ msg.text }}
</div>
{% endfor %}
<script>
document.querySelectorAll("[data-auto-dismiss]").forEach((el) => {
const delay = parseInt(el.dataset.autoDismiss, 10);
setTimeout(() => {
el.style.transition = "opacity 0.5s";
el.style.opacity = "0";
setTimeout(() => el.remove(), 500);
}, delay);
});
</script>
HTMX Integration¶
Toast Notifications with HTMX¶
Use HTMX to display flash messages as toast notifications without a full page reload:
@app.post("/items/create")
async def create_item(request):
item = await Item.objects.create(name=request.form_data()["name"])
# For HTMX requests, return messages as an OOB swap
if request.headers.get("HX-Request"):
html = f'<div id="toast-container" hx-swap-oob="beforeend">'
html += f'<div class="toast" data-auto-dismiss="3000">Item "{item.name}" created!</div>'
html += f'</div>'
return Response.html(html)
# For regular requests, use flash messages
success(request, f"Item \"{item.name}\" created!")
return redirect("/items/")
HTMX Toast Container¶
Add a toast container to your base template:
<!-- base.html -->
<body>
<div id="toast-container" class="toast-container"></div>
{% block content %}{% endblock %}
</body>
.toast-container {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
}
.toast {
background: #333;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
animation:
slideIn 0.3s ease-out,
fadeOut 0.5s ease-in 2.5s forwards;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
HTMX Response Headers¶
Alternatively, use HX-Trigger headers to trigger client-side toast display:
@app.post("/items/create")
async def create_item(request):
item = await Item.objects.create(name=request.form_data()["name"])
response = Response.html(render_items_list())
response.headers["HX-Trigger"] = json.dumps({
"showToast": {"message": f"Item '{item.name}' created!", "level": "success"}
})
return response
<script>
document.body.addEventListener("showToast", function (evt) {
const { message, level } = evt.detail;
const toast = document.createElement("div");
toast.className = `toast toast-${level}`;
toast.textContent = message;
document.getElementById("toast-container").appendChild(toast);
setTimeout(() => toast.remove(), 3000);
});
</script>
Testing Messages¶
Checking Messages in Tests¶
from hyperdjango.testing import TestClient
from hyperdjango.messages import get_messages
async def test_create_item_message():
client = TestClient(app)
response = await client.post("/items/create", data={"name": "Test"})
assert response.status_code == 302
# Follow the redirect
response = await client.get("/items/")
assert "Item created successfully!" in response.text
async def test_message_levels():
client = TestClient(app)
# Add multiple messages at different levels
await client.post("/test-messages")
response = await client.get("/show-messages")
assert "alert-success" in response.text
assert "alert-error" in response.text
Testing Message Content Directly¶
async def test_messages_stored_in_session():
client = TestClient(app)
# Trigger a message
response = await client.post("/items/create", data={"name": "Test"})
# Check session contains the message
# (this depends on your session storage implementation)
assert response.status_code == 302
Testing Without Session Middleware¶
Without session middleware, messages are stored in request._flash_messages:
async def test_messages_per_request():
from hyperdjango.messages import add_message, get_messages
class FakeRequest:
pass
request = FakeRequest()
add_message(request, "success", "Test message")
messages = get_messages(request)
assert len(messages) == 1
assert messages[0]["level"] == "success"
assert messages[0]["text"] == "Test message"
# Messages are cleared after get_messages
messages = get_messages(request)
assert len(messages) == 0