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 — the dispatcher
112 /// (`AdminOps::execute_bulk_action`) is what actually runs the
113 /// action on the selected rows. Project models that need a custom
114 /// action override `AdminOps::execute_bulk_action` to match on
115 /// `name` and apply the work; the framework's default impl
116 /// returns a clear `BadRequest` for any name it doesn't recognise,
117 /// so a forgotten implementation surfaces as an error page rather
118 /// than a silent no-op.
119 fn bulk_actions() -> &'static [BulkAction] {
120 &[]
121 }
122}
123
124// public:
125/// One project-defined bulk action declared by
126/// [`ModelAdmin::bulk_actions`]. Static metadata only — see
127/// `AdminOps::execute_bulk_action` for the runtime dispatcher.
128#[derive(Debug, Clone, Copy)]
129pub struct BulkAction {
130 /// Stable URL slug. Routed at `POST /admin/:model/bulk/:name`.
131 /// Use snake_case identifiers; the framework reserves `delete`
132 /// for its built-in cascade-aware delete (handled separately at
133 /// `/bulk_delete`).
134 pub name: &'static str,
135 /// Human-readable button label. Rendered as-is in the bulk bar
136 /// and on the confirmation page header.
137 pub label: &'static str,
138 /// `true` → render the button with the framework's destructive
139 /// (red) styling. Use for actions that lose data or change state
140 /// in a hard-to-undo way.
141 pub destructive: bool,
142 /// `true` → POST shows a confirmation page first listing every
143 /// selected row; the user must click again to commit. `false` →
144 /// execute on the first POST. Default in the recommended call
145 /// pattern is `true` for any action a user might regret.
146 pub confirm: bool,
147}
148
149// public:
150/// One column to sort by, with direction.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum SortDir {
153 Asc,
154 Desc,
155}
156
157impl SortDir {
158 // public:
159 /// Stable SQL fragment.
160 pub fn sql(self) -> &'static str {
161 match self {
162 SortDir::Asc => "ASC",
163 SortDir::Desc => "DESC",
164 }
165 }
166}
167
168// public:
169/// Parse one `ordering()` slice entry. `"-foo"` → (`"foo"`, Desc);
170/// `"foo"` → (`"foo"`, Asc).
171pub fn parse_order_spec(spec: &str) -> (String, SortDir) {
172 if let Some(rest) = spec.strip_prefix('-') {
173 (rest.to_string(), SortDir::Desc)
174 } else {
175 (spec.to_string(), SortDir::Asc)
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn parse_order_spec_handles_leading_minus() {
185 assert_eq!(parse_order_spec("-id"), ("id".to_string(), SortDir::Desc));
186 assert_eq!(parse_order_spec("name"), ("name".to_string(), SortDir::Asc));
187 }
188
189 #[test]
190 fn sort_dir_sql_is_stable() {
191 assert_eq!(SortDir::Asc.sql(), "ASC");
192 assert_eq!(SortDir::Desc.sql(), "DESC");
193 }
194}