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/// One named group of fields on the change form. The framework's
45/// default heuristic in [`super::render::form_ctx`] groups by name
46/// (Default / System / Advanced); a project that wants explicit
47/// section ordering returns a non-empty `&'static [Fieldset]` from
48/// [`ModelAdmin::fieldsets`] and the renderer honours that instead.
49#[derive(Debug, Clone)]
50pub struct Fieldset {
51    pub title: &'static str,
52    pub fields: &'static [&'static str],
53}
54
55/// Django-style customisation surface for a registered admin model.
56///
57/// Every type that implements [`AdminModel`] gets a default impl via
58/// the blanket below. Override the methods you care about; everything
59/// else inherits sensible defaults.
60pub trait ModelAdmin: AdminModel {
61    /// Columns shown on the list page, in order. Default: every
62    /// field declared on `AdminModel::FIELDS`.
63    ///
64    /// Returning `&[]` means "use the model's full field list" — the
65    /// list page expands the empty default into `M::FIELDS`. Any
66    /// non-empty slice replaces the defaults verbatim.
67    fn list_display() -> &'static [&'static str] {
68        &[]
69    }
70
71    /// Columns offered as filter chips in the sidebar. Default: none.
72    fn list_filter() -> &'static [&'static str] {
73        &[]
74    }
75
76    /// Columns searched by the list-page search box (case-insensitive
77    /// substring match). Default: none.
78    fn search_fields() -> &'static [&'static str] {
79        &[]
80    }
81
82    /// Default ordering. `-foo` for `foo DESC`, `foo` for `foo ASC`.
83    /// Multiple entries → multi-column ORDER BY in slice order.
84    /// Default: `["-id"]` (newest first).
85    fn ordering() -> &'static [&'static str] {
86        &["-id"]
87    }
88
89    /// Rows per page on the list view. Default: 50.
90    fn list_per_page() -> usize {
91        50
92    }
93
94    /// Read-only fields on the change form. Default: none.
95    fn readonly_fields() -> &'static [&'static str] {
96        &[]
97    }
98
99    /// Field grouping on the change form. Default: empty — fall back
100    /// to the framework heuristic (`Default` / `System` / `Advanced`).
101    fn fieldsets() -> &'static [Fieldset] {
102        &[]
103    }
104}
105
106/// One column to sort by, with direction.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum SortDir {
109    Asc,
110    Desc,
111}
112
113impl SortDir {
114    /// Stable SQL fragment.
115    pub fn sql(self) -> &'static str {
116        match self {
117            SortDir::Asc => "ASC",
118            SortDir::Desc => "DESC",
119        }
120    }
121}
122
123/// Parse one `ordering()` slice entry. `"-foo"` → (`"foo"`, Desc);
124/// `"foo"` → (`"foo"`, Asc).
125pub fn parse_order_spec(spec: &str) -> (String, SortDir) {
126    if let Some(rest) = spec.strip_prefix('-') {
127        (rest.to_string(), SortDir::Desc)
128    } else {
129        (spec.to_string(), SortDir::Asc)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn parse_order_spec_handles_leading_minus() {
139        assert_eq!(parse_order_spec("-id"), ("id".to_string(), SortDir::Desc));
140        assert_eq!(parse_order_spec("name"), ("name".to_string(), SortDir::Asc));
141    }
142
143    #[test]
144    fn sort_dir_sql_is_stable() {
145        assert_eq!(SortDir::Asc.sql(), "ASC");
146        assert_eq!(SortDir::Desc.sql(), "DESC");
147    }
148}