Skip to main content

umbral_admin/
registry.rs

1//! `AdminRegistry` — per-plugin grouping of registered models.
2//!
3//! The registry records which plugin owns each model so the sidebar
4//! nav can be built as a tree: `plugin → [model, model, ...]`.
5//!
6//! # Usage
7//!
8//! [`AdminPlugin::register`] calls [`AdminRegistry::register`] internally,
9//! passing `Plugin::name()` as the plugin identifier. The rendered shell
10//! calls [`AdminRegistry::apps`] to get the sorted, permission-filtered
11//! sidebar tree.
12//!
13//! # Auto-discovery
14//!
15//! [`AdminRegistry::apps`] synthesises a default [`AdminRegistration`] for
16//! every model in the global model registry that does NOT have an explicit
17//! registration. The label comes from `ModelMeta::display` (which reflects
18//! `Model::DISPLAY`) and the icon from `ModelMeta::icon` (`Model::ICON`).
19//! Explicit registrations override the synthesised defaults — same table
20//! name means the explicit entry wins.
21//!
22//! # Permission gating
23//!
24//! Today, [`AdminRegistry::apps`] passes every entry through for any staff
25//! user — matching the current baseline behaviour. When `umbral-permissions`
26//! lands (gap 33), add a `view_<table>` permission check per entry and
27//! filter out models the viewer may not see.
28
29use std::collections::{HashMap, HashSet};
30
31use umbral_auth::AuthUser;
32
33use crate::AdminModel;
34
35/// One registered model entry: the display config plus metadata.
36#[derive(Debug, Clone)]
37pub struct AdminRegistration {
38    /// The per-model admin configuration.
39    pub model: AdminModel,
40    /// The name of the plugin that registered this model
41    /// (`Plugin::name()`).
42    pub plugin: String,
43    /// Human-readable label shown in the sidebar. Defaults to the
44    /// table name if not supplied.
45    pub label: String,
46    /// Lucide icon name shown in the sidebar. Defaults to `"database"`.
47    pub icon: Option<String>,
48}
49
50/// One plugin's group in the sidebar tree.
51#[derive(Debug, Clone)]
52pub struct App {
53    /// The plugin name used as the group header.
54    pub plugin: String,
55    /// Display label for the group (same as `plugin` today; a future
56    /// `verbose_name` field on `Plugin` could override this).
57    pub label: String,
58    /// Models in this group, sorted by label.
59    pub models: Vec<AdminRegistration>,
60}
61
62/// Central registry that maps `table_name → AdminRegistration`.
63///
64/// One instance lives inside [`crate::AdminPlugin`] and is Arc-shared
65/// into every route handler via [`crate::AdminState`].
66#[derive(Debug, Default, Clone)]
67pub struct AdminRegistry {
68    // table_name -> AdminRegistration
69    entries: HashMap<String, AdminRegistration>,
70}
71
72impl AdminRegistry {
73    /// Register an [`AdminModel`] under the given plugin name.
74    ///
75    /// If a model with the same table was already registered, the new
76    /// registration wins (last-write-wins; a duplicate registration
77    /// overwrites the earlier one).
78    pub fn register(&mut self, plugin: &str, model: AdminModel) {
79        let label = model.label.clone().unwrap_or_else(|| {
80            // Default: title-case the table name (replace `_` with space).
81            titlecase(&model.table)
82        });
83        let icon = model.icon.clone();
84        let table = model.table.clone();
85        self.entries.insert(
86            table,
87            AdminRegistration {
88                model,
89                plugin: plugin.to_string(),
90                label,
91                icon,
92            },
93        );
94    }
95
96    /// Build the sidebar tree for the given viewer.
97    ///
98    /// Walks the full model registry and synthesises a default
99    /// [`AdminRegistration`] for every model not explicitly registered.
100    /// Explicit registrations override the synthesised defaults (same
101    /// table name = explicit wins).
102    ///
103    /// Ordering: plugins sorted alphabetically with the implicit `"app"`
104    /// bucket rendered last; models within each group sorted by label.
105    ///
106    /// # Permission filtering
107    ///
108    /// When `viewer_codenames` is `Some(set)`, a model is included in the
109    /// sidebar only if the set contains `"<plugin>.view_<table>"`. This
110    /// mirrors the changelist gate in `permcheck::require(Action::View)`.
111    ///
112    /// `None` means "no filtering" — used when `PermissionsPlugin` is not
113    /// installed (staff-only baseline) or when the viewer is a superuser
114    /// (who holds every permission implicitly). Both cases preserve the
115    /// pre-#75 / pre-#83 behaviour: all models visible to every staff user.
116    pub fn apps(&self, _viewer: &AuthUser, viewer_codenames: Option<&HashSet<String>>) -> Vec<App> {
117        // Build the merged map: start with synthesised defaults for every
118        // model in the global registry, then overlay explicit registrations.
119        let mut merged: HashMap<String, AdminRegistration> = HashMap::new();
120
121        // Walk every plugin known to the migration registry.
122        for plugin_name in umbral::migrate::registered_plugins() {
123            for meta in umbral::migrate::models_for_plugin(&plugin_name) {
124                let label = titlecase(&meta.display);
125                let icon = meta.icon.clone();
126                let table = meta.table.clone();
127                let reg = AdminRegistration {
128                    model: AdminModel::new(&table),
129                    plugin: plugin_name.clone(),
130                    label,
131                    icon: Some(icon),
132                };
133                merged.insert(table, reg);
134            }
135        }
136        // Also pick up models registered via `.model::<T>()` (the implicit
137        // `"app"` plugin). These land in `registered_models()` but may not
138        // appear in `registered_plugins()` if `"app"` contributed zero models
139        // via a Plugin impl.
140        for meta in umbral::migrate::registered_models() {
141            if !merged.contains_key(&meta.table) {
142                let label = titlecase(&meta.display);
143                let icon = meta.icon.clone();
144                let table = meta.table.clone();
145                let reg = AdminRegistration {
146                    model: AdminModel::new(&table),
147                    plugin: "app".to_string(),
148                    label,
149                    icon: Some(icon),
150                };
151                merged.insert(table, reg);
152            }
153        }
154        // Overlay explicit registrations — they always win.
155        for (table, explicit) in &self.entries {
156            merged.insert(table.clone(), explicit.clone());
157        }
158
159        // Permission gate — filter out models the viewer may not see.
160        //
161        // When `viewer_codenames` is `Some`, keep only entries whose
162        // `"<plugin>.view_<table>"` codename is in the set. When `None`
163        // (superuser / no PermissionsPlugin), every model passes through.
164        let merged_filtered: Vec<AdminRegistration> = merged
165            .into_values()
166            .filter(|reg| match viewer_codenames {
167                None => true,
168                Some(codenames) => {
169                    let required = format!("{}.view_{}", reg.plugin, reg.model.table);
170                    codenames.contains(&required)
171                }
172            })
173            .collect();
174
175        // Group by plugin, sort, and produce the tree.
176        let mut by_plugin: HashMap<String, Vec<AdminRegistration>> = HashMap::new();
177        for reg in merged_filtered {
178            by_plugin.entry(reg.plugin.clone()).or_default().push(reg);
179        }
180        let mut apps: Vec<App> = by_plugin
181            .into_iter()
182            .map(|(plugin, mut models)| {
183                models.sort_by(|a, b| a.label.cmp(&b.label));
184                let label = plugin.clone();
185                App {
186                    plugin,
187                    label,
188                    models,
189                }
190            })
191            .collect();
192        // Named plugins alphabetically first; the implicit "app" bucket last.
193        apps.sort_by(|a, b| match (a.plugin.as_str(), b.plugin.as_str()) {
194            ("app", "app") => std::cmp::Ordering::Equal,
195            ("app", _) => std::cmp::Ordering::Greater,
196            (_, "app") => std::cmp::Ordering::Less,
197            _ => a.plugin.cmp(&b.plugin),
198        });
199        apps
200    }
201
202    /// Look up the registration for a table by name.
203    pub fn get(&self, table: &str) -> Option<&AdminRegistration> {
204        self.entries.get(table)
205    }
206
207    /// Iterate all registrations. Used when building the legacy
208    /// `configs` slice that the existing routing code depends on.
209    pub fn all(&self) -> impl Iterator<Item = &AdminRegistration> {
210        self.entries.values()
211    }
212}
213
214/// Titlecase a string: replace `_` with space, capitalise the first
215/// character of each word (split on `_` and space).
216fn titlecase(s: &str) -> String {
217    if s.is_empty() {
218        return String::new();
219    }
220    s.split('_')
221        .map(|word| {
222            let mut c = word.chars();
223            match c.next() {
224                None => String::new(),
225                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
226            }
227        })
228        .collect::<Vec<_>>()
229        .join(" ")
230}