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/// Django-style customisation surface for a registered admin model.
58///
59/// Every type that implements [`AdminModel`] gets a default impl via
60/// the blanket below. Override the methods you care about; everything
61/// else inherits sensible defaults.
62pub trait ModelAdmin: AdminModel {
63    /// Columns shown on the list page, in order. Default: every
64    /// field declared on `AdminModel::FIELDS`.
65    ///
66    /// Returning `&[]` means "use the model's full field list" — the
67    /// list page expands the empty default into `M::FIELDS`. Any
68    /// non-empty slice replaces the defaults verbatim.
69    fn list_display() -> &'static [&'static str] {
70        &[]
71    }
72
73    /// Columns offered as filter chips in the sidebar. Default: none.
74    fn list_filter() -> &'static [&'static str] {
75        &[]
76    }
77
78    /// Columns searched by the list-page search box (case-insensitive
79    /// substring match). Default: none.
80    fn search_fields() -> &'static [&'static str] {
81        &[]
82    }
83
84    /// Default ordering. `-foo` for `foo DESC`, `foo` for `foo ASC`.
85    /// Multiple entries → multi-column ORDER BY in slice order.
86    /// Default: `["-id"]` (newest first).
87    fn ordering() -> &'static [&'static str] {
88        &["-id"]
89    }
90
91    /// Rows per page on the list view. Default: 50.
92    fn list_per_page() -> usize {
93        50
94    }
95
96    /// Read-only fields on the change form. Default: none.
97    fn readonly_fields() -> &'static [&'static str] {
98        &[]
99    }
100
101    /// Field grouping on the change form. Default: empty — fall back
102    /// to the framework heuristic (`Default` / `System` / `Advanced`).
103    fn fieldsets() -> &'static [Fieldset] {
104        &[]
105    }
106
107    /// Custom bulk actions surfaced as extra buttons in the list-view
108    /// bulk bar (next to the framework's built-in Delete). Default:
109    /// none.
110    ///
111    /// `BulkAction` is metadata only — pair this method with an
112    /// [`ModelAdmin::execute_bulk_action`] override that matches on
113    /// `name` and applies the work. The framework's default
114    /// dispatcher returns a clear `BadRequest` for any name it
115    /// doesn't recognise, so a forgotten implementation surfaces as
116    /// an error page rather than a silent no-op.
117    fn bulk_actions() -> &'static [BulkAction] {
118        &[]
119    }
120
121    /// Run a project-defined bulk action against `ids`. Called once
122    /// per `POST /admin/:model/bulk/:name` submission with the full
123    /// id list — the implementation chooses between a single bulk
124    /// SQL update and a per-row loop.
125    ///
126    /// The framework wraps this call with one [`audit::record`]
127    /// emission per submission (using `BulkActionContext.actor`,
128    /// `correlation_id`, and the `BulkActionResult` outcome).
129    /// Projects don't need to audit the dispatch envelope themselves;
130    /// any business-level audit emissions inside the action body are
131    /// still the project's call.
132    ///
133    /// Two channels for "something went wrong":
134    ///
135    ///   - **Action itself failed** — return `Err(...)`. The framework
136    ///     surfaces it as a 4xx/5xx page and still writes an audit row
137    ///     for the attempt.
138    ///   - **Some rows failed** — return `Ok(BulkActionResult)` with
139    ///     a populated `failed` list. The framework records a
140    ///     partial-success audit row and renders the per-id failure
141    ///     summary on the next request.
142    ///
143    /// The framework's built-in `delete` action does **not** flow
144    /// through this method. It runs through the cascade-aware
145    /// `/bulk_delete` route. Override `delete` semantics on the
146    /// underlying [`crate::Model`] / handler layer if you need
147    /// custom delete behaviour.
148    ///
149    /// The default implementation returns a structured error so a
150    /// declared-but-unimplemented action surfaces clearly:
151    ///
152    /// ```ignore
153    /// use std::future::Future;
154    /// use std::pin::Pin;
155    /// use rustio_admin::{
156    ///     BulkAction, BulkActionContext, BulkActionResult, Db, ModelAdmin, Result,
157    /// };
158    ///
159    /// # struct Loan; impl rustio_admin::AdminModel for Loan {
160    /// #     const ADMIN_NAME: &'static str = ""; const DISPLAY_NAME: &'static str = "";
161    /// #     const SINGULAR_NAME: &'static str = ""; const FIELDS: &'static [rustio_admin::AdminField] = &[];
162    /// #     fn display_values(&self) -> Vec<(String, String)> { vec![] }
163    /// #     fn from_form(_: &rustio_admin::FormData) -> ::std::result::Result<Self, Vec<String>> { Err(vec![]) }
164    /// #     fn object_label(&self) -> String { String::new() } fn id(&self) -> i64 { 0 }
165    /// #     fn values_to_update(&self) -> Vec<(&'static str, rustio_admin::Value)> { vec![] }
166    /// # }
167    /// impl ModelAdmin for Loan {
168    ///     fn bulk_actions() -> &'static [BulkAction] {
169    ///         &[BulkAction {
170    ///             name: "mark_overdue",
171    ///             label: "Mark overdue",
172    ///             destructive: false,
173    ///             confirm: true,
174    ///         }]
175    ///     }
176    ///
177    ///     fn execute_bulk_action<'a>(
178    ///         action: &'a str,
179    ///         ids: &'a [i64],
180    ///         _db: &'a Db,
181    ///         _ctx: &'a BulkActionContext<'a>,
182    ///     ) -> Pin<Box<dyn Future<Output = Result<BulkActionResult>> + Send + 'a>> {
183    ///         Box::pin(async move {
184    ///             match action {
185    ///                 "mark_overdue" => Ok(BulkActionResult::ok(ids.len())),
186    ///                 _ => Ok(BulkActionResult::default()),
187    ///             }
188    ///         })
189    ///     }
190    /// }
191    /// ```
192    fn execute_bulk_action<'a>(
193        action: &'a str,
194        _ids: &'a [i64],
195        _db: &'a crate::orm::Db,
196        _ctx: &'a crate::admin::bulk::BulkActionContext<'a>,
197    ) -> ::std::pin::Pin<
198        ::std::boxed::Box<
199            dyn ::std::future::Future<
200                    Output = crate::error::Result<crate::admin::bulk::BulkActionResult>,
201                > + ::std::marker::Send
202                + 'a,
203        >,
204    > {
205        let owned = action.to_string();
206        Box::pin(async move {
207            Err(crate::error::Error::BadRequest(format!(
208                "bulk action `{owned}` has no project handler — \
209                 override `ModelAdmin::execute_bulk_action` on this model"
210            )))
211        })
212    }
213}
214
215// public:
216/// One project-defined bulk action declared by
217/// [`ModelAdmin::bulk_actions`]. Static metadata only — see
218/// `AdminOps::execute_bulk_action` for the runtime dispatcher.
219#[derive(Debug, Clone, Copy)]
220pub struct BulkAction {
221    /// Stable URL slug. Routed at `POST /admin/:model/bulk/:name`.
222    /// Use snake_case identifiers; the framework reserves `delete`
223    /// for its built-in cascade-aware delete (handled separately at
224    /// `/bulk_delete`).
225    pub name: &'static str,
226    /// Human-readable button label. Rendered as-is in the bulk bar
227    /// and on the confirmation page header.
228    pub label: &'static str,
229    /// `true` → render the button with the framework's destructive
230    /// (red) styling. Use for actions that lose data or change state
231    /// in a hard-to-undo way.
232    pub destructive: bool,
233    /// `true` → POST shows a confirmation page first listing every
234    /// selected row; the user must click again to commit. `false` →
235    /// execute on the first POST. Default in the recommended call
236    /// pattern is `true` for any action a user might regret.
237    pub confirm: bool,
238}
239
240// public:
241/// One column to sort by, with direction.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum SortDir {
244    Asc,
245    Desc,
246}
247
248impl SortDir {
249    // public:
250    /// Stable SQL fragment.
251    pub fn sql(self) -> &'static str {
252        match self {
253            SortDir::Asc => "ASC",
254            SortDir::Desc => "DESC",
255        }
256    }
257}
258
259// public:
260/// Parse one `ordering()` slice entry. `"-foo"` → (`"foo"`, Desc);
261/// `"foo"` → (`"foo"`, Asc).
262pub fn parse_order_spec(spec: &str) -> (String, SortDir) {
263    if let Some(rest) = spec.strip_prefix('-') {
264        (rest.to_string(), SortDir::Desc)
265    } else {
266        (spec.to_string(), SortDir::Asc)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn parse_order_spec_handles_leading_minus() {
276        assert_eq!(parse_order_spec("-id"), ("id".to_string(), SortDir::Desc));
277        assert_eq!(parse_order_spec("name"), ("name".to_string(), SortDir::Asc));
278    }
279
280    #[test]
281    fn sort_dir_sql_is_stable() {
282        assert_eq!(SortDir::Asc.sql(), "ASC");
283        assert_eq!(SortDir::Desc.sql(), "DESC");
284    }
285}