use std::collections::HashMap;
use crate::core::{inventory, FieldSchema, Join, ModelEntry, ModelSchema, Relation};
#[allow(unused_imports)]
use crate::sql::sqlx;
use super::render;
#[allow(unused_imports)]
use super::templates::render_template;
use super::urls::AppState;
pub(crate) type FkMap = HashMap<(String, String), String>;
pub(crate) fn inventory_entries_dedup_by_table() -> Vec<&'static ModelEntry> {
let mut by_table: indexmap::IndexMap<&'static str, &'static ModelEntry> =
indexmap::IndexMap::new();
for entry in inventory::iter::<ModelEntry> {
let table = entry.schema.table;
match by_table.get(table) {
Some(existing) if existing.schema.fields.len() >= entry.schema.fields.len() => {
}
_ => {
by_table.insert(table, entry);
}
}
}
by_table.into_values().collect()
}
pub(crate) fn chrome_context(state: &AppState, active_table: Option<&str>) -> serde_json::Value {
let session = super::session::current();
chrome_context_with_session(state, active_table, session.as_ref())
}
pub(crate) fn csrf_input_for(csrf_token: &str) -> String {
if csrf_token.is_empty() {
String::new()
} else {
crate::forms::csrf::csrf_input_html(csrf_token)
}
}
#[cfg(feature = "totp")]
pub(crate) fn current_csrf_input() -> String {
csrf_input_for(&super::session::current_csrf_token().unwrap_or_default())
}
pub(crate) fn chrome_context_with_session(
state: &AppState,
active_table: Option<&str>,
session: Option<&super::session::AdminSession>,
) -> serde_json::Value {
let admin_title = state.config.title.as_deref().unwrap_or("Rustango Admin");
let brand_name = state.config.brand_name.as_deref().unwrap_or(admin_title);
let brand_tagline = state
.config
.brand_tagline
.as_deref()
.or(state.config.subtitle.as_deref());
let csrf_token = super::session::current_csrf_token().unwrap_or_default();
let csrf_input = csrf_input_for(&csrf_token);
serde_json::json!({
"csrf_token": csrf_token,
"csrf_input": csrf_input,
"sidebar_groups": sidebar_context(state, active_table),
"active_table": active_table.unwrap_or(""),
"admin_title": admin_title,
"admin_subtitle": state.config.subtitle.as_deref(),
"brand_name": brand_name,
"brand_tagline": brand_tagline,
"brand_logo_url": state.config.brand_logo_url.as_deref(),
"theme_mode": state.config.theme_mode.as_deref().unwrap_or("auto"),
"tenant_brand_css": state.config.tenant_brand_css.as_deref(),
"impersonated_by_operator_id": state.config.impersonated_by,
"admin_prefix": &state.config.admin_prefix,
"static_url": &state.config.static_url,
"audit_url": &state.config.audit_url,
"can_view_audit": state.audit_reader().is_some(),
"change_password_url": state.config.change_password_url.clone().or_else(|| {
state.config.session_secret.as_ref()
.map(|_| format!("{}/account/password", state.config.admin_prefix))
}),
"logout_url": state.config.logout_url.clone().unwrap_or_else(
|| format!("{}/logout", state.config.admin_prefix)
),
"session_user": match (session, state.config.session_secret.is_some()) {
(Some(s), _) => serde_json::json!({
"authenticated": true,
"username": s.username,
"is_superuser": s.is_superuser,
"impersonated_by": s.impersonated_by,
}),
(None, true) => serde_json::json!({ "authenticated": true }),
(None, false) => serde_json::Value::Null,
},
})
}
pub(crate) fn sidebar_context(
state: &AppState,
active_table: Option<&str>,
) -> Vec<serde_json::Value> {
let mut entries: Vec<&'static ModelEntry> = inventory_entries_dedup_by_table()
.into_iter()
.filter(|e| state.is_visible(e.schema.table))
.collect();
entries.sort_by_key(|e| e.schema.name);
let mut by_app: indexmap::IndexMap<String, Vec<&'static ModelEntry>> =
indexmap::IndexMap::new();
for e in entries {
let label = e
.resolved_app_label()
.map_or_else(|| "Project".to_owned(), str::to_owned);
by_app.entry(label).or_default().push(e);
}
let mut groups: Vec<(String, Vec<&'static ModelEntry>)> = by_app.into_iter().collect();
groups.sort_by(|a, b| match (a.0.as_str(), b.0.as_str()) {
("Project", _) => std::cmp::Ordering::Greater,
(_, "Project") => std::cmp::Ordering::Less,
_ => a.0.cmp(&b.0),
});
groups
.into_iter()
.map(|(label, items)| {
let models: Vec<serde_json::Value> = items
.into_iter()
.map(|e| {
serde_json::json!({
"name": e.schema.name,
"table": e.schema.table,
"active": active_table == Some(e.schema.table),
})
})
.collect();
serde_json::json!({ "app": label, "models": models })
})
.collect()
}
pub(crate) fn resolve_model(
state: &AppState,
table: &str,
) -> Result<&'static ModelSchema, crate::admin::errors::AdminError> {
lookup_model(state, table).ok_or_else(|| crate::admin::errors::AdminError::TableNotFound {
table: table.to_owned(),
})
}
pub(crate) fn resolve_model_and_pk(
state: &AppState,
table: &str,
pk_raw: &str,
) -> Result<
(
&'static ModelSchema,
&'static crate::core::FieldSchema,
crate::core::SqlValue,
),
crate::admin::errors::AdminError,
> {
let model = resolve_model(state, table)?;
let pk_field = primary_key_or_internal(model)?;
let pk_value = crate::forms::parse_pk_string(pk_field, pk_raw)
.map_err(crate::admin::errors::AdminError::Form)?;
Ok((model, pk_field, pk_value))
}
#[must_use]
pub(crate) fn admin_config_or_default(model: &'static ModelSchema) -> crate::core::AdminConfig {
model
.admin
.copied()
.unwrap_or(crate::core::AdminConfig::DEFAULT)
}
#[must_use]
pub(crate) fn is_secret_field(admin_cfg: &crate::core::AdminConfig, name: &str) -> bool {
admin_cfg
.formfield_overrides
.iter()
.any(|(f, w)| *f == name && *w == "password")
}
#[must_use]
pub(crate) fn search_columns(
model: &'static ModelSchema,
admin_cfg: &crate::core::AdminConfig,
) -> Vec<&'static str> {
let fields: Vec<&'static FieldSchema> = if admin_cfg.search_fields.is_empty() {
model.searchable_fields().collect()
} else {
admin_cfg
.search_fields
.iter()
.filter_map(|name| model.field(name))
.collect()
};
fields
.into_iter()
.filter(|f| !is_secret_field(admin_cfg, f.name))
.map(|f| f.column)
.collect()
}
#[must_use]
pub(crate) fn url_filterable(
model: &'static ModelSchema,
admin_cfg: &crate::core::AdminConfig,
field: &FieldSchema,
) -> bool {
if is_secret_field(admin_cfg, field.name) {
return false;
}
let named = |names: &[&str]| names.contains(&field.name);
named(admin_cfg.list_filter)
|| named(admin_cfg.list_display)
|| admin_cfg.list_display.is_empty()
|| field.relation.is_some()
|| super::inlines::is_parent_pin(model.table, field.column)
}
pub(crate) fn render_secret_cell(row: &serde_json::Value, field: &FieldSchema) -> String {
render_secret_value(row.get(field.name))
}
pub(crate) fn render_secret_value(value: Option<&serde_json::Value>) -> String {
let set = value
.and_then(serde_json::Value::as_str)
.is_some_and(|v| !v.is_empty());
if set {
"<em>set</em>"
} else {
"<em>not set</em>"
}
.to_owned()
}
pub(crate) fn primary_key_or_internal(
model: &'static ModelSchema,
) -> Result<&'static crate::core::FieldSchema, crate::admin::errors::AdminError> {
model.primary_key().ok_or_else(|| {
crate::admin::errors::AdminError::Internal(format!(
"model `{}` has no primary key",
model.name
))
})
}
pub(crate) fn lookup_model(state: &AppState, table: &str) -> Option<&'static ModelSchema> {
if !state.is_visible(table) {
return None;
}
served_entry(table).map(|e| e.schema)
}
pub(crate) fn served_entry(table: &str) -> Option<&'static ModelEntry> {
inventory::iter::<ModelEntry>
.into_iter()
.filter(|e| e.schema.table == table)
.reduce(|best, e| {
if e.schema.fields.len() > best.schema.fields.len() {
e
} else {
best
}
})
}
pub(crate) struct FkDisplay {
pub(crate) field: &'static FieldSchema,
pub(crate) to: &'static str,
pub(crate) target: &'static ModelSchema,
pub(crate) on: &'static str,
pub(crate) display_field: &'static FieldSchema,
}
pub(crate) fn fk_display_targets(state: &AppState, model: &'static ModelSchema) -> Vec<FkDisplay> {
let admin_cfg = admin_config_or_default(model);
let whitelist: Option<&'static [&'static str]> = match admin_cfg.list_select_related {
crate::core::ListSelectRelated::None => return Vec::new(),
crate::core::ListSelectRelated::Only(names) => Some(names),
_ => None,
};
model
.scalar_fields()
.filter(|f| whitelist.map_or(true, |allowed| allowed.contains(&f.name)))
.filter_map(|field| {
let (to, on) = match field.relation? {
Relation::Fk { to, on } | Relation::O2O { to, on } => (to, on),
};
let target = lookup_model(state, to)?;
Some(FkDisplay {
field,
to,
target,
on,
display_field: target.display_field()?,
})
})
.collect()
}
pub(crate) fn build_fk_joins(
state: &AppState,
model: &'static ModelSchema,
parts: &axum::http::request::Parts,
) -> Vec<Join> {
fk_display_targets(state, model)
.into_iter()
.filter(|t| !super::object_permissions::has_hook(t.target.table, "view"))
.map(|t| {
let alias = t.field.name;
Join {
target: t.target,
alias,
kind: crate::core::JoinKind::Left,
on: super::queryset_hooks::RowScope::of(t.target, parts).constrain(
crate::core::WhereExpr::ExprCompare {
lhs: crate::core::Expr::AliasedColumn {
alias: model.table,
column: t.field.column,
},
op: crate::core::Op::Eq,
rhs: crate::core::Expr::AliasedColumn {
alias,
column: t.on,
},
},
),
project: vec![t.display_field.column],
}
})
.collect()
}
#[derive(Clone, Debug)]
pub(crate) struct ListQuery {
base: String,
params: Vec<(String, String)>,
}
impl ListQuery {
pub(crate) fn new(base: String) -> Self {
Self {
base,
params: Vec::new(),
}
}
pub(crate) fn push(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.params.push((key.into(), value.into()));
}
#[must_use]
pub(crate) fn without(&self, keys: &[&str]) -> Self {
Self {
base: self.base.clone(),
params: self
.params
.iter()
.filter(|(k, _)| !keys.contains(&k.as_str()))
.cloned()
.collect(),
}
}
#[must_use]
pub(crate) fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.push(key, value);
self
}
pub(crate) fn pairs(&self) -> &[(String, String)] {
&self.params
}
pub(crate) fn url(&self) -> String {
if self.params.is_empty() {
return self.base.clone();
}
format!("{}?{}", self.base, &self.suffix()[1..])
}
pub(crate) fn suffix(&self) -> String {
let mut out = String::new();
for (k, v) in &self.params {
out.push('&');
out.push_str(&url_encode(k));
out.push('=');
out.push_str(&url_encode(v));
}
out
}
}
use crate::url_codec::url_encode;
pub(crate) fn fk_map_from_joined_rows_json(
state: &AppState,
model: &'static ModelSchema,
rows: &[serde_json::Value],
) -> FkMap {
let mut map: FkMap = HashMap::new();
for field in model.scalar_fields() {
let Some(rel) = field.relation else { continue };
let to = match rel {
Relation::Fk { to, .. } | Relation::O2O { to, .. } => to,
};
let Some(target) = lookup_model(state, to) else {
continue;
};
let Some(display_field) = target.display_field() else {
continue;
};
for row in rows {
let Some(source) = render::read_value_as_string_json(row, field) else {
continue;
};
let Some(display) =
render::read_joined_value_as_html_json(row, field.name, display_field)
else {
continue;
};
map.insert((to.to_owned(), source), display);
}
}
map
}
pub(crate) fn render_cell_json(
row: &serde_json::Value,
field: &FieldSchema,
fk_map: &FkMap,
admin_prefix: &str,
) -> String {
if let Some(rel) = field.relation {
let to = match rel {
Relation::Fk { to, .. } | Relation::O2O { to, .. } => to,
};
let Some(raw_value) = render::read_value_as_string_json(row, field) else {
return "<em>NULL</em>".to_owned();
};
let raw_esc = render::escape(&raw_value);
let to_esc = render::escape(to);
return match fk_map.get(&(to.to_owned(), raw_value)) {
Some(display) => format!(
r#"<a href="{prefix}/{to_esc}/{raw_esc}">{display}</a>"#,
prefix = render::escape(admin_prefix),
),
None => raw_esc,
};
}
render::render_value_json(row, field)
}
pub(crate) fn render_form(
state: &AppState,
model: &'static ModelSchema,
prefill: Option<&HashMap<String, String>>,
pk_locked: bool,
error_msg: Option<&str>,
) -> String {
render_form_with_inlines_and_pickers(
state,
model,
prefill,
pk_locked,
error_msg,
Vec::new(),
&[],
)
}
fn new_form_bool_value(f: &FieldSchema) -> &'static str {
match f.default.map(|d| d.trim_matches('\'').to_ascii_lowercase()) {
Some(d) if d == "true" || d == "1" => "true",
Some(d) if f.nullable && (d == "false" || d == "0") => "false",
_ => "",
}
}
#[allow(clippy::too_many_arguments)]
fn render_form_with_inlines_and_pickers(
state: &AppState,
model: &'static ModelSchema,
prefill: Option<&HashMap<String, String>>,
pk_locked: bool,
error_msg: Option<&str>,
inline_panels: Vec<super::inlines::InlineFormPanel>,
gfk_picker_cts: &[crate::contenttypes::ContentType],
) -> String {
let admin_prefix = state.config.admin_prefix.as_str();
let (action, edit_pk) = if pk_locked {
let pk_field = model.primary_key().expect("pk_locked requires a PK");
let pk_value = prefill
.and_then(|m| m.get(pk_field.name).cloned())
.unwrap_or_default();
(
format!(
"{admin_prefix}/{}/{}",
model.table,
render::escape(&pk_value)
),
Some(pk_value),
)
} else {
(format!("{admin_prefix}/{}", model.table), None)
};
let title = if pk_locked {
format!("Edit {}", model.display_label())
} else {
format!("New {}", model.display_label())
};
let admin_cfg = model
.admin
.copied()
.unwrap_or(crate::core::AdminConfig::DEFAULT);
let locked = state.locked_fields(
model,
if pk_locked {
super::object_permissions::AdminWrite::Change
} else {
super::object_permissions::AdminWrite::Add
},
);
let gfk_ct_columns: std::collections::HashSet<&'static str> = if gfk_picker_cts.is_empty() {
std::collections::HashSet::new()
} else {
model
.generic_relations
.iter()
.map(|gr| gr.ct_column)
.collect()
};
let row_for_field = |f: &'static FieldSchema| -> serde_json::Value {
let is_secret = is_secret_field(&admin_cfg, f.name);
let value = match prefill.and_then(|m| m.get(f.name)) {
_ if is_secret => "",
Some(v) => v.as_str(),
None if prefill.is_none() && f.ty == crate::core::FieldType::Bool => {
new_form_bool_value(f)
}
None => "",
};
let is_readonly_field = locked.contains(f.name);
let extra = if f.primary_key {
" <small>(pk)</small>"
} else if is_readonly_field {
" <small>read-only</small>"
} else if is_secret && pk_locked {
" <small>leave empty to keep</small>"
} else if f.auto {
" <small>auto</small>"
} else if gfk_ct_columns.contains(f.column) {
" <small>generic FK</small>"
} else if !f.nullable {
" <small>required</small>"
} else {
""
};
let lock_input = f.auto || is_readonly_field || (pk_locked && f.primary_key);
let widget_override = admin_cfg
.formfield_overrides
.iter()
.find(|(name, _)| *name == f.name)
.map(|(_, widget)| *widget);
let mut input_html = if gfk_ct_columns.contains(f.column) {
render::render_gfk_select(f, value, lock_input, gfk_picker_cts)
} else if is_secret {
render::render_secret_input(f, lock_input, !pk_locked)
} else {
render::render_input_with_widget(f, value, lock_input, widget_override)
};
if admin_cfg.raw_id_fields.iter().any(|n| *n == f.name) {
if let Some(rel) = f.relation {
let target_table = match rel {
crate::core::Relation::Fk { to, .. }
| crate::core::Relation::O2O { to, .. } => to,
};
let lookup_url = format!("{}/{}", admin_prefix, render::escape(target_table));
use std::fmt::Write as _;
let _ = write!(
input_html,
r#" <a class="raw-id-lookup" href="{lookup_url}" target="_blank" rel="noopener" title="Look up {label}">🔍</a>"#,
label = render::escape(f.display_label()),
);
}
}
if admin_cfg.autocomplete_fields.iter().any(|n| *n == f.name) {
if let Some(rel) = f.relation {
let target_table = match rel {
crate::core::Relation::Fk { to, .. }
| crate::core::Relation::O2O { to, .. } => to,
};
let escaped_target = render::escape(target_table);
let escaped_name = render::escape(f.name);
let datalist_id = format!("{escaped_name}_options");
let needle = format!(r#"name="{escaped_name}""#);
let replacement =
format!(r#"name="{escaped_name}" list="{datalist_id}" autocomplete="off""#);
input_html = input_html.replacen(&needle, &replacement, 1);
use std::fmt::Write as _;
let _ = write!(
input_html,
concat!(
r#" <datalist id="{datalist}"></datalist>"#,
r#"<script{nonce}>(function(){{"#,
r#" var inp=document.querySelector('input[name="{name}"]');"#,
r#" if(!inp)return;"#,
r#" var dl=document.getElementById('{datalist}');"#,
r#" var url='{prefix}/{target}/__autocomplete';"#,
r#" function refresh(){{"#,
r#" fetch(url+'?q='+encodeURIComponent(inp.value)).then(function(r){{return r.json();}}).then(function(j){{"#,
r#" dl.replaceChildren.apply(dl,(j.results||[]).map(function(o){{var op=document.createElement('option');op.value=o.id;op.textContent=o.text||o.id;return op;}}));"#,
r#" }}).catch(function(){{}});"#,
r#" }}"#,
r#" inp.addEventListener('input',refresh);"#,
r#" inp.addEventListener('focus',refresh);"#,
r#"}})();</script>"#,
),
datalist = datalist_id,
nonce = crate::csp_nonce::nonce_attr(),
name = escaped_name,
prefix = render::escape(admin_prefix),
target = escaped_target,
);
}
}
serde_json::json!({
"label": f.display_label(),
"extra": extra,
"input": input_html,
"help_text": f.help_text,
})
};
let fieldsets_ctx: Vec<serde_json::Value> = FormLayout::of(model, &admin_cfg, pk_locked)
.groups
.into_iter()
.map(|(title, fields)| {
let rows: Vec<serde_json::Value> = fields.into_iter().map(row_for_field).collect();
serde_json::json!({ "title": title, "rows": rows })
})
.collect();
let inline_form_panels_ctx: Vec<serde_json::Value> = inline_panels
.into_iter()
.map(|p| serde_json::to_value(p).unwrap_or(serde_json::Value::Null))
.collect();
let prepopulated_ctx: Vec<serde_json::Value> = admin_cfg
.prepopulated_fields
.iter()
.filter_map(|p| {
let target = model.field(p.target)?;
if !target.editable || target.auto {
return None;
}
let sources: Vec<&str> = p
.sources
.iter()
.filter_map(|src| model.field(src).map(|f| f.name))
.collect();
if sources.is_empty() {
return None;
}
Some(serde_json::json!({
"target": target.name,
"sources": sources,
}))
})
.collect();
let mut ctx = serde_json::json!({
"model": {
"name": model.name,
"table": model.table,
"label": model.display_label(),
"label_plural": model.display_label_plural(),
},
"title": title,
"action": action,
"edit_pk": edit_pk,
"error": error_msg,
"fieldsets": fieldsets_ctx,
"inline_form_panels": inline_form_panels_ctx,
"prepopulated_fields": prepopulated_ctx,
"prepopulated_active": !pk_locked && !prepopulated_ctx.is_empty(),
});
super::templates::render_with_chrome(
"form.html",
&mut ctx,
chrome_context(state, Some(model.table)),
)
}
pub(crate) struct FormLayout {
pub(crate) groups: Vec<(&'static str, Vec<&'static FieldSchema>)>,
}
impl FormLayout {
pub(crate) fn of(
model: &'static ModelSchema,
admin_cfg: &crate::core::AdminConfig,
pk_locked: bool,
) -> Self {
let visible = |f: &&'static FieldSchema| f.editable && (pk_locked || !f.auto);
let groups = if admin_cfg.fieldsets.is_empty() {
vec![("", model.scalar_fields().filter(visible).collect())]
} else {
admin_cfg
.fieldsets
.iter()
.map(|set| {
let fields = set
.fields
.iter()
.filter_map(|name| model.field(name))
.filter(visible)
.collect();
(set.title, fields)
})
.collect()
};
Self { groups }
}
pub(crate) fn unrendered(&self, model: &'static ModelSchema) -> Vec<&'static str> {
model
.scalar_fields()
.filter(|f| {
!f.auto
&& !self
.groups
.iter()
.any(|(_, g)| g.iter().any(|r| r.name == f.name))
})
.map(|f| f.name)
.collect()
}
}
pub(crate) fn render_form_with_inlines_and_picker(
state: &AppState,
model: &'static ModelSchema,
prefill: Option<&HashMap<String, String>>,
pk_locked: bool,
error_msg: Option<&str>,
inline_panels: Vec<super::inlines::InlineFormPanel>,
gfk_picker_cts: &[crate::contenttypes::ContentType],
) -> String {
render_form_with_inlines_and_pickers(
state,
model,
prefill,
pk_locked,
error_msg,
inline_panels,
gfk_picker_cts,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::FieldType;
#[test]
fn fk_cell_links_under_the_admin_prefix() {
let mut f = FieldSchema::new("author", "author_id", FieldType::I64);
f.relation = Some(Relation::Fk {
to: "author",
on: "id",
});
let row = serde_json::json!({ "author": 7 });
let mut fk_map = FkMap::new();
fk_map.insert(("author".into(), "7".into()), "Ann".into());
assert_eq!(
render_cell_json(&row, &f, &fk_map, "/adm"),
r#"<a href="/adm/author/7">Ann</a>"#
);
}
#[test]
fn new_form_bool_preselects_the_default() {
let mut f = FieldSchema::new("flag", "flag", FieldType::Bool);
f.nullable = true;
f.default = Some("false");
assert_eq!(new_form_bool_value(&f), "false");
f.default = Some("0");
assert_eq!(new_form_bool_value(&f), "false");
f.default = None;
assert_eq!(new_form_bool_value(&f), "");
f.default = Some("TRUE");
assert_eq!(new_form_bool_value(&f), "true");
f.nullable = false;
f.default = Some("false");
assert_eq!(new_form_bool_value(&f), "");
}
#[test]
fn list_query_drops_and_adds_keys() {
let mut q = ListQuery::new("/adm/t".into());
q.push("q", "a b");
q.push("year", "2024");
assert_eq!(q.url(), "/adm/t?q=a%20b&year=2024");
assert_eq!(q.without(&["q", "year"]).url(), "/adm/t");
assert_eq!(q.without(&["year"]).with("k", "v").suffix(), "&q=a%20b&k=v");
}
}