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 /// `ModelAdmin::bulk_actions()`. Empty by default — the bulk bar
170 /// only renders the framework's built-in Delete.
171 pub bulk_actions: &'static [super::modeladmin::BulkAction],
172 pub(crate) ops: Arc<dyn AdminOps>,
173}
174
175/// Per-request options for [`AdminOps::list`]. Empty / `None` fields
176/// mean "framework default": no ordering override falls back to
177/// `id DESC` inside the runtime, no filters skips the WHERE clause,
178/// no limit fetches every row.
179#[derive(Debug, Clone, Default)]
180pub struct ListOpts {
181 /// Validated `(column, dir)` pairs to apply as `ORDER BY`. The
182 /// column name is bound to the model's `M::COLUMNS` set inside
183 /// the runtime, so callers can pass user-supplied names without
184 /// SQL-injection risk.
185 pub ordering: Vec<(String, super::modeladmin::SortDir)>,
186 /// `(column, value)` pairs applied as `WHERE col::text = $N`.
187 /// Cast to text so the comparison matches the same string-shape
188 /// semantics the in-memory pre-P10 filter used for bool / int /
189 /// timestamp columns.
190 pub filters: Vec<(String, String)>,
191 /// Free-text search: `(term, columns)`. The runtime emits
192 /// `WHERE (col1::text ILIKE $N OR col2::text ILIKE $N OR …)`
193 /// with `$N = '%term%'`. An empty `term` or empty `columns`
194 /// leaves the WHERE alone.
195 pub search: Option<(String, Vec<String>)>,
196 /// `LIMIT $N` for the data query. The COUNT(*) query never
197 /// applies it. `None` → no limit.
198 pub limit: Option<i64>,
199 /// `OFFSET $N` for the data query. `None` or `Some(0)` → no offset.
200 pub offset: Option<i64>,
201}
202
203/// Result of [`AdminOps::list`]: the requested page plus the total
204/// row count under the same WHERE clause (so handlers can render
205/// pagination footers without a separate query).
206#[derive(Debug, Default)]
207pub struct ListPage {
208 pub rows: Vec<ListRow>,
209 pub total: i64,
210}
211
212/// Type-erased CRUD operations. The `Admin::model::<M>()` call captures
213/// a concrete `M: AdminModel + Model` and hides it behind this trait so
214/// the router can treat every model uniformly. The single live impl is
215/// [`super::ops::ConcreteOps<M>`].
216pub(crate) trait AdminOps: Send + Sync {
217 fn list<'a>(
218 &'a self,
219 db: &'a Db,
220 opts: ListOpts,
221 ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>>;
222
223 fn find_row<'a>(
224 &'a self,
225 db: &'a Db,
226 id: i64,
227 ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>>;
228
229 fn create<'a>(&'a self, db: &'a Db, form: &'a FormData) -> CreateResult<'a>;
230
231 fn update<'a>(&'a self, db: &'a Db, id: i64, form: &'a FormData) -> UpdateResult<'a>;
232
233 fn delete<'a>(
234 &'a self,
235 db: &'a Db,
236 id: i64,
237 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
238
239 fn object_label<'a>(
240 &'a self,
241 db: &'a Db,
242 id: i64,
243 ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>>;
244
245 /// Run a project-defined bulk action against the supplied row
246 /// ids. Called once per submission with the full id list, so the
247 /// implementation can choose between a single bulk SQL update or
248 /// a per-row loop. The default impl returns `BadRequest` with the
249 /// action name embedded — projects override to match on `name`
250 /// and apply the work; an unknown name surfaces as a clear error
251 /// page rather than a silent no-op.
252 ///
253 /// Note: the framework's built-in `delete` action is **not**
254 /// dispatched through here. It runs through the cascade-aware
255 /// `/bulk_delete` route which calls `delete()` per row. Override
256 /// `delete` instead if you need custom delete semantics.
257 fn execute_bulk_action<'a>(
258 &'a self,
259 _db: &'a Db,
260 name: &'a str,
261 _ids: &'a [i64],
262 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
263 let owned = name.to_string();
264 Box::pin(async move {
265 Err(crate::error::Error::BadRequest(format!(
266 "bulk action `{owned}` has no project handler — override \
267 AdminOps::execute_bulk_action on this model to implement it"
268 )))
269 })
270 }
271}
272
273/// A row as shown on the list page.
274#[derive(Debug)]
275pub struct ListRow {
276 pub id: i64,
277 pub cells: Vec<String>,
278}
279
280/// The raw field values used to pre-fill the edit form.
281#[derive(Debug)]
282pub struct EditRow {
283 #[allow(dead_code)]
284 pub id: i64,
285 pub values: Vec<(String, String)>,
286}
287
288/// Per-project admin branding. Defaults are RustIO-flavoured;
289/// projects override via [`Admin::site_branding`].
290#[derive(Clone, Debug)]
291pub struct SiteBranding {
292 pub site_title: String,
293 pub site_header: String,
294 pub index_title: String,
295 pub footer_copyright: String,
296 /// DNS-shape string available to project handlers; not surfaced in
297 /// any framework template.
298 pub domain: String,
299}
300
301impl Default for SiteBranding {
302 fn default() -> Self {
303 Self {
304 site_title: "RustIO administration".into(),
305 site_header: "RustIO administration".into(),
306 index_title: "Site administration".into(),
307 footer_copyright: format!("RustIO {}", env!("CARGO_PKG_VERSION")),
308 domain: "rustio.local".into(),
309 }
310 }
311}
312
313/// Project-level override patch for the admin chrome palette.
314///
315/// `admin.css` is the single source of truth for the framework's design
316/// tokens (light defaults, dark mode, semantic surfaces, typography
317/// scale, …). `AdminTheme` is **purely a patch layer**: every field is
318/// `Option<String>` and defaults to `None`, meaning *“don’t override —
319/// let the stylesheet decide.”* Out of the box the framework emits no
320/// inline `<style>` block at all.
321///
322/// Set a field — usually via the fluent builder methods or
323/// [`Admin::accent_color`] — to inject a `--rio-*` custom-property
324/// override on every page. Overrides apply across `data-rio-theme`
325/// states (system / light / dark) by emitting a multi-state selector
326/// after `admin.css`, so they win cascade ties without `!important`.
327///
328/// Values are hex (`#rrggbb` or `rrggbb`); the leading `#` is
329/// auto-normalised at construction. Malformed input is rejected at
330/// override time rather than panicking — the admin path never breaks
331/// over a config typo.
332#[derive(Clone, Debug, Default, PartialEq, Eq)]
333pub struct AdminTheme {
334 pub accent: Option<String>,
335 pub bg: Option<String>,
336 pub surface: Option<String>,
337 pub text: Option<String>,
338 pub text_muted: Option<String>,
339 pub border: Option<String>,
340}
341
342impl AdminTheme {
343 /// New empty patch — no overrides emitted, `admin.css` wins.
344 pub fn new() -> Self {
345 Self::default()
346 }
347
348 /// `true` when at least one field is set. Used by the renderer to
349 /// decide whether to emit the inline `<style>` block at all.
350 pub fn has_overrides(&self) -> bool {
351 self.accent.is_some()
352 || self.bg.is_some()
353 || self.surface.is_some()
354 || self.text.is_some()
355 || self.text_muted.is_some()
356 || self.border.is_some()
357 }
358
359 /// Override `--rio-accent`. Hex form, `#` optional.
360 pub fn accent(mut self, color: impl Into<String>) -> Self {
361 self.accent = Some(normalise_hex(color));
362 self
363 }
364
365 /// Override `--rio-bg` (page canvas).
366 pub fn bg(mut self, color: impl Into<String>) -> Self {
367 self.bg = Some(normalise_hex(color));
368 self
369 }
370
371 /// Override `--rio-surface` (cards, topbar, sidebar, table body).
372 pub fn surface(mut self, color: impl Into<String>) -> Self {
373 self.surface = Some(normalise_hex(color));
374 self
375 }
376
377 /// Override `--rio-text` (body text colour).
378 pub fn text(mut self, color: impl Into<String>) -> Self {
379 self.text = Some(normalise_hex(color));
380 self
381 }
382
383 /// Override `--rio-text-muted` (secondary text, breadcrumb links).
384 pub fn text_muted(mut self, color: impl Into<String>) -> Self {
385 self.text_muted = Some(normalise_hex(color));
386 self
387 }
388
389 /// Override `--rio-border` (default divider, card outline).
390 pub fn border(mut self, color: impl Into<String>) -> Self {
391 self.border = Some(normalise_hex(color));
392 self
393 }
394}
395
396/// Builder for the admin. Register models with `.model::<M>()`, then
397/// hand it to the router via `register_admin_routes`.
398pub struct Admin {
399 pub(crate) entries: Vec<AdminEntry>,
400 pub(crate) site_branding: SiteBranding,
401 pub(crate) user_profile_ext: Option<UserProfileExtensionFn>,
402 pub(crate) theme: AdminTheme,
403}
404
405impl Default for Admin {
406 fn default() -> Self {
407 Self::new()
408 }
409}
410
411impl Admin {
412 /// Constructs a new `Admin` with the framework's core entries
413 /// pre-seeded. The only core entry is `User`; project models are
414 /// added on top via [`Self::model`].
415 pub fn new() -> Self {
416 Self {
417 entries: vec![core_user_entry()],
418 site_branding: SiteBranding::default(),
419 user_profile_ext: None,
420 theme: AdminTheme::default(),
421 }
422 }
423
424 /// Override the default RustIO branding.
425 pub fn site_branding(mut self, branding: SiteBranding) -> Self {
426 self.site_branding = branding;
427 self
428 }
429
430 /// Read-only access to the active branding.
431 pub fn branding(&self) -> &SiteBranding {
432 &self.site_branding
433 }
434
435 /// Set the admin chrome's accent colour. Hex form, with or without
436 /// the leading `#` (`"#1e6ba8"` and `"1e6ba8"` both work). Replaces
437 /// any prior accent override; other [`AdminTheme`] fields are
438 /// left untouched.
439 pub fn accent_color(mut self, color: impl Into<String>) -> Self {
440 self.theme.accent = Some(normalise_hex(color));
441 self
442 }
443
444 /// Replace the entire admin chrome palette patch in one call. See
445 /// [`AdminTheme`] for the field-by-field contract.
446 pub fn theme(mut self, theme: AdminTheme) -> Self {
447 self.theme = theme;
448 self
449 }
450
451 /// Read-only access to the configured accent colour, if any. `None`
452 /// means *“no override — admin.css owns it”*.
453 pub fn accent(&self) -> Option<&str> {
454 self.theme.accent.as_deref()
455 }
456
457 /// Read-only access to the active theme override patch.
458 pub fn active_theme(&self) -> &AdminTheme {
459 &self.theme
460 }
461
462 pub fn model<M>(mut self) -> Self
463 where
464 M: super::ModelAdmin + crate::orm::Model,
465 {
466 let ops: Arc<dyn AdminOps> = Arc::new(super::ops::ConcreteOps::<M>::new());
467 self.entries.push(AdminEntry {
468 admin_name: M::ADMIN_NAME,
469 display_name: M::DISPLAY_NAME,
470 singular_name: M::SINGULAR_NAME,
471 table: <M as crate::orm::Model>::TABLE,
472 fields: M::FIELDS,
473 core: false,
474 list_display: M::list_display(),
475 list_filter: M::list_filter(),
476 search_fields: M::search_fields(),
477 ordering: M::ordering(),
478 list_per_page: M::list_per_page(),
479 readonly_fields: M::readonly_fields(),
480 fieldsets: M::fieldsets(),
481 bulk_actions: M::bulk_actions(),
482 ops,
483 });
484 self
485 }
486
487 pub fn entries(&self) -> &[AdminEntry] {
488 &self.entries
489 }
490
491 /// Register a project-specific extension that contributes extra
492 /// sections to the built-in user profile page. The closure is
493 /// invoked on every render of `GET /admin/users/:id` (Overview tab);
494 /// it receives the `Db` handle and the loaded
495 /// [`crate::auth::UserProfile`] (no `password_hash`) and returns a
496 /// `Vec<UserProfileSection>`. Sections render in the order returned,
497 /// immediately after the core profile show-grid.
498 ///
499 /// Zero-config baseline: don't call this method, and the extension
500 /// area stays empty. Projects that need richer layout than key-value
501 /// rows override the `{% block project_user_fields %}` template
502 /// block in `templates/admin/user_view.html` instead.
503 pub fn user_profile_extension<F, Fut>(mut self, ext: F) -> Self
504 where
505 F: Fn(Db, crate::auth::UserProfile) -> Fut + Send + Sync + 'static,
506 Fut: Future<Output = Result<Vec<UserProfileSection>>> + Send + 'static,
507 {
508 self.user_profile_ext = Some(Arc::new(move |db, user| Box::pin(ext(db, user))));
509 self
510 }
511
512 /// Internal accessor — handlers fetch the registered extension
513 /// closure (if any) here. Used by `admin/builtin.rs` (P6.b).
514 #[allow(dead_code)]
515 pub(crate) fn user_profile_ext(&self) -> Option<&UserProfileExtensionFn> {
516 self.user_profile_ext.as_ref()
517 }
518
519 pub fn find(&self, admin_name: &str) -> Option<&AdminEntry> {
520 self.entries.iter().find(|e| e.admin_name == admin_name)
521 }
522
523 /// Register the canonical (add/change/delete/view) permissions for
524 /// every model. Call during startup after `init_tables`.
525 pub async fn seed_permissions(&self, db: &crate::orm::Db) -> crate::error::Result<()> {
526 for entry in &self.entries {
527 let singular = entry.singular_name.to_ascii_lowercase();
528 crate::auth::register_model_permissions(db, entry.admin_name, &singular).await?;
529 }
530 Ok(())
531 }
532}
533
534// -------------------------------------------------------------------------
535// Core User entry — synthetic, route-only stub
536// -------------------------------------------------------------------------
537//
538// Every project's admin index lists `Users` so operators can navigate
539// to the bespoke `/admin/users/*` pages owned by `admin::builtin`. The
540// `User` entry is built directly here rather than implementing
541// `AdminModel` on a placeholder struct: the auth subsystem already
542// owns the live `/admin/users` page with its own logic; routing
543// through generic CRUD here would spawn a duplicate page.
544
545const CORE_USER_FIELDS: &[AdminField] = &[
546 AdminField {
547 name: "id",
548 label: "id",
549 field_type: FieldType::I64,
550 editable: false,
551 relation: None,
552 choices: None,
553 },
554 AdminField {
555 name: "email",
556 label: "email",
557 field_type: FieldType::String,
558 editable: true,
559 relation: None,
560 choices: None,
561 },
562 AdminField {
563 name: "password_hash",
564 label: "password_hash",
565 field_type: FieldType::String,
566 editable: false,
567 relation: None,
568 choices: None,
569 },
570 AdminField {
571 name: "role",
572 label: "role",
573 field_type: FieldType::String,
574 editable: true,
575 relation: None,
576 choices: None,
577 },
578 AdminField {
579 name: "is_active",
580 label: "is_active",
581 field_type: FieldType::Bool,
582 editable: true,
583 relation: None,
584 choices: None,
585 },
586 AdminField {
587 name: "created_at",
588 label: "created_at",
589 field_type: FieldType::DateTime,
590 editable: false,
591 relation: None,
592 choices: None,
593 },
594];
595
596/// Normalise a user-supplied colour string to `#rrggbb` form. Accepts
597/// both `"#1e6ba8"` and `"1e6ba8"`; trims whitespace; does NOT validate
598/// that the body is hex (that's the renderer's job, where invalid
599/// values fall back to the framework default rather than panic). The
600/// `format!()` adds back exactly one leading `#`.
601pub(crate) fn normalise_hex(input: impl Into<String>) -> String {
602 let raw = input.into();
603 let trimmed = raw.trim().trim_start_matches('#');
604 format!("#{trimmed}")
605}
606
607fn core_user_entry() -> AdminEntry {
608 AdminEntry {
609 admin_name: "users",
610 display_name: "Users",
611 singular_name: "User",
612 table: "rustio_users",
613 fields: CORE_USER_FIELDS,
614 core: true,
615 list_display: &[],
616 list_filter: &[],
617 search_fields: &[],
618 ordering: &["-id"],
619 list_per_page: 50,
620 readonly_fields: &[],
621 fieldsets: &[],
622 bulk_actions: &[],
623 ops: Arc::new(CoreUserOps),
624 }
625}
626
627/// Route-only stub for the synthetic User entry. The live
628/// `/admin/users` page is wired separately by `admin::builtin`, so
629/// every method here returns a dedicated error rather than silently
630/// half-working. If the generic admin ever routes to this, the error
631/// makes the misuse obvious.
632struct CoreUserOps;
633
634fn core_user_route_error() -> crate::error::Error {
635 crate::error::Error::Internal(
636 "the core User entry is route-only — use the dedicated /admin/users page".into(),
637 )
638}
639
640impl AdminOps for CoreUserOps {
641 fn list<'a>(
642 &'a self,
643 _db: &'a Db,
644 _opts: ListOpts,
645 ) -> Pin<Box<dyn Future<Output = Result<ListPage>> + Send + 'a>> {
646 Box::pin(async { Err(core_user_route_error()) })
647 }
648
649 fn find_row<'a>(
650 &'a self,
651 _db: &'a Db,
652 _id: i64,
653 ) -> Pin<Box<dyn Future<Output = Result<Option<EditRow>>> + Send + 'a>> {
654 Box::pin(async { Err(core_user_route_error()) })
655 }
656
657 fn create<'a>(&'a self, _db: &'a Db, _form: &'a FormData) -> CreateResult<'a> {
658 Box::pin(async { Err(core_user_route_error()) })
659 }
660
661 fn update<'a>(&'a self, _db: &'a Db, _id: i64, _form: &'a FormData) -> UpdateResult<'a> {
662 Box::pin(async { Err(core_user_route_error()) })
663 }
664
665 fn delete<'a>(
666 &'a self,
667 _db: &'a Db,
668 _id: i64,
669 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
670 Box::pin(async { Err(core_user_route_error()) })
671 }
672
673 fn object_label<'a>(
674 &'a self,
675 _db: &'a Db,
676 _id: i64,
677 ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
678 Box::pin(async { Err(core_user_route_error()) })
679 }
680}
681
682// Test fixtures (PanicOps / FailingOps + AdminEntry::for_testing*) live
683// with the legacy `admin/macro_tests.rs` etc. that haven't been ported
684// yet. Re-add them here when the first in-tree test needs them.