Chapter 8 — Admin
Two parts: in-process router smoke + a real-browser playwright session.
What it looks like. These are live captures of the auto-admin
served by the admin_demo companion (a plain,
non-tenanted admin so the pages render without tenant host-routing —
see screenshots/README.md). The same widgets
back the cookbook's own admin.
The model index groups every registered model by app and lists a recent-actions log:

A model's admin(...) block drives the list view — list_display
columns, a list_filter sidebar, search_fields with help text, bulk
actions, and pagination:

The detail page renders the record, an inline child table
(register_admin_inline! — the post's comments), and an audit trail
with a per-write JSON diff:

Part A — in-process smoke (no socket, no browser)
tests/cookbook_chapter08_admin.rs boots admin::Builder::new(pool) .build() and hits routes via tower::ServiceExt::oneshot. 2 tests:
- §8.100 / 8.101
admin_builder_serves_list_page_for_registered_model—GET /cookbook_authorreturns 200 with the table name in the body. - §8.103
admin_create_form_renders_input_for_each_writable_field—GET /cookbook_author/newreturns 200; HTML containsname="name",name="email",name="bio"; does NOT containname="id"(Auto PK is server-assigned and hidden from the create form).
Run: DATABASE_URL=... cargo test --test cookbook_chapter08_admin -- --test-threads=1.
Part B/C — real-binary HTTP loop (Chapter 8b)
tests/cookbook_chapter08b_browser_forms.rs boots the actual
cookbook_blog binary against an isolated DB and drives the full
admin form + ViewSet flow over HTTP — the closest a Rust integration
test comes to a real browser session without playwright in the loop.
1 test:
admin_form_creates_then_viewset_isolates_per_tenant:- migrate registry → create operator → create acme + globex → create alice/tenantpw on acme.
- spawn
cookbook_blogon127.0.0.1:8867. POST /loginas alice (acme tenant).POST /admin/cookbook_authorwithname=ada lovelaceetc.GET /api/authors(acme) → returns[{id:1, name:"ada lovelace"}].GET /api/authors(globex) → returns[].GET /api/authors(apexlocalhost) →404(no tenant route).
Run: DATABASE_URL=... cargo test --test cookbook_chapter08b_browser_forms -- --test-threads=1.
Note: the admin's create/edit handler skips auto-populated fields server-side —
auto_now_addtimestamps andAuto<T>PKs are filled by the database, so posting a new Author through the admin never asks forjoined_at. (Same rule asModelForm::parsein Chapter 7.)
Part D — real-browser session (playwright MCP)
Reproducible by hand:
# Terminal 1 — fresh DB + boot
docker exec shop-postgres-1 psql -U rustango -c "CREATE DATABASE cookbook_browser_dev"
DATABASE_URL=postgres://rustango:rustango@localhost:5432/cookbook_browser_dev \
RUSTANGO_APEX_DOMAIN=localhost \
RUSTANGO_BIND=127.0.0.1:8765 \
RUSTANGO_SESSION_SECRET=cookbook-test-32bytes-cookbook-test-32bytes \
cargo run -- migrate
# `migrate` generates the framework's `system/migrations/` from the
# compiled models on first run and applies them (no `init-tenancy`
# step — the framework ships no hardcoded bootstrap migrations).
# (then `create-operator admin --password letmein`,
# `create-tenant acme --display-name "Acme Inc" --host-pattern acme.localhost`,
# `create-user acme alice --password tenantpw --superuser`,
# finally `cargo run`)
Verified browser-side via playwright MCP:
- §8.0
http://localhost:8765/login— operator login form renders with username/password/Sign in. Logging in asadmin / letmeinlands on the operator console (sidebar nav: Home, Operators, Organizations). - §8.0
http://acme.localhost:8765/login— tenant login form renders titled "Sign in to acme". Logging in asalice / tenantpwlands on the tenant admin index showing every registered model split by app group:apps(the cookbook's blog/auth/etc. models),contenttypes(rustango_content_types),tenancy(rustango_users + friends).
Caveat: the tenant admin returns a JSON 500 (relation "cookbook_author" does not exist) when a tenant-scoped model's table
isn't materialized in the tenant's schema. The cookbook's models live
in inventory but no make-migrations has been run for them, so the
admin lists them on the index then errors on browse — run
migrate-tenants first to materialize the tables.
8.112 register_admin_inline! — read-only inline display
What: Render N child rows under a parent's admin detail page, keyed on a single FK column. Each row links into the child's admin detail. Foundation for the editable variant in 8.113.
Recipe:
rustango::register_admin_inline!(
parent = "blog_post", // ModelSchema::table of the parent
child = "blog_comment", // ModelSchema::table of the child
fk = "post_id", // child column pointing back at the parent
kind = rustango::admin::InlineKind::Tabular, // or Stacked
label = "Comments",
fields = &["body", "created_at"],
);
The parent's /__admin/blog_post/<pk> page renders a "Comments"
panel below the parent fields. Multiple inlines per parent are
supported — each registration produces a separate panel.
Verified by: tests/admin_inlines_live.rs.
8.113 register_admin_inline! — editable inlines + FormSet POST
What: Same registration shape as 8.112; rows on the edit page
become editable inputs. extra blank rows let the operator add new
children, each existing row gets a hidden PK + a DELETE checkbox.
On POST the handler dispatches per row: PK+DELETE → delete_pool;
PK → update_pool (FK column skipped — no reparenting); no PK +
non-empty → insert_pool with FK pinned to the parent.
rustango::register_admin_inline!(
parent = "blog_post",
child = "blog_comment",
fk = "post_id",
extra = 2, // two blank rows for adding new children
max_num = Some(20), // upper bound (rendered to mgmt form)
);
The full Django FormSet shape is rendered: <prefix>-TOTAL_FORMS,
<prefix>-INITIAL_FORMS, <prefix>-MAX_NUM_FORMS, prefix-mangled
<prefix>-N-<field> inputs.
Verified by: tests/admin_inlines_edit_live.rs.
8.114 register_admin_inline_generic! — generic admin inlines
What: Generic variant of 8.112/8.113. Keys on a
(content_type_id, object_pk) pair instead of a single FK column —
Django's GenericTabularInline / GenericStackedInline shape.
Recipe:
rustango::register_admin_inline_generic!(
parent = "blog_post",
child = "blog_tag",
ct = "content_type_id", // child's CT column
pk = "object_pk", // child's PK column
kind = rustango::admin::InlineKind::Tabular,
label = "Tags",
fields = &["name"],
extra = 1,
);
The same Tag model can register inlines under multiple parents
(e.g. one under blog_post, another under blog_article). The
INSERT path pins BOTH polymorphic columns to the parent's CT id +
PK; UPDATE skips both columns so a malicious POST can't reparent a
row to a different parent.
Verified by: tests/admin_inline_generic_live.rs (read-only) +
tests/admin_inline_generic_edit_live.rs (editable + reparenting-
attack pin).
8.115 GFK <select> picker on the standalone create/edit form
What: When a model carries #[rustango(generic_fk(...))], its
standalone /__admin/<table>/new and /__admin/<table>/<pk>/edit
pages render the ct_column as a <select> populated from
rustango_content_types. Each option is labeled
<app_label>.<model_name>; the row's current CT is pre-selected
on edit.
No extra wiring required — the picker is automatic when the schema
declares a generic_fk. Operators no longer have to memorize
integer CT ids.
Verified by: tests/admin_gfk_picker_live.rs.
8.116 Full GFK demo
The complete polymorphic-relations surface — declaration, accessor,
setter, list-view link, both inline variants, and the picker — is
exercised end-to-end in examples/gfk_demo. Run
locally with:
mkdir -p var
DATABASE_URL='sqlite:./var/gfk_demo.db?mode=rwc' \
cargo run -p rustango --example gfk_demo \
--features sqlite,admin,runserver
Visit http://localhost:8080/ and click through:
/gfkdemo_post/1— Tags + Comments inline panels (read-only display)/gfkdemo_post/1/edit— editable inlines withextrablank rows/gfkdemo_tag— list view with thetargetcolumn as one clickable link/gfkdemo_tag/new— create form with the CT<select>picker
One sqlite file, no tenancy, ~150 LOC across main.rs + models.rs + seed.rs.