Skip to main content

rustio_admin/admin/
types.rs

1//! The admin's data vocabulary. Kept separate from rendering and
2//! handlers so changes here ripple out predictably.
3
4// `for_testing[_failing_list]` + the PanicOps/FailingOps fixtures
5// are part of the admin's test surface but no in-tree test exercises
6// them yet (the legacy admin/macro_tests etc. land in a follow-up).
7// Keep them gated behind cfg(test) elsewhere; allow dead inside that
8// gate.
9use std::future::Future;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use crate::error::Result;
14use crate::http::FormData;
15use crate::orm::{Db, Value};
16
17pub(crate) type CreateResult<'a> =
18    Pin<Box<dyn Future<Output = Result<std::result::Result<i64, Vec<String>>>> + Send + 'a>>;
19
20pub(crate) type UpdateResult<'a> =
21    Pin<Box<dyn Future<Output = Result<std::result::Result<(), Vec<String>>>> + Send + 'a>>;
22
23// ---------------------------------------------------------------------------
24// User profile extension API
25// ---------------------------------------------------------------------------
26
27/// One labeled section rendered in the project-extension area of the
28/// built-in user profile page (admin/user_view.html — `{% block
29/// project_user_fields %}`). A project's extension closure returns
30/// `Vec<UserProfileSection>` so it can contribute multiple disjoint
31/// areas in a single registration.
32#[derive(Debug, Clone, serde::Serialize)]
33pub struct UserProfileSection {
34    pub label: String,
35    pub rows: Vec<UserProfileRow>,
36}
37
38/// One key-value row inside a [`UserProfileSection`]. Both fields are
39/// `String` so projects can format whatever shape they need. Rendered
40/// escaped — pass plain text; for arbitrary HTML, projects override
41/// the template block instead.
42#[derive(Debug, Clone, serde::Serialize)]
43pub struct UserProfileRow {
44    pub label: String,
45    pub value: String,
46}
47
48/// The boxed-closure shape stored on `Admin`. `pub(crate)` because
49/// projects use the generic [`Admin::user_profile_extension`] builder
50/// method and never have to name this directly.
51pub(crate) type UserProfileExtensionFn =
52    Arc<dyn Fn(Db, crate::auth::UserProfile) -> UserProfileExtensionFuture + Send + Sync + 'static>;
53
54pub(crate) type UserProfileExtensionFuture =
55    Pin<Box<dyn Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static>>;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum FieldType {
60    I32,
61    I64,
62    Bool,
63    String,
64    DateTime,
65    OptionalI64,
66    OptionalString,
67    OptionalDateTime,
68}
69
70impl FieldType {
71    pub fn widget(&self) -> &'static str {
72        match self {
73            FieldType::Bool => "checkbox",
74            FieldType::DateTime | FieldType::OptionalDateTime => "datetime",
75            FieldType::I32 | FieldType::I64 | FieldType::OptionalI64 => "number",
76            FieldType::String | FieldType::OptionalString => "text",
77        }
78    }
79
80    pub fn nullable(&self) -> bool {
81        matches!(
82            self,
83            FieldType::OptionalI64 | FieldType::OptionalString | FieldType::OptionalDateTime
84        )
85    }
86}
87
88#[derive(Debug, Clone)]
89pub struct AdminField {
90    pub name: &'static str,
91    pub label: &'static str,
92    pub field_type: FieldType,
93    pub editable: bool,
94    pub relation: Option<AdminRelation>,
95    /// Closed list of allowed string values for this field. When
96    /// `Some`, the form layer renders a `<select>` with one option per
97    /// entry. The values double as labels (raw, not humanised) per
98    /// the "no invented content" rule.
99    pub choices: Option<&'static [&'static str]>,
100}
101
102#[derive(Debug, Clone)]
103pub struct AdminRelation {
104    pub target_model: &'static str,
105    pub display_field: Option<&'static str>,
106    /// `true` for many-to-many relations (form renders
107    /// `<select multiple>`), `false` for the default belongs-to
108    /// (single `<select>`). Macro emits `false`; consumers that want
109    /// M2M behaviour must hand-set this until the macro learns a
110    /// `#[rustio(many_to_many)]` attribute.
111    pub multi: bool,
112}
113
114/// What the `#[derive(RustioAdmin)]` macro produces for each struct.
115pub trait AdminModel: Send + Sync + 'static {
116    const ADMIN_NAME: &'static str;
117    const DISPLAY_NAME: &'static str;
118    const SINGULAR_NAME: &'static str;
119    const FIELDS: &'static [AdminField];
120
121    /// Render one row for the list page (column → display string).
122    fn display_values(&self) -> Vec<(String, String)>;
123
124    /// Populate a new instance from an HTTP form. Returns a list of
125    /// validation errors if anything was wrong.
126    fn from_form(form: &FormData) -> std::result::Result<Self, Vec<String>>
127    where
128        Self: Sized;
129
130    /// A stable label for one instance (used on the delete confirm page).
131    fn object_label(&self) -> String;
132
133    fn id(&self) -> i64;
134
135    fn values_to_update(&self) -> Vec<(&'static str, Value)>;
136}
137
138/// Runtime metadata about one admin-registered model. Captures both
139/// the [`AdminModel`] static surface and the [`super::ModelAdmin`]
140/// customisation values at registration time, so handlers read every
141/// per-model knob from this struct instead of re-resolving traits.
142pub struct AdminEntry {
143    pub admin_name: &'static str,
144    pub display_name: &'static str,
145    pub singular_name: &'static str,
146    /// SQL table name. For user-registered models this is `<M as Model>::TABLE`;
147    /// for the synthetic core User entry it's `"rustio_users"`.
148    pub table: &'static str,
149    pub fields: &'static [AdminField],
150    /// `true` only for framework-owned entries (currently just `User`).
151    pub core: bool,
152    /// `ModelAdmin::list_display()`. Empty → use every column on
153    /// `fields`; non-empty → use exactly the listed names in order.
154    pub list_display: &'static [&'static str],
155    /// `ModelAdmin::list_filter()`. Empty by default.
156    pub list_filter: &'static [&'static str],
157    /// `ModelAdmin::search_fields()`. Empty by default.
158    pub search_fields: &'static [&'static str],
159    /// `ModelAdmin::ordering()`. Strings parsed via
160    /// [`super::modeladmin::parse_order_spec`].
161    pub ordering: &'static [&'static str],
162    /// `ModelAdmin::list_per_page()`. Default 50.
163    pub list_per_page: usize,
164    /// `ModelAdmin::readonly_fields()`. Empty by default.
165    pub readonly_fields: &'static [&'static str],
166    /// `ModelAdmin::fieldsets()`. Empty → fall back to the
167    /// framework's name-heuristic grouping.
168    pub fieldsets: &'static [super::modeladmin::Fieldset],
169    pub(crate) ops: Arc<dyn AdminOps>,
170}
171
172/// Per-request options for [`AdminOps::list`]. Empty / `None` fields
173/// mean "framework default": no ordering override falls back to
174/// `id DESC` inside the runtime, no filters skips the WHERE clause,
175/// no limit fetches every row.
176#[derive(Debug, Clone, Default)]
177pub struct ListOpts {
178    /// Validated `(column, dir)` pairs to apply as `ORDER BY`. The
179    /// column name is bound to the model's `M::COLUMNS` set inside
180    /// the runtime, so callers can pass user-supplied names without
181    /// SQL-injection risk.
182    pub ordering: Vec<(String, super::modeladmin::SortDir)>,
183    /// `(column, value)` pairs applied as `WHERE col::text = $N`.
184    /// Cast to text so the comparison matches the same string-shape
185    /// semantics the in-memory pre-P10 filter used for bool / int /
186    /// timestamp columns.
187    pub filters: Vec<(String, String)>,
188    /// Free-text search: `(term, columns)`. The runtime emits
189    /// `WHERE (col1::text ILIKE $N OR col2::text ILIKE $N OR …)`
190    /// with `$N = '%term%'`. An empty `term` or empty `columns`
191    /// leaves the WHERE alone.
192    pub search: Option<(String, Vec<String>)>,
193    /// `LIMIT $N` for the data query. The COUNT(*) query never
194    /// applies it. `None` → no limit.
195    pub limit: Option<i64>,
196    /// `OFFSET $N` for the data query. `None` or `Some(0)` → no offset.
197    pub offset: Option<i64>,
198}
199
200/// Result of [`AdminOps::list`]: the requested page plus the total
201/// row count under the same WHERE clause (so handlers can render
202/// pagination footers without a separate query).
203#[derive(Debug, Default)]
204pub struct ListPage {
205    pub rows: Vec<ListRow>,
206    pub total: i64,
207}
208
209/// Type-erased CRUD operations. The `Admin::model::<M>()` call captures
210/// a concrete `M: AdminModel + Model` and hides it behind this trait so
211/// the router can treat every model uniformly. The single live impl is
212/// [`super::ops::ConcreteOps<M>`].
213pub(crate) trait AdminOps: Send + Sync {
214    fn list<'a>(
215        &'a self,
216        db: &'a Db,
217        opts: ListOpts,
218    ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>>;
219
220    fn find_row<'a>(
221        &'a self,
222        db: &'a Db,
223        id: i64,
224    ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>>;
225
226    fn create<'a>(&'a self, db: &'a Db, form: &'a FormData) -> CreateResult<'a>;
227
228    fn update<'a>(&'a self, db: &'a Db, id: i64, form: &'a FormData) -> UpdateResult<'a>;
229
230    fn delete<'a>(
231        &'a self,
232        db: &'a Db,
233        id: i64,
234    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
235
236    fn object_label<'a>(
237        &'a self,
238        db: &'a Db,
239        id: i64,
240    ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>>;
241}
242
243/// A row as shown on the list page.
244#[derive(Debug)]
245pub struct ListRow {
246    pub id: i64,
247    pub cells: Vec<String>,
248}
249
250/// The raw field values used to pre-fill the edit form.
251#[derive(Debug)]
252pub struct EditRow {
253    #[allow(dead_code)]
254    pub id: i64,
255    pub values: Vec<(String, String)>,
256}
257
258/// Per-project admin branding. Defaults are RustIO-flavoured;
259/// projects override via [`Admin::site_branding`].
260#[derive(Clone, Debug)]
261pub struct SiteBranding {
262    pub site_title: String,
263    pub site_header: String,
264    pub index_title: String,
265    pub footer_copyright: String,
266    /// DNS-shape string available to project handlers; not surfaced in
267    /// any framework template.
268    pub domain: String,
269}
270
271impl Default for SiteBranding {
272    fn default() -> Self {
273        Self {
274            site_title: "RustIO administration".into(),
275            site_header: "RustIO administration".into(),
276            index_title: "Site administration".into(),
277            footer_copyright: format!("RustIO {}", env!("CARGO_PKG_VERSION")),
278            domain: "rustio.local".into(),
279        }
280    }
281}
282
283/// Full admin chrome palette. Each field maps onto one of the
284/// framework's `--rio-*` design tokens defined in `_base.html`, so
285/// overriding these values via `Admin::theme(...)` re-skins the
286/// entire admin shell without touching CSS.
287///
288/// Defaults match the framework's current chrome so a project that
289/// doesn't call `.theme(...)` renders unchanged.
290///
291/// Hex form (`#rrggbb` or `rrggbb`); leading `#` is auto-normalised
292/// at render time. Malformed values fall back to framework defaults
293/// rather than panic — the admin path never breaks over a config typo.
294#[derive(Clone, Debug, PartialEq, Eq)]
295pub struct AdminTheme {
296    pub accent: String,
297    pub bg: String,
298    pub surface: String,
299    pub text: String,
300    pub text_muted: String,
301    pub border: String,
302}
303
304impl Default for AdminTheme {
305    fn default() -> Self {
306        // Crimson light palette — matches admin.css :root defaults.
307        // Projects override via Admin::theme(...) or accent_color(...).
308        Self {
309            accent: "#A0341A".into(),
310            bg: "#EBEEF4".into(),
311            surface: "#FFFFFF".into(),
312            text: "#0A0E1A".into(),
313            text_muted: "#3D4452".into(),
314            border: "#CDD3DF".into(),
315        }
316    }
317}
318
319/// Builder for the admin. Register models with `.model::<M>()`, then
320/// hand it to the router via `register_admin_routes`.
321pub struct Admin {
322    pub(crate) entries: Vec<AdminEntry>,
323    pub(crate) site_branding: SiteBranding,
324    pub(crate) user_profile_ext: Option<UserProfileExtensionFn>,
325    pub(crate) theme: AdminTheme,
326}
327
328impl Default for Admin {
329    fn default() -> Self {
330        Self::new()
331    }
332}
333
334impl Admin {
335    /// Constructs a new `Admin` with the framework's core entries
336    /// pre-seeded. The only core entry is `User`; project models are
337    /// added on top via [`Self::model`].
338    pub fn new() -> Self {
339        Self {
340            entries: vec![core_user_entry()],
341            site_branding: SiteBranding::default(),
342            user_profile_ext: None,
343            theme: AdminTheme::default(),
344        }
345    }
346
347    /// Override the default RustIO branding.
348    pub fn site_branding(mut self, branding: SiteBranding) -> Self {
349        self.site_branding = branding;
350        self
351    }
352
353    /// Read-only access to the active branding.
354    pub fn branding(&self) -> &SiteBranding {
355        &self.site_branding
356    }
357
358    /// Set the admin chrome's accent colour. Hex form, with or without
359    /// the leading `#` (`"#1e6ba8"` and `"1e6ba8"` both work).
360    pub fn accent_color(mut self, color: impl Into<String>) -> Self {
361        self.theme.accent = normalise_hex(color);
362        self
363    }
364
365    /// Set the entire admin chrome palette in one call. See
366    /// [`AdminTheme`] for the field-by-field contract.
367    pub fn theme(mut self, theme: AdminTheme) -> Self {
368        self.theme = theme;
369        self
370    }
371
372    /// Read-only access to the configured accent colour (`#rrggbb`).
373    pub fn accent(&self) -> &str {
374        &self.theme.accent
375    }
376
377    /// Read-only access to the active full theme.
378    pub fn active_theme(&self) -> &AdminTheme {
379        &self.theme
380    }
381
382    pub fn model<M>(mut self) -> Self
383    where
384        M: super::ModelAdmin + crate::orm::Model,
385    {
386        let ops: Arc<dyn AdminOps> = Arc::new(super::ops::ConcreteOps::<M>::new());
387        self.entries.push(AdminEntry {
388            admin_name: M::ADMIN_NAME,
389            display_name: M::DISPLAY_NAME,
390            singular_name: M::SINGULAR_NAME,
391            table: <M as crate::orm::Model>::TABLE,
392            fields: M::FIELDS,
393            core: false,
394            list_display: M::list_display(),
395            list_filter: M::list_filter(),
396            search_fields: M::search_fields(),
397            ordering: M::ordering(),
398            list_per_page: M::list_per_page(),
399            readonly_fields: M::readonly_fields(),
400            fieldsets: M::fieldsets(),
401            ops,
402        });
403        self
404    }
405
406    pub fn entries(&self) -> &[AdminEntry] {
407        &self.entries
408    }
409
410    /// Register a project-specific extension that contributes extra
411    /// sections to the built-in user profile page. The closure is
412    /// invoked on every render of `GET /admin/users/:id` (Overview tab);
413    /// it receives the `Db` handle and the loaded
414    /// [`crate::auth::UserProfile`] (no `password_hash`) and returns a
415    /// `Vec<UserProfileSection>`. Sections render in the order returned,
416    /// immediately after the core profile show-grid.
417    ///
418    /// Zero-config baseline: don't call this method, and the extension
419    /// area stays empty. Projects that need richer layout than key-value
420    /// rows override the `{% block project_user_fields %}` template
421    /// block in `templates/admin/user_view.html` instead.
422    pub fn user_profile_extension<F, Fut>(mut self, ext: F) -> Self
423    where
424        F: Fn(Db, crate::auth::UserProfile) -> Fut + Send + Sync + 'static,
425        Fut: Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static,
426    {
427        self.user_profile_ext = Some(Arc::new(move |db, user| Box::pin(ext(db, user))));
428        self
429    }
430
431    /// Internal accessor — handlers fetch the registered extension
432    /// closure (if any) here. Used by `admin/builtin.rs` (P6.b).
433    #[allow(dead_code)]
434    pub(crate) fn user_profile_ext(&self) -> Option<&UserProfileExtensionFn> {
435        self.user_profile_ext.as_ref()
436    }
437
438    pub fn find(&self, admin_name: &str) -> Option<&AdminEntry> {
439        self.entries.iter().find(|e| e.admin_name == admin_name)
440    }
441
442    /// Register the canonical (add/change/delete/view) permissions for
443    /// every model. Call during startup after `init_tables`.
444    pub async fn seed_permissions(&self, db: &crate::orm::Db) -> crate::error::Result<()> {
445        for entry in &self.entries {
446            let singular = entry.singular_name.to_ascii_lowercase();
447            crate::auth::register_model_permissions(db, entry.admin_name, &singular).await?;
448        }
449        Ok(())
450    }
451}
452
453// -------------------------------------------------------------------------
454// Core User entry — synthetic, route-only stub
455// -------------------------------------------------------------------------
456//
457// Every project's admin index lists `Users` so operators can navigate
458// to the bespoke `/admin/users/*` pages owned by `admin::builtin`. The
459// `User` entry is built directly here rather than implementing
460// `AdminModel` on a placeholder struct: the auth subsystem already
461// owns the live `/admin/users` page with its own logic; routing
462// through generic CRUD here would spawn a duplicate page.
463
464const CORE_USER_FIELDS: &[AdminField] = &[
465    AdminField {
466        name: "id",
467        label: "id",
468        field_type: FieldType::I64,
469        editable: false,
470        relation: None,
471        choices: None,
472    },
473    AdminField {
474        name: "email",
475        label: "email",
476        field_type: FieldType::String,
477        editable: true,
478        relation: None,
479        choices: None,
480    },
481    AdminField {
482        name: "password_hash",
483        label: "password_hash",
484        field_type: FieldType::String,
485        editable: false,
486        relation: None,
487        choices: None,
488    },
489    AdminField {
490        name: "role",
491        label: "role",
492        field_type: FieldType::String,
493        editable: true,
494        relation: None,
495        choices: None,
496    },
497    AdminField {
498        name: "is_active",
499        label: "is_active",
500        field_type: FieldType::Bool,
501        editable: true,
502        relation: None,
503        choices: None,
504    },
505    AdminField {
506        name: "created_at",
507        label: "created_at",
508        field_type: FieldType::DateTime,
509        editable: false,
510        relation: None,
511        choices: None,
512    },
513];
514
515/// Normalise a user-supplied colour string to `#rrggbb` form. Accepts
516/// both `"#1e6ba8"` and `"1e6ba8"`; trims whitespace; does NOT validate
517/// that the body is hex (that's the renderer's job, where invalid
518/// values fall back to the framework default rather than panic). The
519/// `format!()` adds back exactly one leading `#`.
520pub(crate) fn normalise_hex(input: impl Into<String>) -> String {
521    let raw = input.into();
522    let trimmed = raw.trim().trim_start_matches('#');
523    format!("#{trimmed}")
524}
525
526fn core_user_entry() -> AdminEntry {
527    AdminEntry {
528        admin_name: "users",
529        display_name: "Users",
530        singular_name: "User",
531        table: "rustio_users",
532        fields: CORE_USER_FIELDS,
533        core: true,
534        list_display: &[],
535        list_filter: &[],
536        search_fields: &[],
537        ordering: &["-id"],
538        list_per_page: 50,
539        readonly_fields: &[],
540        fieldsets: &[],
541        ops: Arc::new(CoreUserOps),
542    }
543}
544
545/// Route-only stub for the synthetic User entry. The live
546/// `/admin/users` page is wired separately by `admin::builtin`, so
547/// every method here returns a dedicated error rather than silently
548/// half-working. If the generic admin ever routes to this, the error
549/// makes the misuse obvious.
550struct CoreUserOps;
551
552fn core_user_route_error() -> crate::error::Error {
553    crate::error::Error::Internal(
554        "the core User entry is route-only — use the dedicated /admin/users page".into(),
555    )
556}
557
558impl AdminOps for CoreUserOps {
559    fn list<'a>(
560        &'a self,
561        _db: &'a Db,
562        _opts: ListOpts,
563    ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>> {
564        Box::pin(async { Err(core_user_route_error()) })
565    }
566
567    fn find_row<'a>(
568        &'a self,
569        _db: &'a Db,
570        _id: i64,
571    ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
572        Box::pin(async { Err(core_user_route_error()) })
573    }
574
575    fn create<'a>(&'a self, _db: &'a Db, _form: &'a FormData) -> CreateResult<'a> {
576        Box::pin(async { Err(core_user_route_error()) })
577    }
578
579    fn update<'a>(&'a self, _db: &'a Db, _id: i64, _form: &'a FormData) -> UpdateResult<'a> {
580        Box::pin(async { Err(core_user_route_error()) })
581    }
582
583    fn delete<'a>(
584        &'a self,
585        _db: &'a Db,
586        _id: i64,
587    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
588        Box::pin(async { Err(core_user_route_error()) })
589    }
590
591    fn object_label<'a>(
592        &'a self,
593        _db: &'a Db,
594        _id: i64,
595    ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
596        Box::pin(async { Err(core_user_route_error()) })
597    }
598}
599
600// Test fixtures (PanicOps / FailingOps + AdminEntry::for_testing*) live
601// with the legacy `admin/macro_tests.rs` etc. that haven't been ported
602// yet. Re-add them here when the first in-tree test needs them.