use std::collections::HashMap;
use serde::Serialize;
use umbral::migrate::ModelMeta;
use umbral::orm::SqlType;
use crate::AdminState;
use crate::config::AdminConfig;
#[derive(Debug, Clone, Serialize)]
pub(crate) struct SidebarModel {
pub table: String,
pub label: String,
pub icon: String,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct SidebarApp {
pub plugin: String,
pub label: String,
pub models: Vec<SidebarModel>,
}
pub(crate) async fn sidebar_apps(
state: &AdminState,
user: &umbral_auth::AuthUser,
) -> Vec<SidebarApp> {
let viewer_codenames: Option<std::collections::HashSet<String>> =
if !crate::permcheck::permissions_installed() || user.is_superuser {
None
} else {
let user_id = user.id.to_string();
match umbral_permissions::user_perms(&user_id).await {
Ok(set) => Some(set),
Err(err) => {
tracing::warn!(
user_id = user_id.as_str(),
error = %err,
"sidebar_apps: failed to load viewer codenames; showing no models"
);
Some(std::collections::HashSet::new())
}
}
};
state
.registry
.apps(user, viewer_codenames.as_ref())
.into_iter()
.map(|app| SidebarApp {
plugin: app.plugin.clone(),
label: app.label.clone(),
models: app
.models
.into_iter()
.map(|r| SidebarModel {
table: r.model.table.clone(),
label: r.label.clone(),
icon: r.icon.clone().unwrap_or_else(|| "database".to_string()),
})
.collect(),
})
.collect()
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct FormField {
pub name: String,
pub kind: &'static str,
pub value: String,
pub nullable: bool,
pub readonly: bool,
pub fk_table: String,
pub is_password: bool,
pub choices: Vec<ChoiceOption>,
pub help: String,
pub widget: String,
pub value_url: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ChoiceOption {
pub value: String,
pub label: String,
}
pub(crate) fn form_fields_for(
model: &ModelMeta,
prefill: Option<&HashMap<String, String>>,
cfg: Option<&AdminConfig>,
) -> Vec<FormField> {
let all_col_names: Vec<&str> = model.fields.iter().map(|c| c.name.as_str()).collect();
let readonly_set: std::collections::HashSet<String> = if let Some(c) = cfg {
c.effective_readonly_fields(&all_col_names)
.into_iter()
.map(|s| s.to_string())
.collect()
} else {
all_col_names
.iter()
.filter(|n| crate::config::is_sensitive_column(n))
.map(|s| s.to_string())
.collect()
};
let mut result: Vec<FormField> = model
.fields
.iter()
.filter(|c| {
if c.primary_key {
return false;
}
if c.noform {
return false;
}
if c.auto_now || c.auto_now_add {
return false;
}
if let Some(c2) = cfg.and_then(|cfg| cfg.password_field.as_deref()) {
if c.name == c2 {
return false;
}
}
true
})
.map(|c| {
let raw = prefill
.and_then(|m| m.get(&c.name))
.cloned()
.unwrap_or_default();
let fk_table = if matches!(c.ty, SqlType::ForeignKey) {
c.fk_target
.clone()
.unwrap_or_else(|| c.name.trim_end_matches("_id").to_string())
} else {
String::new()
};
let is_readonly = readonly_set.contains(&c.name) || c.noedit;
let choices: Vec<ChoiceOption> = c
.choices
.iter()
.enumerate()
.map(|(i, value)| ChoiceOption {
value: value.clone(),
label: c
.choice_labels
.get(i)
.cloned()
.unwrap_or_else(|| value.clone()),
})
.collect();
let kind = input_kind(c);
let value = format_for_input(&raw, c.ty);
let value_url = if (kind == "file" || kind == "image") && !value.is_empty() {
umbral::storage::storage_opt()
.map(|s| s.url(&value))
.unwrap_or_else(|| value.clone())
} else {
String::new()
};
FormField {
name: c.name.clone(),
kind,
value,
nullable: c.nullable,
readonly: is_readonly,
fk_table,
is_password: false,
choices,
help: c.help.clone(),
widget: c.widget.clone().unwrap_or_default(),
value_url,
error: String::new(),
}
})
.collect();
if let Some(c) = cfg {
if let Some(ref pw_col) = c.password_field {
if prefill.is_none() {
result.push(FormField {
name: pw_col.clone(),
kind: "password",
value: String::new(),
nullable: false,
readonly: false,
fk_table: String::new(),
is_password: true,
choices: Vec::new(),
help: String::new(),
widget: String::new(),
value_url: String::new(),
error: String::new(),
});
}
}
}
result
}
pub(crate) fn validate_form(
model: &ModelMeta,
form: &HashMap<String, String>,
cfg: Option<&AdminConfig>,
) -> std::collections::BTreeMap<String, String> {
let mut errors = std::collections::BTreeMap::new();
let fields = form_fields_for(model, Some(form), cfg);
for field in &fields {
if field.is_password || field.kind == "password" {
continue;
}
if field.readonly {
continue;
}
if field.kind == "file" || field.kind == "image" {
continue;
}
let raw = form.get(&field.name).map(|s| s.as_str()).unwrap_or("");
let value = raw.trim();
let col = model.fields.iter().find(|c| c.name == field.name);
let required = match col {
Some(c) => !c.nullable && c.default.is_empty(),
None => false,
};
if required && value.is_empty() && field.kind != "bool" {
errors.insert(field.name.clone(), "This field is required.".to_string());
continue;
}
if value.is_empty() {
continue;
}
match field.kind {
"number" => {
let is_float = matches!(
col.map(|c| c.ty),
Some(SqlType::Real) | Some(SqlType::Double) | Some(SqlType::Decimal)
);
let ok = if is_float {
value.parse::<f64>().is_ok()
} else {
value.parse::<i64>().is_ok()
};
if !ok {
errors.insert(field.name.clone(), "Enter a valid number.".to_string());
}
}
"date" => {
if chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_err() {
errors.insert(field.name.clone(), "Enter a valid date.".to_string());
}
}
"time" => {
if !valid_time(value) {
errors.insert(field.name.clone(), "Enter a valid time.".to_string());
}
}
"datetime-local" => {
if !valid_datetime_local(value) {
errors.insert(
field.name.clone(),
"Enter a valid date and time.".to_string(),
);
}
}
"select" => {
if !field.choices.iter().any(|c| c.value == value) {
errors.insert(field.name.clone(), "Select a valid option.".to_string());
}
}
_ => {}
}
if let Some(c) = col {
if c.max_length > 0 && value.chars().count() > c.max_length as usize {
errors
.entry(field.name.clone())
.or_insert_with(|| format!("Must be at most {} characters.", c.max_length));
}
}
}
errors
}
fn valid_time(value: &str) -> bool {
chrono::NaiveTime::parse_from_str(value, "%H:%M:%S").is_ok()
|| chrono::NaiveTime::parse_from_str(value, "%H:%M").is_ok()
}
fn valid_datetime_local(value: &str) -> bool {
chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S").is_ok()
|| chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M").is_ok()
}
pub(crate) const M2M_OPTION_CAP: u64 = 200;
#[derive(Debug, Clone, Serialize)]
pub(crate) struct M2MFormField {
pub name: String,
pub label: String,
pub junction_table: String,
pub candidates: Vec<M2MCandidate>,
pub selected_values: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct M2MCandidate {
pub value: String,
pub label: String,
}
pub(crate) async fn form_m2m_fields_for(
parent: &ModelMeta,
parent_pk_value: Option<&str>,
) -> Vec<M2MFormField> {
if parent.m2m_relations.is_empty() {
return Vec::new();
}
let mut out = Vec::with_capacity(parent.m2m_relations.len());
let parent_pk_col = parent.fields.iter().find(|c| c.primary_key);
for rel in &parent.m2m_relations {
let Some(target) = umbral::migrate::registered_models()
.into_iter()
.find(|m| m.table == rel.target_table)
else {
continue;
};
let Some(child_pk_col) = target.fields.iter().find(|c| c.primary_key) else {
continue;
};
let label_col_name = target
.fields
.iter()
.find(|c| c.is_string_repr)
.map(|c| c.name.clone())
.unwrap_or_else(|| child_pk_col.name.clone());
let select_cols = if label_col_name == child_pk_col.name {
vec![child_pk_col.name.clone()]
} else {
vec![child_pk_col.name.clone(), label_col_name.clone()]
};
let candidate_rows = match umbral::orm::DynQuerySet::for_meta(&target)
.select_cols(&select_cols)
.limit(M2M_OPTION_CAP)
.fetch_as_strings()
.await
{
Ok(rows) => rows,
Err(_) => Vec::new(),
};
let mut candidates: Vec<M2MCandidate> = candidate_rows
.into_iter()
.filter_map(|row| {
let value = row.get(&child_pk_col.name).cloned()?;
let label = row
.get(&label_col_name)
.cloned()
.unwrap_or_else(|| value.clone());
Some(M2MCandidate { value, label })
})
.collect();
let selected_values: Vec<String> = match (parent_pk_col, parent_pk_value) {
(Some(pk_col), Some(pk_str)) => {
let junction_table = format!("{}_{}", parent.table, rel.field_name);
let parent_value = match umbral::orm::write::json_to_sea_value(
pk_col.ty,
&serde_json::Value::String(pk_str.to_string()),
false,
&pk_col.name,
None,
) {
Ok(v) => v,
Err(_) => continue,
};
match umbral::orm::load_junction_selection(
&junction_table,
parent_value,
child_pk_col.ty,
Some(parent.name.as_str()),
)
.await
{
Ok(v) => v,
Err(_) => Vec::new(),
}
}
_ => Vec::new(),
};
if !selected_values.is_empty() {
let in_candidates: std::collections::HashSet<&str> =
candidates.iter().map(|c| c.value.as_str()).collect();
let missing: Vec<String> = selected_values
.iter()
.filter(|v| !in_candidates.contains(v.as_str()))
.cloned()
.collect();
if !missing.is_empty() {
let extra_rows = umbral::orm::DynQuerySet::for_meta(&target)
.select_cols(&select_cols)
.filter_in_strings(&child_pk_col.name, &missing)
.fetch_as_strings()
.await
.unwrap_or_default();
for row in extra_rows {
let Some(value) = row.get(&child_pk_col.name).cloned() else {
continue;
};
let label = row
.get(&label_col_name)
.cloned()
.unwrap_or_else(|| value.clone());
candidates.push(M2MCandidate { value, label });
}
}
}
out.push(M2MFormField {
name: rel.field_name.clone(),
label: rel.field_name.clone(),
junction_table: format!("{}_{}", parent.table, rel.field_name),
candidates,
selected_values,
});
}
out
}
pub(crate) fn format_for_input(raw: &str, ty: SqlType) -> String {
if raw.is_empty() {
return String::new();
}
match ty {
SqlType::Timestamptz => match chrono::DateTime::parse_from_rfc3339(raw) {
Ok(dt) => {
let utc = dt.with_timezone(&chrono::Utc);
let local = umbral::timezone::utc_to_naive_local(utc);
local.format("%Y-%m-%dT%H:%M").to_string()
}
Err(_) => raw.to_string(),
},
SqlType::Time => {
if let Some(dot) = raw.find('.') {
raw[..dot].to_string()
} else {
raw.to_string()
}
}
_ => raw.to_string(),
}
}
pub(crate) fn input_kind(col: &umbral::migrate::Column) -> &'static str {
if !col.is_multichoice && col.choices.is_empty() && !matches!(col.ty, SqlType::ForeignKey) {
match col.widget.as_deref() {
Some("markdown") => return "markdown",
Some("rte") => return "rte",
Some("code") => return "code",
Some("textarea") => return "textarea",
Some("file") => return "file",
Some("image") => return "image",
_ => {}
}
}
if col.is_multichoice {
return "multiselect";
}
if !col.choices.is_empty() {
return "select";
}
match col.ty {
SqlType::SmallInt
| SqlType::Integer
| SqlType::BigInt
| SqlType::Real
| SqlType::Double => "number",
SqlType::Boolean => "bool",
SqlType::Text => {
if col.max_length > 0 {
"text"
} else {
"textarea"
}
}
SqlType::Uuid => "text",
SqlType::Date => "date",
SqlType::Time => "time",
SqlType::Timestamptz => "datetime-local",
SqlType::Json => "json",
SqlType::Array(_) => "json",
SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => "text",
SqlType::FullText => "textarea",
SqlType::Xml => "textarea",
SqlType::Ltree | SqlType::Bit => "text",
SqlType::ForeignKey => "fk",
SqlType::Bytes => "text",
SqlType::Decimal => "text",
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ModelView {
pub name: String,
pub table: String,
pub fields: Vec<ColumnView>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ColumnView {
pub name: String,
pub nullable: bool,
pub primary_key: bool,
pub sql_type: String,
pub kind: String,
}
pub(crate) fn sql_type_name(ty: SqlType) -> &'static str {
match ty {
SqlType::SmallInt | SqlType::Integer => "integer",
SqlType::BigInt => "bigint",
SqlType::Real | SqlType::Double => "number",
SqlType::Boolean => "boolean",
SqlType::Text => "text",
SqlType::Date => "date",
SqlType::Time => "time",
SqlType::Timestamptz => "datetime",
SqlType::Uuid => "uuid",
SqlType::Json => "json",
SqlType::ForeignKey => "fk",
SqlType::Array(_) => "array",
SqlType::Inet | SqlType::Cidr | SqlType::MacAddr => "text",
SqlType::FullText => "text",
SqlType::Xml | SqlType::Ltree | SqlType::Bit => "text",
SqlType::Bytes => "bytes",
SqlType::Decimal => "decimal",
}
}
pub(crate) fn model_for_template(model: &ModelMeta) -> ModelView {
ModelView {
name: model.name.clone(),
table: model.table.clone(),
fields: model
.fields
.iter()
.map(|c| ColumnView {
name: c.name.clone(),
nullable: c.nullable,
primary_key: c.primary_key,
sql_type: sql_type_name(c.ty).to_string(),
kind: input_kind(c).to_string(),
})
.collect(),
}
}
pub(crate) fn model_for_template_cols(model: &ModelMeta, display_cols: &[String]) -> ModelView {
let valid: std::collections::HashSet<&str> =
model.fields.iter().map(|c| c.name.as_str()).collect();
let fields: Vec<ColumnView> = display_cols
.iter()
.filter(|n| valid.contains(n.as_str()))
.map(|n| {
let col = model.fields.iter().find(|c| &c.name == n).unwrap();
ColumnView {
name: col.name.clone(),
nullable: col.nullable,
primary_key: col.primary_key,
sql_type: sql_type_name(col.ty).to_string(),
kind: input_kind(col).to_string(),
}
})
.collect();
ModelView {
name: model.name.clone(),
table: model.table.clone(),
fields,
}
}
#[cfg(test)]
mod tests {
use super::{form_fields_for, format_for_input, input_kind};
use umbral::migrate::{Column, ModelMeta};
use umbral::orm::{FkAction, SqlType};
#[derive(Debug, Clone, Default, sqlx::FromRow, umbral::orm::Model, umbral::forms::Form)]
#[umbral(table = "repro_showcase")]
#[allow(dead_code, private_interfaces)]
struct Repro {
pub id: i64,
#[form(required, length(min = 2, max = 120))]
pub project_name: String,
#[form(optional, length(max = 20_000))]
#[umbral(widget = "markdown")]
pub long_content: Option<String>,
}
#[test]
fn showcase_long_content_renders_as_markdown_not_input() {
let meta = ModelMeta::for_::<Repro>();
let col = meta
.fields
.iter()
.find(|c| c.name == "long_content")
.expect("long_content column");
assert_eq!(
col.widget.as_deref(),
Some("markdown"),
"widget lost through Model+Form derive / for_()"
);
assert_eq!(
col.max_length, 0,
"form length must NOT leak into max_length"
);
assert_eq!(input_kind(col), "markdown", "field.kind should be markdown");
let fields = form_fields_for(&meta, None, None);
let f = fields
.iter()
.find(|f| f.name == "long_content")
.expect("long_content field");
assert_eq!(f.kind, "markdown", "rendered field.kind must be markdown");
assert_eq!(f.widget, "markdown");
}
fn col(name: &str, auto_now: bool, auto_now_add: bool, primary_key: bool) -> Column {
Column {
name: name.to_string(),
ty: SqlType::Timestamptz,
primary_key,
nullable: false,
fk_target: None,
noform: false,
db_constraint: true,
noedit: false,
is_string_repr: false,
max_length: 0,
choices: Vec::new(),
choice_labels: Vec::new(),
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: FkAction::NoAction,
on_update: FkAction::NoAction,
index: false,
auto_now_add,
auto_now,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: None,
slug_from: None,
}
}
fn meta(table: &str, fields: Vec<Column>) -> ModelMeta {
ModelMeta {
name: table.to_string(),
table: table.to_string(),
fields,
display: table.to_string(),
icon: "database".to_string(),
database: None,
singleton: false,
unique_together: Vec::new(),
indexes: Vec::new(),
ordering: Vec::new(),
m2m_relations: Vec::new(),
soft_delete: false,
app_label: "app".to_string(),
}
}
#[test]
fn form_excludes_auto_now_columns() {
let model = meta(
"customer",
vec![
col("id", false, false, true),
col("phone", false, false, false),
col("created_at", false, true, false),
col("updated_at", true, false, false),
],
);
let fields = form_fields_for(&model, None, None);
let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"phone"), "regular fields still surface");
assert!(
!names.contains(&"created_at"),
"auto_now_add column hidden from form; got {names:?}"
);
assert!(
!names.contains(&"updated_at"),
"auto_now column hidden from form; got {names:?}"
);
assert!(!names.contains(&"id"), "PK already excluded (sanity)");
}
#[test]
fn widget_and_help_reach_the_form_field() {
let mut body = col("body", false, false, false);
body.ty = SqlType::Text;
body.widget = Some("markdown".to_string());
body.help = "Markdown supported — headings, lists, code.".to_string();
let model = meta("post", vec![col("id", false, false, true), body]);
let fields = form_fields_for(&model, None, None);
let f = fields
.iter()
.find(|f| f.name == "body")
.expect("body field present");
assert_eq!(f.kind, "markdown", "widget drives the input kind");
assert_eq!(f.widget, "markdown", "raw widget name carried for JS");
assert_eq!(f.help, "Markdown supported — headings, lists, code.");
}
#[test]
fn unknown_widget_falls_back_to_type_kind() {
let mut c = col("blob", false, false, false);
c.ty = SqlType::Text; c.widget = Some("some-future-editor".to_string());
assert_eq!(input_kind(&c), "textarea");
}
#[test]
fn widget_applies_to_nullable_text_field() {
let mut c = col("long_content", false, false, false);
c.ty = SqlType::Text;
c.nullable = true;
c.widget = Some("markdown".to_string());
assert_eq!(input_kind(&c), "markdown");
}
#[test]
fn code_widget_selects_code_kind() {
let mut j = col("payload", false, false, false);
j.ty = SqlType::Json;
j.widget = Some("code".to_string());
assert_eq!(input_kind(&j), "code");
let mut s = col("config", false, false, false);
s.ty = SqlType::Text;
s.widget = Some("code".to_string());
assert_eq!(input_kind(&s), "code");
let mut plain = col("payload2", false, false, false);
plain.ty = SqlType::Json;
assert_eq!(input_kind(&plain), "json");
}
#[test]
fn format_for_input_coerces_rfc3339_to_datetime_local() {
let coerced = format_for_input("2026-05-30T12:00:00+00:00", SqlType::Timestamptz);
assert_eq!(coerced, "2026-05-30T12:00");
}
#[test]
fn format_for_input_handles_rfc3339_with_offset() {
let coerced = format_for_input("2026-05-30T17:00:00+05:00", SqlType::Timestamptz);
assert_eq!(coerced, "2026-05-30T12:00");
}
#[test]
fn format_for_input_empty_stays_empty() {
assert_eq!(format_for_input("", SqlType::Timestamptz), "");
assert_eq!(format_for_input("", SqlType::Time), "");
assert_eq!(format_for_input("", SqlType::Text), "");
}
#[test]
fn format_for_input_passes_through_simple_types() {
assert_eq!(format_for_input("2026-05-30", SqlType::Date), "2026-05-30");
assert_eq!(format_for_input("hello", SqlType::Text), "hello");
assert_eq!(format_for_input("42", SqlType::BigInt), "42");
}
#[test]
fn format_for_input_trims_subsecond_time() {
assert_eq!(format_for_input("12:34:56.789", SqlType::Time), "12:34:56");
assert_eq!(format_for_input("12:34:56", SqlType::Time), "12:34:56");
assert_eq!(format_for_input("12:34", SqlType::Time), "12:34");
}
#[test]
fn format_for_input_passes_through_bad_rfc3339_unchanged() {
let bad = "not-a-valid-timestamp";
assert_eq!(format_for_input(bad, SqlType::Timestamptz), bad);
}
#[test]
fn validate_form_collects_all_field_errors() {
let mut name = col("name", false, false, false);
name.ty = SqlType::Text;
name.nullable = false;
let mut age = col("age", false, false, false);
age.ty = SqlType::Integer;
age.nullable = false;
let model = meta("person", vec![col("id", false, false, true), name, age]);
let mut form = std::collections::HashMap::new();
form.insert("name".to_string(), "".to_string()); form.insert("age".to_string(), "abc".to_string());
let errors = super::validate_form(&model, &form, None);
assert_eq!(errors.len(), 2, "both fields must report, got {errors:?}");
assert_eq!(
errors.get("name").map(String::as_str),
Some("This field is required.")
);
assert_eq!(
errors.get("age").map(String::as_str),
Some("Enter a valid number.")
);
}
#[test]
fn validate_form_accepts_valid_submission() {
let mut name = col("name", false, false, false);
name.ty = SqlType::Text;
name.nullable = false;
let mut age = col("age", false, false, false);
age.ty = SqlType::Integer;
age.nullable = true;
let model = meta("person", vec![col("id", false, false, true), name, age]);
let mut form = std::collections::HashMap::new();
form.insert("name".to_string(), "Ada".to_string());
form.insert("age".to_string(), "42".to_string());
let errors = super::validate_form(&model, &form, None);
assert!(errors.is_empty(), "valid form should pass, got {errors:?}");
}
#[test]
fn validate_form_rejects_invalid_choice() {
let mut status = col("status", false, false, false);
status.ty = SqlType::Text;
status.nullable = false;
status.choices = vec!["draft".to_string(), "published".to_string()];
status.choice_labels = vec!["Draft".to_string(), "Published".to_string()];
let model = meta("post", vec![col("id", false, false, true), status]);
let mut bad = std::collections::HashMap::new();
bad.insert("status".to_string(), "archived".to_string());
let errors = super::validate_form(&model, &bad, None);
assert_eq!(
errors.get("status").map(String::as_str),
Some("Select a valid option.")
);
let mut good = std::collections::HashMap::new();
good.insert("status".to_string(), "draft".to_string());
assert!(super::validate_form(&model, &good, None).is_empty());
}
#[test]
fn validate_form_default_satisfies_required() {
let mut flag = col("flag", false, false, false);
flag.ty = SqlType::Text;
flag.nullable = false;
flag.default = "active".to_string();
let model = meta("thing", vec![col("id", false, false, true), flag]);
let mut form = std::collections::HashMap::new();
form.insert("flag".to_string(), "".to_string());
assert!(
super::validate_form(&model, &form, None).is_empty(),
"a column with a DEFAULT is not required"
);
}
}