Chapter 9b — Template views (Django-shape CBVs)
API: template_views::ListView,
template_views::DetailView,
template_views::CreateView,
template_views::UpdateView,
template_views::DeleteView.
The template_views module is the HTML-side sibling of viewset —
generic class-based views that build a Tera-rendered axum::Router
over any #[derive(Model)] schema. The full Django-shape CRUD
surface ships: ListView, DetailView, CreateView, UpdateView,
DeleteView.
use rustango::template_views::{ListView, DetailView};
use std::sync::Arc;
use tera::Tera;
let mut tera = Tera::default();
tera.add_raw_template("posts_list.html", r#"
{% for post in object_list %}<h2>{{ post.title }}</h2>{% endfor %}
{% if has_prev %}<a href="?page={{ page - 1 }}">prev</a>{% endif %}
{% if has_next %}<a href="?page={{ page + 1 }}">next</a>{% endif %}
"#).unwrap();
tera.add_raw_template("posts_detail.html", r#"
<h1>{{ object.title }}</h1>
"#).unwrap();
let tera = Arc::new(tera);
use rustango::template_views::{CreateView, UpdateView, DeleteView};
let app = axum::Router::new()
.merge(ListView::for_model(Post::SCHEMA)
.page_size(20)
.max_page_size(100) // cap for ?page_size= overrides
.order_by("created_at", true)
.filter_fields(&["author_id", "status"]) // ?author_id=42&status=published
.search_fields(&["title", "body"]) // ?search=rustango → ILIKE %rustango%
.ordering_fields(&["title", "created_at"]) // ?ordering=title / ?ordering=-created_at
.router("/posts", tera.clone(), pool.clone()))
.merge(DetailView::for_model(Post::SCHEMA)
.router("/posts", tera.clone(), pool.clone()))
.merge(CreateView::for_model(Post::SCHEMA)
.success_url("/posts/{pk}/{slug}") // any column from the new row
.router("/posts", tera.clone(), pool.clone()))
.merge(UpdateView::for_model(Post::SCHEMA)
.success_url("/posts")
.router("/posts", tera.clone(), pool.clone()))
.merge(DeleteView::for_model(Post::SCHEMA)
.success_url("/posts")
.router("/posts", tera.clone(), pool.clone()));
Tera context (consistent across views so templates port cleanly):
| view | context vars |
|---|---|
ListView | object_list (Vec of row-as-JSON), page, page_size, total, total_pages, has_next, has_prev, filters (Map), search (String), ordering (String — active spec or ""), next_page_url / prev_page_url (Option — query strings preserving filter/search/ordering across pagination) |
DetailView | object (single row as JSON) |
CreateView (GET) | form: { fields, errors }, is_create=true, is_update=false |
UpdateView (GET) | form: { fields, errors }, object, pk, is_create=false, is_update=true |
DeleteView (GET confirm) | object (single row as JSON) |
form.fields is a list of {name, column, ty, required, max_length, value}
records — branch on ty ("string" | "i16" | "i32" | "i64" | "f32" | "f64" | "bool" | "datetime" | "date" | "uuid" | "json") to pick
<input type=…> markup. The PK and Auto<T> columns are skipped
automatically (DB-assigned). Validation failures re-render the form
with form.errors populated and a 422 status code, preserving what
the user typed:
- Required-missing — empty value on a NOT NULL non-bool field
- Type coercion —
"abc"submitted for ani64column - Bounds —
max_lengthexceeded on a string,min/maxviolated on an integer (usescore::validate_valueso the error matches what the SQL layer would have caught on insert, but surfaced server-side without a round-trip)
Default template names follow Django convention:
<table>_list.html / <table>_detail.html / <table>_form.html
(shared by Create + Update) / <table>_confirm_delete.html. Override
via .template("custom.html"). Restrict columns rendered into the
context via .fields(&["id", "title"]).
DeleteView is two-step: GET <prefix>/{pk}/delete renders a
confirmation page (so the user can change their mind), POST <prefix>/{pk}/delete executes the delete and 303s to success_url
(default /; typically the list URL).
CSRF protection: every form GET (CreateView, UpdateView,
DeleteView) stamps csrf_token into the Tera context and sets
the rustango_csrf cookie when missing, so templates can render:
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<!-- {% for field in form.fields %} … {% endfor %} -->
<button type="submit">Save</button>
</form>
POST validation is a separate layer. The recommended
shortcut is Cli::with_csrf() — see
Auto-mounting CSRF.
For projects not using Cli, mount forms::csrf::layer() directly
on the router to enforce that the _csrf form field matches the
cookie value. Without it the csrf_token context var still
populates, but POSTs aren't validated.
Bulk actions on ListView
Django-admin shape: row checkboxes + an action <select> that
applies the same operation to every selected row. Opt in with
.bulk_actions(true):
use rustango::template_views::{BulkActionFn, ListView};
use std::sync::Arc;
let publish: BulkActionFn = Arc::new(|pool, pks| {
let pool = pool.clone();
let pks = pks.to_vec();
Box::pin(async move {
let ids: Vec<i64> = pks.iter()
.filter_map(|v| match v { SqlValue::I64(n) => Some(*n), _ => None })
.collect();
sqlx::query("UPDATE posts SET status = 'published' WHERE id = ANY($1)")
.bind(&ids).execute(&pool).await
.map(|_| ()).map_err(|e| e.to_string())
})
});
ListView::for_model(Post::SCHEMA)
.bulk_actions(true) // built-in delete_selected
.action("publish_selected", "Publish selected", publish)
.router("/posts", tera, pool)
Template glue:
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<select name="action">
{% for a in bulk_actions %}
<option value="{{ a.name }}">{{ a.label }}</option>
{% endfor %}
</select>
<button type="submit">Apply</button>
{% for row in object_list %}
<input type="checkbox" name="_selected_action" value="{{ row.id }}">
{{ row.title }}
{% endfor %}
</form>
Tenancy projects use .tenant_action(name, label, handler) (the
handler takes &mut PgConnection from Tenant::conn() instead of
a captured pool) and mount via .tenant_router(...) instead of
.router(...). Mixing kinds — registering a .action() then
mounting via .tenant_router() — surfaces a clear runtime error
on dispatch.
FK display in list rows
Admin-shape lists usually want to show the FK target's name, not
its raw integer ID. Opt in with .with_fk_display(true):
ListView::for_model(Post::SCHEMA)
.with_fk_display(true) // adds `<col>_display` siblings
.router("/posts", tera, pool)
Each row's JSON now carries <column>_display for every FK on
the schema, resolved via a batch SELECT pk, display FROM <target> WHERE pk = ANY(...) (one extra query per FK column per
page). Templates render the display value with a graceful
fallback:
<td>{{ row.author_id_display | default(value=row.author_id) }}</td>
The default(value=...) keeps the template robust when the FK
target is unregistered, lacks a display field, or points at a
deleted row.
Confirmation step for destructive actions
delete_selected is a hard-to-undo operation; opt into a Django-
admin-shape confirmation page with .with_delete_confirmation(true):
ListView::for_model(Post::SCHEMA)
.bulk_actions(true)
.with_delete_confirmation(true) // two-step flow
.router("/posts", tera, pool)
The first POST renders <table>_confirm_bulk_delete.html (override
via .with_delete_confirmation_template("…")) with action,
pks, objects (full row data), and csrf_token in the Tera
context. The confirm button submits the same form with
confirmed=true added; the handler then runs the DELETE and 303s
back to the list.
Custom actions registered via .action(...) are NOT gated by the
flag — matches Django's convention (only delete_selected is
confirmed by default). Build your own confirm-then-submit shape if
a custom action needs it.
Business validation — .validator(...) and .form::<T>()
Schema-level checks (max_length, min, max) ship for free.
Business validation (min_length, regex, custom validator fns,
cross-field checks) hooks in via two builder methods on
CreateView / UpdateView:
// Closure shape — no new types, just `data: &HashMap<String,String>`.
CreateView::for_model(Post::SCHEMA)
.validator(|data| {
let mut errs = rustango::forms::FormErrors::default();
if data.get("title").map_or(true, |s| s.len() < 5) {
errs.add("title", "must be at least 5 characters");
}
if errs.is_empty() { Ok(()) } else { Err(errs) }
})
.router("/posts", tera, pool)
// Typed Form — wires #[derive(Form)] validators automatically.
#[derive(rustango::Form)]
pub struct PostForm {
#[form(min_length = 5)] title: String,
#[form(min_length = 1)] body: String,
}
CreateView::for_model(Post::SCHEMA)
.form::<PostForm>()
.router("/posts", tera, pool)
Both work the same on tenant_router(...). Errors merge with the
schema-level error map via "; " joining; non-field errors land
under form.errors.__all__ for top-of-form rendering.
Tenancy projects: tenant_router(...)
For multi-tenant projects (subdomain / schema / per-tenant database)
every CBV ships a tenant_router(prefix, tera) variant that drops
the pool argument — each request resolves its own connection via
the [crate::extractors::Tenant] extractor instead of capturing a
single pool at mount time. Mirrors viewset::ViewSet::tenant_router.
use rustango::template_views::{ListView, DetailView, CreateView, UpdateView, DeleteView};
let app = axum::Router::new()
.merge(ListView::for_model(Post::SCHEMA)
.page_size(20)
.tenant_router("/posts", tera.clone())) // no pool!
.merge(DetailView::for_model(Post::SCHEMA)
.tenant_router("/posts", tera.clone()))
.merge(CreateView::for_model(Post::SCHEMA)
.success_url("/posts")
.tenant_router("/posts", tera.clone()))
.merge(UpdateView::for_model(Post::SCHEMA)
.success_url("/posts")
.tenant_router("/posts", tera.clone()))
.merge(DeleteView::for_model(Post::SCHEMA)
.success_url("/posts")
.tenant_router("/posts", tera));
Every other knob (template name, page size, ordering, fields,
success_url) carries through unchanged. The Tera context shape is
identical between router and tenant_router so templates port
across without edits. Available behind the combined template_views
tenancyfeatures.
Single-tenant only today (capture a PgPool at mount time). The
tenant_router variant lands once we settle on the Tenant-extractor
pattern matching the viewset::tenant_router shape.