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}