use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use umbral::db::DbPool;
#[derive(Debug, Clone)]
pub enum ToastLevel {
Info,
Success,
Warning,
Error,
}
impl ToastLevel {
pub fn as_str(&self) -> &'static str {
match self {
ToastLevel::Info => "info",
ToastLevel::Success => "success",
ToastLevel::Warning => "warning",
ToastLevel::Error => "error",
}
}
}
#[derive(Debug, Clone)]
pub enum ActionResult {
Toast {
message: String,
level: ToastLevel,
},
RefreshTable,
OpenSheet {
table: String,
id: i64,
},
Download {
filename: String,
content_type: String,
bytes: Vec<u8>,
},
Redirect {
url: String,
},
}
#[derive(Debug, Clone)]
pub enum ActionVariant {
Default,
Danger,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ActionScope {
Row,
Bulk,
Both,
}
#[derive(Debug, Clone)]
pub struct ActionInvocation {
pub ids: Vec<String>,
pub username: String,
pub table: String,
pub pool: DbPool,
}
#[derive(Debug, Clone)]
pub struct AdminContext {
pub username: String,
pub table: String,
}
pub(crate) type ActionFuture =
Pin<Box<dyn Future<Output = Result<ActionResult, String>> + Send + 'static>>;
pub(crate) type ActionHandlerFn =
Arc<dyn Fn(ActionInvocation) -> ActionFuture + Send + Sync + 'static>;
#[derive(Clone)]
pub struct Action {
pub(crate) key: String,
pub(crate) label: String,
pub(crate) icon: String,
pub(crate) variant: ActionVariant,
pub(crate) scope: ActionScope,
pub(crate) confirm: Option<String>,
pub(crate) permission: Option<String>,
pub(crate) handler: ActionHandlerFn,
}
impl std::fmt::Debug for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Action")
.field("key", &self.key)
.field("label", &self.label)
.field("icon", &self.icon)
.finish()
}
}
impl Action {
pub fn new<F, Fut>(
key: impl Into<String>,
label: impl Into<String>,
icon: impl Into<String>,
f: F,
) -> Self
where
F: Fn(ActionInvocation) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<ActionResult, String>> + Send + 'static,
{
let key = key.into();
assert!(
!key.is_empty() && key.chars().all(is_action_key_char),
"Action::new: key {key:?} must be ASCII [a-z0-9_-]"
);
Action {
key,
label: label.into(),
icon: icon.into(),
variant: ActionVariant::Default,
scope: ActionScope::Both,
confirm: None,
permission: None,
handler: Arc::new(move |inv| Box::pin(f(inv))),
}
}
pub fn danger(mut self) -> Self {
self.variant = ActionVariant::Danger;
self
}
pub fn scope(mut self, scope: ActionScope) -> Self {
self.scope = scope;
self
}
pub fn confirm(mut self, message: impl Into<String>) -> Self {
self.confirm = Some(message.into());
self
}
pub fn permission(mut self, codename: impl Into<String>) -> Self {
self.permission = Some(codename.into());
self
}
pub fn delete_selected() -> Self {
Self::new(
"delete_selected",
"Delete selected",
"trash-2",
|inv| async move {
if inv.ids.is_empty() {
return Ok(ActionResult::Toast {
message: "No rows selected.".to_string(),
level: ToastLevel::Info,
});
}
let Some((_, meta)) = crate::discovery::find_model(&inv.table) else {
return Err(format!("unknown table `{}`", inv.table));
};
let pk_name = crate::discovery::pk_column(&meta)
.map(|c| c.name.clone())
.unwrap_or_else(|| "id".to_string());
match umbral::orm::DynQuerySet::for_meta(&meta)
.filter_in_strings(&pk_name, &inv.ids)
.delete()
.await
{
Ok(deleted) => Ok(ActionResult::Toast {
message: format!("Deleted {deleted} row(s)."),
level: ToastLevel::Success,
}),
Err(e) => {
tracing::error!(error = %e, "admin: delete_selected failed");
Err("database error during delete".to_string())
}
}
},
)
.danger()
.scope(ActionScope::Bulk)
.confirm("This will permanently delete the selected rows. Continue?")
}
pub fn restore_selected() -> Self {
Self::new(
"restore_selected",
"Restore selected",
"archive-restore",
|inv| async move {
if inv.ids.is_empty() {
return Ok(ActionResult::Toast {
message: "No rows selected.".to_string(),
level: ToastLevel::Info,
});
}
let Some((_, meta)) = crate::discovery::find_model(&inv.table) else {
return Err(format!("unknown table `{}`", inv.table));
};
let pk_name = crate::discovery::pk_column(&meta)
.map(|c| c.name.clone())
.unwrap_or_else(|| "id".to_string());
match umbral::orm::DynQuerySet::for_meta(&meta)
.with_deleted()
.filter_in_strings(&pk_name, &inv.ids)
.restore()
.await
{
Ok(restored) => Ok(ActionResult::Toast {
message: format!("Restored {restored} row(s)."),
level: ToastLevel::Success,
}),
Err(e) => {
tracing::error!(error = %e, "admin: restore_selected failed");
Err("database error during restore".to_string())
}
}
},
)
.scope(ActionScope::Bulk)
}
pub fn delete_permanently() -> Self {
Self::new(
"delete_permanently",
"Delete permanently",
"trash-2",
|inv| async move {
if inv.ids.is_empty() {
return Ok(ActionResult::Toast {
message: "No rows selected.".to_string(),
level: ToastLevel::Info,
});
}
let Some((_, meta)) = crate::discovery::find_model(&inv.table) else {
return Err(format!("unknown table `{}`", inv.table));
};
let pk_name = crate::discovery::pk_column(&meta)
.map(|c| c.name.clone())
.unwrap_or_else(|| "id".to_string());
match umbral::orm::DynQuerySet::for_meta(&meta)
.hard_delete()
.with_deleted()
.filter_in_strings(&pk_name, &inv.ids)
.delete()
.await
{
Ok(deleted) => Ok(ActionResult::Toast {
message: format!("Permanently deleted {deleted} row(s)."),
level: ToastLevel::Success,
}),
Err(e) => {
tracing::error!(error = %e, "admin: delete_permanently failed");
Err("database error during permanent delete".to_string())
}
}
},
)
.danger()
.scope(ActionScope::Bulk)
.confirm("This will PERMANENTLY delete the selected rows. They cannot be restored. Continue?")
}
pub fn key(&self) -> &str {
&self.key
}
}
pub(crate) fn effective_actions(
configured: &[Action],
soft_delete: bool,
trash: bool,
) -> Vec<Action> {
if !soft_delete {
return configured.to_vec();
}
if trash {
vec![Action::restore_selected(), Action::delete_permanently()]
} else {
configured.to_vec()
}
}
fn is_action_key_char(c: char) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InlineKind {
#[default]
Tabular,
Stacked,
}
impl InlineKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
InlineKind::Tabular => "tabular",
InlineKind::Stacked => "stacked",
}
}
}
#[derive(Debug, Clone)]
pub struct InlineModel {
pub model: String,
pub fk_field: String,
pub list_display: Vec<String>,
pub kind: InlineKind,
pub extra: usize,
pub can_delete: bool,
pub readonly_fields: Vec<String>,
}
impl Default for InlineModel {
fn default() -> Self {
Self {
model: String::new(),
fk_field: String::new(),
list_display: Vec::new(),
kind: InlineKind::Tabular,
extra: 1,
can_delete: true,
readonly_fields: Vec::new(),
}
}
}
impl InlineModel {
pub fn new(
model: impl Into<String>,
fk_field: impl Into<String>,
list_display: &[&str],
) -> Self {
Self {
model: model.into(),
fk_field: fk_field.into(),
list_display: list_display.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
pub fn kind(mut self, kind: InlineKind) -> Self {
self.kind = kind;
self
}
pub fn extra(mut self, extra: usize) -> Self {
self.extra = extra;
self
}
pub fn can_delete(mut self, can_delete: bool) -> Self {
self.can_delete = can_delete;
self
}
pub fn readonly_fields(mut self, fields: &[&str]) -> Self {
self.readonly_fields = fields.iter().map(|s| s.to_string()).collect();
self
}
}
#[derive(Clone, Debug)]
pub struct AdminModel {
pub(crate) table: String,
pub(crate) list_display: Vec<String>,
pub(crate) list_filter: Vec<String>,
pub(crate) search_fields: Vec<String>,
pub(crate) ordering: Vec<String>,
pub(crate) actions: Vec<Action>,
pub(crate) readonly_fields: Vec<String>,
pub(crate) list_per_page: usize,
pub(crate) inlines: Vec<InlineModel>,
pub(crate) label: Option<String>,
pub(crate) icon: Option<String>,
pub(crate) inline_edit_fields: Vec<String>,
pub(crate) column_widths: Vec<(String, String)>,
pub(crate) password_field: Option<String>,
}
pub(crate) fn is_sensitive_column(name: &str) -> bool {
matches!(name, "password_hash" | "password" | "salt") || name.starts_with("secret")
}
impl AdminModel {
pub fn new(table: impl Into<String>) -> Self {
Self {
table: table.into(),
list_display: Vec::new(),
list_filter: Vec::new(),
search_fields: Vec::new(),
ordering: Vec::new(),
actions: Vec::new(),
readonly_fields: Vec::new(),
list_per_page: 25,
inlines: Vec::new(),
label: None,
icon: None,
inline_edit_fields: Vec::new(),
column_widths: Vec::new(),
password_field: None,
}
}
pub fn list_display(mut self, fields: &[&str]) -> Self {
self.list_display = fields.iter().map(|s| s.to_string()).collect();
self
}
pub fn list_filter(mut self, fields: &[&str]) -> Self {
self.list_filter = fields.iter().map(|s| s.to_string()).collect();
self
}
pub fn search_fields(mut self, fields: &[&str]) -> Self {
self.search_fields = fields.iter().map(|s| s.to_string()).collect();
self
}
pub fn ordering(mut self, fields: &[&str]) -> Self {
self.ordering = fields.iter().map(|s| s.to_string()).collect();
self
}
pub fn actions(mut self, actions: Vec<Action>) -> Self {
self.actions = actions;
self
}
pub fn readonly_fields(mut self, fields: &[&str]) -> Self {
self.readonly_fields = fields.iter().map(|s| s.to_string()).collect();
self
}
pub fn column_widths(mut self, widths: &[(&str, &str)]) -> Self {
self.column_widths = widths
.iter()
.map(|(col, w)| (col.to_string(), w.to_string()))
.collect();
self
}
pub fn effective_readonly_fields<'a>(&'a self, all_columns: &[&'a str]) -> Vec<&'a str> {
let mut set: std::collections::HashSet<&str> =
self.readonly_fields.iter().map(|s| s.as_str()).collect();
for col in all_columns {
if is_sensitive_column(col) {
set.insert(col);
}
}
set.into_iter().collect()
}
pub fn list_per_page(mut self, n: usize) -> Self {
self.list_per_page = n;
self
}
pub fn inlines(mut self, inlines: Vec<InlineModel>) -> Self {
self.inlines = inlines;
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn inline_edit_fields(mut self, fields: &[&str]) -> Self {
self.inline_edit_fields = fields.iter().map(|s| s.to_string()).collect();
self
}
pub fn password_field(mut self, column: impl Into<String>) -> Self {
self.password_field = Some(column.into());
self
}
pub fn table(&self) -> &str {
&self.table
}
pub fn get_list_per_page(&self) -> usize {
self.list_per_page
}
pub fn get_column_widths(&self) -> &[(String, String)] {
&self.column_widths
}
}
pub type AdminConfig = AdminModel;