Rustango docs
← Cookbook

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:

Admin dashboard — models grouped by app, recent actions

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:

Post list view — columns, filters, search, bulk actions, 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:

Post detail — inline comments and audit trail

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_modelGET /cookbook_author returns 200 with the table name in the body.
  • §8.103 admin_create_form_renders_input_for_each_writable_fieldGET /cookbook_author/new returns 200; HTML contains name="name", name="email", name="bio"; does NOT contain name="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_blog on 127.0.0.1:8867.
    • POST /login as alice (acme tenant).
    • POST /admin/cookbook_author with name=ada lovelace etc.
    • GET /api/authors (acme) → returns [{id:1, name:"ada lovelace"}].
    • GET /api/authors (globex) → returns [].
    • GET /api/authors (apex localhost) → 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_add timestamps and Auto<T> PKs are filled by the database, so posting a new Author through the admin never asks for joined_at. (Same rule as ModelForm::parse in 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 as admin / letmein lands 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 as alice / tenantpw lands 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 with extra blank rows
  • /gfkdemo_tag — list view with the target column 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.