Rustango docs
← Cookbook

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):

viewcontext vars
ListViewobject_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)
DetailViewobject (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 an i64 column
  • Boundsmax_length exceeded on a string, min/max violated on an integer (uses core::validate_value so 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. As of v0.29.10 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 (v0.30.4)

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 (v0.30.8)

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 (v0.30.7)

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>() (v0.30.2)

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

  • tenancy features.

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.