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}