rustio-admin 0.20.0

Django Admin, but for Rust. A small, focused admin framework.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! `ModelAdmin` — Django-style customisation surface.
//!
//! Every model that ships through `Admin::model::<M>()` must
//! implement `ModelAdmin`. The trait defines defaults for every
//! method, so a project that wants standard behaviour writes a one-
//! line empty impl:
//!
//! ```ignore
//! use rustio_admin::ModelAdmin;
//!
//! impl ModelAdmin for Course {}            // accept every default
//! ```
//!
//! Override only the methods you care about; the rest inherit the
//! trait defaults:
//!
//! ```ignore
//! impl ModelAdmin for Course {
//!     fn list_display() -> &'static [&'static str] {
//!         &["code", "title", "credit_hours", "is_published"]
//!     }
//!     fn list_filter()  -> &'static [&'static str] { &["status", "level"] }
//!     fn search_fields() -> &'static [&'static str] { &["code", "title"] }
//!     fn ordering()     -> &'static [&'static str] { &["code"] }
//! }
//! ```
//!
//! The values are captured into [`super::AdminEntry`] at registration
//! time. The runtime reads them straight from the entry — no
//! per-request virtual dispatch beyond the existing `dyn AdminOps`.
//!
//! ### Why no blanket impl?
//!
//! An earlier draft shipped `impl<T: AdminModel> ModelAdmin for T {}`
//! so every derived `AdminModel` would auto-pick-up the defaults.
//! That collides with Rust's coherence rules — without
//! `feature(specialization)` (nightly-only), a blanket impl forbids
//! any per-type impl, which would block project overrides entirely.
//! The opt-in `impl ModelAdmin for X {}` is the standard stable-Rust
//! pattern (serde, axum, std).

use super::AdminModel;

// public:
/// One named group of fields on the change form. The framework's
/// default heuristic in [`super::render::form_ctx`] groups by name
/// (Default / System / Advanced); a project that wants explicit
/// section ordering returns a non-empty `&'static [Fieldset]` from
/// [`ModelAdmin::fieldsets`] and the renderer honours that instead.
#[derive(Debug, Clone)]
pub struct Fieldset {
    pub title: &'static str,
    pub fields: &'static [&'static str],
}

// public:
/// One related-children section to render below a parent model's
/// edit form. v1 surface: read-only listing of up to `max_rows`
/// children matching `<target.fk_field> = <parent.id>`, each row
/// a click-through to its own edit page, with an "Add new …"
/// link that lands the operator on the child's new form (the
/// operator fills the parent FK manually for now).
///
/// Project authors declare these on the parent via
/// [`ModelAdmin::inlines`]:
///
/// ```ignore
/// fn inlines() -> &'static [Inline] {
///     &[Inline {
///         target_model: "Appointment",
///         fk_field: "patient_id",
///         label: Some("Appointments"),
///         max_rows: 50,
///     }]
/// }
/// ```
///
/// `target_model` must match a registered admin entry's
/// `SINGULAR_NAME`. `fk_field` is the column on the child that
/// holds the parent's id. A typo in either name silently renders
/// an empty section.
#[derive(Debug, Clone)]
pub struct Inline {
    pub target_model: &'static str,
    pub fk_field: &'static str,
    /// Section title. `None` → fall back to the target model's
    /// `display_name`.
    pub label: Option<&'static str>,
    /// Cap how many children are fetched + rendered. Operators
    /// who need to see the rest follow a "…and N more" link to
    /// the target's list page pre-filtered to this parent.
    pub max_rows: usize,
    /// Column on the target whose value is rendered as each
    /// inline row's clickable label. `None` falls through the
    /// framework's display-field ladder (`name → title →
    /// full_name → email`) and finally to `#<id>`. Set this for
    /// child models without a natural-name column (e.g.
    /// `Appointment.status` or `Loan.borrowed_at`).
    pub display_field: Option<&'static str>,
}

// public:
/// One validation failure attached to a project-driven `validate`
/// call on [`ModelAdmin`]. Either targets a specific field (rendered
/// inline next to its input) or surfaces globally in the form's
/// error banner.
///
/// Plain owned struct — `Send + Sync` so a `Vec<FieldValidationError>`
/// can cross await points freely.
#[derive(Debug, Clone)]
pub struct FieldValidationError {
    /// `Some(name)` routes the error to the matching field on the
    /// form (rendered next to that input with the existing inline-
    /// error styling). `None` lands the message in the form-level
    /// banner — appropriate for cross-field rules ("end date must
    /// not be before start date" could attach to either, but a
    /// "this booking conflicts with another one" message has no
    /// single owning field).
    pub field: Option<&'static str>,
    /// User-facing message, one sentence. Should not include the
    /// field's own label — the renderer adds it.
    pub message: String,
}

impl FieldValidationError {
    // public:
    /// Construct an error attached to one field. `field` must
    /// match an `AdminField.name` on the model — otherwise the
    /// renderer falls through to the global banner.
    pub fn field(field: &'static str, message: impl Into<String>) -> Self {
        Self {
            field: Some(field),
            message: message.into(),
        }
    }

    // public:
    /// Construct a global / cross-field error. Renders in the form
    /// banner without a field anchor.
    pub fn global(message: impl Into<String>) -> Self {
        Self {
            field: None,
            message: message.into(),
        }
    }
}

// public:
/// Django-style customisation surface for a registered admin model.
///
/// Every type that implements [`AdminModel`] gets a default impl via
/// the blanket below. Override the methods you care about; everything
/// else inherits sensible defaults.
pub trait ModelAdmin: AdminModel {
    /// Columns shown on the list page, in order. Default: every
    /// field declared on `AdminModel::FIELDS`.
    ///
    /// Returning `&[]` means "use the model's full field list" — the
    /// list page expands the empty default into `M::FIELDS`. Any
    /// non-empty slice replaces the defaults verbatim.
    fn list_display() -> &'static [&'static str] {
        &[]
    }

    /// Columns offered as filter chips in the sidebar. Default: none.
    fn list_filter() -> &'static [&'static str] {
        &[]
    }

    /// Columns searched by the list-page search box (case-insensitive
    /// substring match). Default: none.
    fn search_fields() -> &'static [&'static str] {
        &[]
    }

    /// Name of a Postgres `tsvector` column to use for full-text
    /// search instead of the framework's default `ILIKE` OR-loop
    /// across `search_fields()`. When `Some("search_vector")`, the
    /// list-page WHERE clause switches to
    /// `<col> @@ websearch_to_tsquery('english', $N)` — operators
    /// keep typing in the same search box; the index does the
    /// work. Maintain the tsvector yourself (a generated column
    /// or a trigger; the framework doesn't write to it). Default:
    /// `None` (the existing ILIKE path).
    ///
    /// ```ignore
    /// // 1. Add a generated tsvector column in a migration:
    /// //    ALTER TABLE posts ADD COLUMN search_vector tsvector
    /// //      GENERATED ALWAYS AS (to_tsvector('english',
    /// //        coalesce(title,'') || ' ' || coalesce(body,''))) STORED;
    /// //    CREATE INDEX posts_search_idx ON posts USING gin(search_vector);
    /// //
    /// // 2. Opt in from ModelAdmin:
    /// fn search_index_column() -> Option<&'static str> {
    ///     Some("search_vector")
    /// }
    /// ```
    fn search_index_column() -> Option<&'static str> {
        None
    }

    /// Default ordering. `-foo` for `foo DESC`, `foo` for `foo ASC`.
    /// Multiple entries → multi-column ORDER BY in slice order.
    /// Default: `["-id"]` (newest first).
    fn ordering() -> &'static [&'static str] {
        &["-id"]
    }

    /// Rows per page on the list view. Default: 50.
    fn list_per_page() -> usize {
        50
    }

    /// Field names rendered as `disabled` on the change form. The
    /// browser does not submit disabled fields, so the framework
    /// transparently re-injects the existing row value into the form
    /// before calling `from_form` — readonly columns are persisted
    /// unchanged. Applies to **edit only**; on the add form the
    /// listed fields stay editable so the project can supply their
    /// initial value. Default: none.
    fn readonly_fields() -> &'static [&'static str] {
        &[]
    }

    /// Related-children sections rendered below the change form
    /// (the parent edit page). Default: empty — no inlines.
    /// Each entry references a registered child model by its
    /// `SINGULAR_NAME` and names the FK column on the child that
    /// points at the parent. The framework fetches up to
    /// `max_rows` matching rows, renders them as a table of
    /// click-through edit links + a per-row Delete link, and
    /// appends "Add new {child}" / "View all" affordances.
    ///
    /// **v1 surface — read-only.** Inline rows are display +
    /// click-through; in-page editing of inline rows is a future
    /// iteration. Adding a child still routes through the
    /// child's normal new-form; the parent FK is filled by the
    /// operator. See [`Inline`].
    fn inlines() -> &'static [Inline] {
        &[]
    }

    /// Field grouping on the change form. Default: empty — fall back
    /// to the framework's name heuristic (`Default` / `System` /
    /// `Advanced`). A non-empty return replaces the heuristic
    /// entirely: each [`Fieldset`] renders as one titled section in
    /// the order returned, and the fields inside it render in the
    /// order listed. Fields that exist on the model but are not
    /// referenced by any fieldset get appended to a trailing "Other"
    /// section so the form stays complete; misspelt names with no
    /// matching field are silently dropped.
    fn fieldsets() -> &'static [Fieldset] {
        &[]
    }

    /// Per-row business-rule validation, called by the framework
    /// after [`AdminModel::from_form`] succeeds but BEFORE the SQL
    /// insert / update fires. Default is `Ok(())` — projects opt in
    /// by overriding. Synchronous: validation can't query the DB;
    /// database-shape errors (UNIQUE violations, FK gone) flow
    /// through the existing constraint-translation path
    /// automatically and aren't this hook's concern.
    ///
    /// Returning `Err` short-circuits both create and update — the
    /// row never reaches Postgres. Each [`FieldValidationError`]
    /// either attaches to a specific field (rendered inline next
    /// to that input, with `aria-invalid`) or surfaces as a global
    /// rule violation (rendered in the form's error banner).
    ///
    /// Common shape:
    ///
    /// ```ignore
    /// fn validate(model: &Self) -> std::result::Result<(), Vec<FieldValidationError>> {
    ///     let mut errs = Vec::new();
    ///     if model.start_date > model.end_date {
    ///         errs.push(FieldValidationError::field(
    ///             "end_date",
    ///             "End date must not be before the start date.",
    ///         ));
    ///     }
    ///     if errs.is_empty() { Ok(()) } else { Err(errs) }
    /// }
    /// ```
    fn validate(_model: &Self) -> std::result::Result<(), Vec<FieldValidationError>> {
        Ok(())
    }

    /// Custom bulk actions surfaced as extra buttons in the list-view
    /// bulk bar (next to the framework's built-in Delete). Default:
    /// none.
    ///
    /// `BulkAction` is metadata only — pair this method with an
    /// [`ModelAdmin::execute_bulk_action`] override that matches on
    /// `name` and applies the work. The framework's default
    /// dispatcher returns a clear `BadRequest` for any name it
    /// doesn't recognise, so a forgotten implementation surfaces as
    /// an error page rather than a silent no-op.
    fn bulk_actions() -> &'static [BulkAction] {
        &[]
    }

    /// Run a project-defined bulk action against `ids`. Called once
    /// per `POST /admin/:model/bulk/:name` submission with the full
    /// id list — the implementation chooses between a single bulk
    /// SQL update and a per-row loop.
    ///
    /// The framework wraps this call with one [`audit::record`]
    /// emission per submission (using `BulkActionContext.actor`,
    /// `correlation_id`, and the `BulkActionResult` outcome).
    /// Projects don't need to audit the dispatch envelope themselves;
    /// any business-level audit emissions inside the action body are
    /// still the project's call.
    ///
    /// Two channels for "something went wrong":
    ///
    ///   - **Action itself failed** — return `Err(...)`. The framework
    ///     surfaces it as a 4xx/5xx page and still writes an audit row
    ///     for the attempt.
    ///   - **Some rows failed** — return `Ok(BulkActionResult)` with
    ///     a populated `failed` list. The framework records a
    ///     partial-success audit row and renders the per-id failure
    ///     summary on the next request.
    ///
    /// The framework's built-in `delete` action does **not** flow
    /// through this method. It runs through the cascade-aware
    /// `/bulk_delete` route. Override `delete` semantics on the
    /// underlying [`crate::Model`] / handler layer if you need
    /// custom delete behaviour.
    ///
    /// The default implementation returns a structured error so a
    /// declared-but-unimplemented action surfaces clearly:
    ///
    /// ```ignore
    /// use std::future::Future;
    /// use std::pin::Pin;
    /// use rustio_admin::{
    ///     BulkAction, BulkActionContext, BulkActionResult, Db, ModelAdmin, Result,
    /// };
    ///
    /// # struct Loan; impl rustio_admin::AdminModel for Loan {
    /// #     const ADMIN_NAME: &'static str = ""; const DISPLAY_NAME: &'static str = "";
    /// #     const SINGULAR_NAME: &'static str = ""; const FIELDS: &'static [rustio_admin::AdminField] = &[];
    /// #     fn display_values(&self) -> Vec<(String, String)> { vec![] }
    /// #     fn from_form(_: &rustio_admin::FormData) -> ::std::result::Result<Self, Vec<String>> { Err(vec![]) }
    /// #     fn object_label(&self) -> String { String::new() } fn id(&self) -> i64 { 0 }
    /// #     fn values_to_update(&self) -> Vec<(&'static str, rustio_admin::Value)> { vec![] }
    /// # }
    /// impl ModelAdmin for Loan {
    ///     fn bulk_actions() -> &'static [BulkAction] {
    ///         &[BulkAction {
    ///             name: "mark_overdue",
    ///             label: "Mark overdue",
    ///             destructive: false,
    ///             confirm: true,
    ///             permission: None,
    ///         }]
    ///     }
    ///
    ///     fn execute_bulk_action<'a>(
    ///         action: &'a str,
    ///         ids: &'a [i64],
    ///         _db: &'a Db,
    ///         _ctx: &'a BulkActionContext<'a>,
    ///     ) -> Pin<Box<dyn Future<Output = Result<BulkActionResult>> + Send + 'a>> {
    ///         Box::pin(async move {
    ///             match action {
    ///                 "mark_overdue" => Ok(BulkActionResult::ok(ids.len())),
    ///                 _ => Ok(BulkActionResult::default()),
    ///             }
    ///         })
    ///     }
    /// }
    /// ```
    fn execute_bulk_action<'a>(
        action: &'a str,
        _ids: &'a [i64],
        _db: &'a crate::orm::Db,
        _ctx: &'a crate::admin::bulk::BulkActionContext<'a>,
    ) -> ::std::pin::Pin<
        ::std::boxed::Box<
            dyn ::std::future::Future<
                    Output = crate::error::Result<crate::admin::bulk::BulkActionResult>,
                > + ::std::marker::Send
                + 'a,
        >,
    > {
        let owned = action.to_string();
        Box::pin(async move {
            Err(crate::error::Error::BadRequest(format!(
                "bulk action `{owned}` has no project handler — \
                 override `ModelAdmin::execute_bulk_action` on this model"
            )))
        })
    }
}

// public:
/// One project-defined bulk action declared by
/// [`ModelAdmin::bulk_actions`]. Static metadata only — see
/// `AdminOps::execute_bulk_action` for the runtime dispatcher.
#[derive(Debug, Clone, Copy)]
pub struct BulkAction {
    /// Stable URL slug. Routed at `POST /admin/:model/bulk/:name`.
    /// Use snake_case identifiers; the framework reserves `delete`
    /// for its built-in cascade-aware delete (handled separately at
    /// `/bulk_delete`).
    pub name: &'static str,
    /// Human-readable button label. Rendered as-is in the bulk bar
    /// and on the confirmation page header.
    pub label: &'static str,
    /// `true` → render the button with the framework's destructive
    /// (red) styling. Use for actions that lose data or change state
    /// in a hard-to-undo way.
    pub destructive: bool,
    /// `true` → POST shows a confirmation page first listing every
    /// selected row; the user must click again to commit. `false` →
    /// execute on the first POST. Default in the recommended call
    /// pattern is `true` for any action a user might regret.
    pub confirm: bool,
    /// Per-action permission gate. When `Some("foo")`, the actor
    /// must additionally hold `<admin_name>.foo_<singular>` (or
    /// bypass group checks via role) on top of the model's `change`
    /// permission that the bulk route already gates. `None`
    /// inherits — the route's `change` gate is the only check.
    ///
    /// Use this to scope destructive bulk actions to a narrower set
    /// of operators than full edit access. Example: a `purge`
    /// action that wipes a year of archive rows might set
    /// `permission: Some("delete")` so only operators with the
    /// model's `delete` permission can fire it, even though
    /// `change` is enough to flip ordinary fields.
    pub permission: Option<&'static str>,
}

// public:
/// One column to sort by, with direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDir {
    Asc,
    Desc,
}

impl SortDir {
    // public:
    /// Stable SQL fragment.
    pub fn sql(self) -> &'static str {
        match self {
            SortDir::Asc => "ASC",
            SortDir::Desc => "DESC",
        }
    }
}

// public:
/// Parse one `ordering()` slice entry. `"-foo"` → (`"foo"`, Desc);
/// `"foo"` → (`"foo"`, Asc).
pub fn parse_order_spec(spec: &str) -> (String, SortDir) {
    if let Some(rest) = spec.strip_prefix('-') {
        (rest.to_string(), SortDir::Desc)
    } else {
        (spec.to_string(), SortDir::Asc)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_order_spec_handles_leading_minus() {
        assert_eq!(parse_order_spec("-id"), ("id".to_string(), SortDir::Desc));
        assert_eq!(parse_order_spec("name"), ("name".to_string(), SortDir::Asc));
    }

    #[test]
    fn sort_dir_sql_is_stable() {
        assert_eq!(SortDir::Asc.sql(), "ASC");
        assert_eq!(SortDir::Desc.sql(), "DESC");
    }

    #[test]
    fn search_index_column_default_is_none() {
        // The FTS opt-in is documented as off by default —
        // projects without a tsvector column get the ILIKE
        // path unchanged. A stub model that doesn't override
        // the method must report None so the runtime branch
        // falls through cleanly to ILIKE.
        struct Stub;
        impl crate::admin::types::AdminModel for Stub {
            const ADMIN_NAME: &'static str = "s";
            const DISPLAY_NAME: &'static str = "S";
            const SINGULAR_NAME: &'static str = "S";
            const FIELDS: &'static [crate::admin::types::AdminField] = &[];
            fn id(&self) -> i64 {
                0
            }
            fn from_form(_: &crate::http::FormData) -> std::result::Result<Self, Vec<String>> {
                Err(vec![])
            }
            fn display_values(&self) -> Vec<(String, String)> {
                vec![]
            }
            fn object_label(&self) -> String {
                String::new()
            }
            fn values_to_update(&self) -> Vec<(&'static str, crate::orm::Value)> {
                vec![]
            }
        }
        impl ModelAdmin for Stub {}
        assert_eq!(<Stub as ModelAdmin>::search_index_column(), None);
    }
}