Skip to main content

rustio_admin/admin/
modeladmin.rs

1//! `ModelAdmin` — Django-style customisation surface.
2//!
3//! Every model that ships through `Admin::model::<M>()` must
4//! implement `ModelAdmin`. The trait defines defaults for every
5//! method, so a project that wants standard behaviour writes a one-
6//! line empty impl:
7//!
8//! ```ignore
9//! use rustio_admin::ModelAdmin;
10//!
11//! impl ModelAdmin for Course {}            // accept every default
12//! ```
13//!
14//! Override only the methods you care about; the rest inherit the
15//! trait defaults:
16//!
17//! ```ignore
18//! impl ModelAdmin for Course {
19//!     fn list_display() -> &'static [&'static str] {
20//!         &["code", "title", "credit_hours", "is_published"]
21//!     }
22//!     fn list_filter()  -> &'static [&'static str] { &["status", "level"] }
23//!     fn search_fields() -> &'static [&'static str] { &["code", "title"] }
24//!     fn ordering()     -> &'static [&'static str] { &["code"] }
25//! }
26//! ```
27//!
28//! The values are captured into [`super::AdminEntry`] at registration
29//! time. The runtime reads them straight from the entry — no
30//! per-request virtual dispatch beyond the existing `dyn AdminOps`.
31//!
32//! ### Why no blanket impl?
33//!
34//! An earlier draft shipped `impl<T: AdminModel> ModelAdmin for T {}`
35//! so every derived `AdminModel` would auto-pick-up the defaults.
36//! That collides with Rust's coherence rules — without
37//! `feature(specialization)` (nightly-only), a blanket impl forbids
38//! any per-type impl, which would block project overrides entirely.
39//! The opt-in `impl ModelAdmin for X {}` is the standard stable-Rust
40//! pattern (serde, axum, std).
41
42use super::AdminModel;
43
44// public:
45/// One named group of fields on the change form. The framework's
46/// default heuristic in [`super::render::form_ctx`] groups by name
47/// (Default / System / Advanced); a project that wants explicit
48/// section ordering returns a non-empty `&'static [Fieldset]` from
49/// [`ModelAdmin::fieldsets`] and the renderer honours that instead.
50#[derive(Debug, Clone)]
51pub struct Fieldset {
52    pub title: &'static str,
53    pub fields: &'static [&'static str],
54}
55
56// public:
57/// One related-children section to render below a parent model's
58/// edit form. v1 surface: read-only listing of up to `max_rows`
59/// children matching `<target.fk_field> = <parent.id>`, each row
60/// a click-through to its own edit page, with an "Add new …"
61/// link that lands the operator on the child's new form (the
62/// operator fills the parent FK manually for now).
63///
64/// Project authors declare these on the parent via
65/// [`ModelAdmin::inlines`]:
66///
67/// ```ignore
68/// fn inlines() -> &'static [Inline] {
69///     &[Inline {
70///         target_model: "Appointment",
71///         fk_field: "patient_id",
72///         label: Some("Appointments"),
73///         max_rows: 50,
74///     }]
75/// }
76/// ```
77///
78/// `target_model` must match a registered admin entry's
79/// `SINGULAR_NAME`. `fk_field` is the column on the child that
80/// holds the parent's id. A typo in either name silently renders
81/// an empty section.
82#[derive(Debug, Clone)]
83pub struct Inline {
84    pub target_model: &'static str,
85    pub fk_field: &'static str,
86    /// Section title. `None` → fall back to the target model's
87    /// `display_name`.
88    pub label: Option<&'static str>,
89    /// Cap how many children are fetched + rendered. Operators
90    /// who need to see the rest follow a "…and N more" link to
91    /// the target's list page pre-filtered to this parent.
92    pub max_rows: usize,
93    /// Column on the target whose value is rendered as each
94    /// inline row's clickable label. `None` falls through the
95    /// framework's display-field ladder (`name → title →
96    /// full_name → email`) and finally to `#<id>`. Set this for
97    /// child models without a natural-name column (e.g.
98    /// `Appointment.status` or `Loan.borrowed_at`).
99    pub display_field: Option<&'static str>,
100}
101
102// public:
103/// One validation failure attached to a project-driven `validate`
104/// call on [`ModelAdmin`]. Either targets a specific field (rendered
105/// inline next to its input) or surfaces globally in the form's
106/// error banner.
107///
108/// Plain owned struct — `Send + Sync` so a `Vec<FieldValidationError>`
109/// can cross await points freely.
110#[derive(Debug, Clone)]
111pub struct FieldValidationError {
112    /// `Some(name)` routes the error to the matching field on the
113    /// form (rendered next to that input with the existing inline-
114    /// error styling). `None` lands the message in the form-level
115    /// banner — appropriate for cross-field rules ("end date must
116    /// not be before start date" could attach to either, but a
117    /// "this booking conflicts with another one" message has no
118    /// single owning field).
119    pub field: Option<&'static str>,
120    /// User-facing message, one sentence. Should not include the
121    /// field's own label — the renderer adds it.
122    pub message: String,
123}
124
125impl FieldValidationError {
126    // public:
127    /// Construct an error attached to one field. `field` must
128    /// match an `AdminField.name` on the model — otherwise the
129    /// renderer falls through to the global banner.
130    pub fn field(field: &'static str, message: impl Into<String>) -> Self {
131        Self {
132            field: Some(field),
133            message: message.into(),
134        }
135    }
136
137    // public:
138    /// Construct a global / cross-field error. Renders in the form
139    /// banner without a field anchor.
140    pub fn global(message: impl Into<String>) -> Self {
141        Self {
142            field: None,
143            message: message.into(),
144        }
145    }
146}
147
148// public:
149/// Django-style customisation surface for a registered admin model.
150///
151/// Every type that implements [`AdminModel`] gets a default impl via
152/// the blanket below. Override the methods you care about; everything
153/// else inherits sensible defaults.
154pub trait ModelAdmin: AdminModel {
155    /// Columns shown on the list page, in order. Default: every
156    /// field declared on `AdminModel::FIELDS`.
157    ///
158    /// Returning `&[]` means "use the model's full field list" — the
159    /// list page expands the empty default into `M::FIELDS`. Any
160    /// non-empty slice replaces the defaults verbatim.
161    fn list_display() -> &'static [&'static str] {
162        &[]
163    }
164
165    /// Columns offered as filter chips in the sidebar. Default: none.
166    fn list_filter() -> &'static [&'static str] {
167        &[]
168    }
169
170    /// Columns searched by the list-page search box (case-insensitive
171    /// substring match). Default: none.
172    fn search_fields() -> &'static [&'static str] {
173        &[]
174    }
175
176    /// Name of a Postgres `tsvector` column to use for full-text
177    /// search instead of the framework's default `ILIKE` OR-loop
178    /// across `search_fields()`. When `Some("search_vector")`, the
179    /// list-page WHERE clause switches to
180    /// `<col> @@ websearch_to_tsquery('english', $N)` — operators
181    /// keep typing in the same search box; the index does the
182    /// work. Maintain the tsvector yourself (a generated column
183    /// or a trigger; the framework doesn't write to it). Default:
184    /// `None` (the existing ILIKE path).
185    ///
186    /// ```ignore
187    /// // 1. Add a generated tsvector column in a migration:
188    /// //    ALTER TABLE posts ADD COLUMN search_vector tsvector
189    /// //      GENERATED ALWAYS AS (to_tsvector('english',
190    /// //        coalesce(title,'') || ' ' || coalesce(body,''))) STORED;
191    /// //    CREATE INDEX posts_search_idx ON posts USING gin(search_vector);
192    /// //
193    /// // 2. Opt in from ModelAdmin:
194    /// fn search_index_column() -> Option<&'static str> {
195    ///     Some("search_vector")
196    /// }
197    /// ```
198    fn search_index_column() -> Option<&'static str> {
199        None
200    }
201
202    /// Default ordering. `-foo` for `foo DESC`, `foo` for `foo ASC`.
203    /// Multiple entries → multi-column ORDER BY in slice order.
204    /// Default: `["-id"]` (newest first).
205    fn ordering() -> &'static [&'static str] {
206        &["-id"]
207    }
208
209    /// Rows per page on the list view. Default: 50.
210    fn list_per_page() -> usize {
211        50
212    }
213
214    /// Field names rendered as `disabled` on the change form. The
215    /// browser does not submit disabled fields, so the framework
216    /// transparently re-injects the existing row value into the form
217    /// before calling `from_form` — readonly columns are persisted
218    /// unchanged. Applies to **edit only**; on the add form the
219    /// listed fields stay editable so the project can supply their
220    /// initial value. Default: none.
221    fn readonly_fields() -> &'static [&'static str] {
222        &[]
223    }
224
225    /// Related-children sections rendered below the change form
226    /// (the parent edit page). Default: empty — no inlines.
227    /// Each entry references a registered child model by its
228    /// `SINGULAR_NAME` and names the FK column on the child that
229    /// points at the parent. The framework fetches up to
230    /// `max_rows` matching rows, renders them as a table of
231    /// click-through edit links + a per-row Delete link, and
232    /// appends "Add new {child}" / "View all" affordances.
233    ///
234    /// **v1 surface — read-only.** Inline rows are display +
235    /// click-through; in-page editing of inline rows is a future
236    /// iteration. Adding a child still routes through the
237    /// child's normal new-form; the parent FK is filled by the
238    /// operator. See [`Inline`].
239    fn inlines() -> &'static [Inline] {
240        &[]
241    }
242
243    /// Field grouping on the change form. Default: empty — fall back
244    /// to the framework's name heuristic (`Default` / `System` /
245    /// `Advanced`). A non-empty return replaces the heuristic
246    /// entirely: each [`Fieldset`] renders as one titled section in
247    /// the order returned, and the fields inside it render in the
248    /// order listed. Fields that exist on the model but are not
249    /// referenced by any fieldset get appended to a trailing "Other"
250    /// section so the form stays complete; misspelt names with no
251    /// matching field are silently dropped.
252    fn fieldsets() -> &'static [Fieldset] {
253        &[]
254    }
255
256    /// Per-row business-rule validation, called by the framework
257    /// after [`AdminModel::from_form`] succeeds but BEFORE the SQL
258    /// insert / update fires. Default is `Ok(())` — projects opt in
259    /// by overriding. Synchronous: validation can't query the DB;
260    /// database-shape errors (UNIQUE violations, FK gone) flow
261    /// through the existing constraint-translation path
262    /// automatically and aren't this hook's concern.
263    ///
264    /// Returning `Err` short-circuits both create and update — the
265    /// row never reaches Postgres. Each [`FieldValidationError`]
266    /// either attaches to a specific field (rendered inline next
267    /// to that input, with `aria-invalid`) or surfaces as a global
268    /// rule violation (rendered in the form's error banner).
269    ///
270    /// Common shape:
271    ///
272    /// ```ignore
273    /// fn validate(model: &Self) -> std::result::Result<(), Vec<FieldValidationError>> {
274    ///     let mut errs = Vec::new();
275    ///     if model.start_date > model.end_date {
276    ///         errs.push(FieldValidationError::field(
277    ///             "end_date",
278    ///             "End date must not be before the start date.",
279    ///         ));
280    ///     }
281    ///     if errs.is_empty() { Ok(()) } else { Err(errs) }
282    /// }
283    /// ```
284    fn validate(_model: &Self) -> std::result::Result<(), Vec<FieldValidationError>> {
285        Ok(())
286    }
287
288    /// Custom bulk actions surfaced as extra buttons in the list-view
289    /// bulk bar (next to the framework's built-in Delete). Default:
290    /// none.
291    ///
292    /// `BulkAction` is metadata only — pair this method with an
293    /// [`ModelAdmin::execute_bulk_action`] override that matches on
294    /// `name` and applies the work. The framework's default
295    /// dispatcher returns a clear `BadRequest` for any name it
296    /// doesn't recognise, so a forgotten implementation surfaces as
297    /// an error page rather than a silent no-op.
298    fn bulk_actions() -> &'static [BulkAction] {
299        &[]
300    }
301
302    /// Run a project-defined bulk action against `ids`. Called once
303    /// per `POST /admin/:model/bulk/:name` submission with the full
304    /// id list — the implementation chooses between a single bulk
305    /// SQL update and a per-row loop.
306    ///
307    /// The framework wraps this call with one [`audit::record`]
308    /// emission per submission (using `BulkActionContext.actor`,
309    /// `correlation_id`, and the `BulkActionResult` outcome).
310    /// Projects don't need to audit the dispatch envelope themselves;
311    /// any business-level audit emissions inside the action body are
312    /// still the project's call.
313    ///
314    /// Two channels for "something went wrong":
315    ///
316    ///   - **Action itself failed** — return `Err(...)`. The framework
317    ///     surfaces it as a 4xx/5xx page and still writes an audit row
318    ///     for the attempt.
319    ///   - **Some rows failed** — return `Ok(BulkActionResult)` with
320    ///     a populated `failed` list. The framework records a
321    ///     partial-success audit row and renders the per-id failure
322    ///     summary on the next request.
323    ///
324    /// The framework's built-in `delete` action does **not** flow
325    /// through this method. It runs through the cascade-aware
326    /// `/bulk_delete` route. Override `delete` semantics on the
327    /// underlying [`crate::Model`] / handler layer if you need
328    /// custom delete behaviour.
329    ///
330    /// The default implementation returns a structured error so a
331    /// declared-but-unimplemented action surfaces clearly:
332    ///
333    /// ```ignore
334    /// use std::future::Future;
335    /// use std::pin::Pin;
336    /// use rustio_admin::{
337    ///     BulkAction, BulkActionContext, BulkActionResult, Db, ModelAdmin, Result,
338    /// };
339    ///
340    /// # struct Loan; impl rustio_admin::AdminModel for Loan {
341    /// #     const ADMIN_NAME: &'static str = ""; const DISPLAY_NAME: &'static str = "";
342    /// #     const SINGULAR_NAME: &'static str = ""; const FIELDS: &'static [rustio_admin::AdminField] = &[];
343    /// #     fn display_values(&self) -> Vec<(String, String)> { vec![] }
344    /// #     fn from_form(_: &rustio_admin::FormData) -> ::std::result::Result<Self, Vec<String>> { Err(vec![]) }
345    /// #     fn object_label(&self) -> String { String::new() } fn id(&self) -> i64 { 0 }
346    /// #     fn values_to_update(&self) -> Vec<(&'static str, rustio_admin::Value)> { vec![] }
347    /// # }
348    /// impl ModelAdmin for Loan {
349    ///     fn bulk_actions() -> &'static [BulkAction] {
350    ///         &[BulkAction {
351    ///             name: "mark_overdue",
352    ///             label: "Mark overdue",
353    ///             destructive: false,
354    ///             confirm: true,
355    ///             permission: None,
356    ///         }]
357    ///     }
358    ///
359    ///     fn execute_bulk_action<'a>(
360    ///         action: &'a str,
361    ///         ids: &'a [i64],
362    ///         _db: &'a Db,
363    ///         _ctx: &'a BulkActionContext<'a>,
364    ///     ) -> Pin<Box<dyn Future<Output = Result<BulkActionResult>> + Send + 'a>> {
365    ///         Box::pin(async move {
366    ///             match action {
367    ///                 "mark_overdue" => Ok(BulkActionResult::ok(ids.len())),
368    ///                 _ => Ok(BulkActionResult::default()),
369    ///             }
370    ///         })
371    ///     }
372    /// }
373    /// ```
374    fn execute_bulk_action<'a>(
375        action: &'a str,
376        _ids: &'a [i64],
377        _db: &'a crate::orm::Db,
378        _ctx: &'a crate::admin::bulk::BulkActionContext<'a>,
379    ) -> ::std::pin::Pin<
380        ::std::boxed::Box<
381            dyn ::std::future::Future<
382                    Output = crate::error::Result<crate::admin::bulk::BulkActionResult>,
383                > + ::std::marker::Send
384                + 'a,
385        >,
386    > {
387        let owned = action.to_string();
388        Box::pin(async move {
389            Err(crate::error::Error::BadRequest(format!(
390                "bulk action `{owned}` has no project handler — \
391                 override `ModelAdmin::execute_bulk_action` on this model"
392            )))
393        })
394    }
395}
396
397// public:
398/// One project-defined bulk action declared by
399/// [`ModelAdmin::bulk_actions`]. Static metadata only — see
400/// `AdminOps::execute_bulk_action` for the runtime dispatcher.
401#[derive(Debug, Clone, Copy)]
402pub struct BulkAction {
403    /// Stable URL slug. Routed at `POST /admin/:model/bulk/:name`.
404    /// Use snake_case identifiers; the framework reserves `delete`
405    /// for its built-in cascade-aware delete (handled separately at
406    /// `/bulk_delete`).
407    pub name: &'static str,
408    /// Human-readable button label. Rendered as-is in the bulk bar
409    /// and on the confirmation page header.
410    pub label: &'static str,
411    /// `true` → render the button with the framework's destructive
412    /// (red) styling. Use for actions that lose data or change state
413    /// in a hard-to-undo way.
414    pub destructive: bool,
415    /// `true` → POST shows a confirmation page first listing every
416    /// selected row; the user must click again to commit. `false` →
417    /// execute on the first POST. Default in the recommended call
418    /// pattern is `true` for any action a user might regret.
419    pub confirm: bool,
420    /// Per-action permission gate. When `Some("foo")`, the actor
421    /// must additionally hold `<admin_name>.foo_<singular>` (or
422    /// bypass group checks via role) on top of the model's `change`
423    /// permission that the bulk route already gates. `None`
424    /// inherits — the route's `change` gate is the only check.
425    ///
426    /// Use this to scope destructive bulk actions to a narrower set
427    /// of operators than full edit access. Example: a `purge`
428    /// action that wipes a year of archive rows might set
429    /// `permission: Some("delete")` so only operators with the
430    /// model's `delete` permission can fire it, even though
431    /// `change` is enough to flip ordinary fields.
432    pub permission: Option<&'static str>,
433}
434
435// public:
436/// One column to sort by, with direction.
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum SortDir {
439    Asc,
440    Desc,
441}
442
443impl SortDir {
444    // public:
445    /// Stable SQL fragment.
446    pub fn sql(self) -> &'static str {
447        match self {
448            SortDir::Asc => "ASC",
449            SortDir::Desc => "DESC",
450        }
451    }
452}
453
454// public:
455/// Parse one `ordering()` slice entry. `"-foo"` → (`"foo"`, Desc);
456/// `"foo"` → (`"foo"`, Asc).
457pub fn parse_order_spec(spec: &str) -> (String, SortDir) {
458    if let Some(rest) = spec.strip_prefix('-') {
459        (rest.to_string(), SortDir::Desc)
460    } else {
461        (spec.to_string(), SortDir::Asc)
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn parse_order_spec_handles_leading_minus() {
471        assert_eq!(parse_order_spec("-id"), ("id".to_string(), SortDir::Desc));
472        assert_eq!(parse_order_spec("name"), ("name".to_string(), SortDir::Asc));
473    }
474
475    #[test]
476    fn sort_dir_sql_is_stable() {
477        assert_eq!(SortDir::Asc.sql(), "ASC");
478        assert_eq!(SortDir::Desc.sql(), "DESC");
479    }
480
481    #[test]
482    fn search_index_column_default_is_none() {
483        // The FTS opt-in is documented as off by default —
484        // projects without a tsvector column get the ILIKE
485        // path unchanged. A stub model that doesn't override
486        // the method must report None so the runtime branch
487        // falls through cleanly to ILIKE.
488        struct Stub;
489        impl crate::admin::types::AdminModel for Stub {
490            const ADMIN_NAME: &'static str = "s";
491            const DISPLAY_NAME: &'static str = "S";
492            const SINGULAR_NAME: &'static str = "S";
493            const FIELDS: &'static [crate::admin::types::AdminField] = &[];
494            fn id(&self) -> i64 {
495                0
496            }
497            fn from_form(_: &crate::http::FormData) -> std::result::Result<Self, Vec<String>> {
498                Err(vec![])
499            }
500            fn display_values(&self) -> Vec<(String, String)> {
501                vec![]
502            }
503            fn object_label(&self) -> String {
504                String::new()
505            }
506            fn values_to_update(&self) -> Vec<(&'static str, crate::orm::Value)> {
507                vec![]
508            }
509        }
510        impl ModelAdmin for Stub {}
511        assert_eq!(<Stub as ModelAdmin>::search_index_column(), None);
512    }
513}