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 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());
serde_json::json!({
"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,
"change_password_url": &state.config.change_password_url,
"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,
}),
(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.scope_visible(e.schema.scope))
.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)
}
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;
}
let entry = inventory_entries_dedup_by_table()
.into_iter()
.find(|e| e.schema.table == table)?;
if !state.scope_visible(entry.schema.scope) {
return None;
}
Some(entry.schema)
}
pub(crate) fn build_fk_joins(state: &AppState, model: &'static ModelSchema) -> Vec<Join> {
let admin_cfg = model
.admin
.copied()
.unwrap_or(crate::core::AdminConfig::DEFAULT);
if matches!(
admin_cfg.list_select_related,
crate::core::ListSelectRelated::None
) {
return Vec::new();
}
let whitelist: Option<&'static [&'static str]> = match admin_cfg.list_select_related {
crate::core::ListSelectRelated::Only(names) => Some(names),
_ => None,
};
let mut joins = Vec::new();
for field in model.scalar_fields() {
if let Some(allowed) = whitelist {
if !allowed.contains(&field.name) {
continue;
}
}
let Some(rel) = field.relation else { continue };
let (to, on) = match rel {
Relation::Fk { to, on } | Relation::O2O { to, on } => (to, on),
};
let Some(target) = lookup_model(state, to) else {
continue;
};
let Some(display_field) = target.display_field() else {
continue;
};
let alias = field.name;
joins.push(Join {
target,
alias,
kind: crate::core::JoinKind::Left,
on: crate::core::WhereExpr::ExprCompare {
lhs: crate::core::Expr::AliasedColumn {
alias: model.table,
column: field.column,
},
op: crate::core::Op::Eq,
rhs: crate::core::Expr::AliasedColumn { alias, column: on },
},
project: vec![display_field.column],
});
}
joins
}
pub(crate) fn pager_suffix(q: Option<&str>, filters: &[(&'static str, String)]) -> String {
let mut out = String::new();
if let Some(qs) = q {
out.push_str("&q=");
out.push_str(&url_encode(qs));
}
for (k, v) in filters {
out.push('&');
out.push_str(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,
) -> 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="/{to_esc}/{raw_esc}">{display}</a>"#),
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(),
&[],
)
}
#[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 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 value = prefill
.and_then(|m| m.get(f.name))
.map_or("", String::as_str);
let is_readonly_field = admin_cfg.readonly_fields.iter().any(|n| *n == f.name);
let extra = if f.primary_key {
" <small>(pk)</small>"
} else if is_readonly_field {
" <small>read-only</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 || (pk_locked && (f.primary_key || is_readonly_field));
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 {
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>(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.innerHTML=(j.results||[]).map(function(o){{return '<option value=\"'+o.id+'\">'+(o.text||o.id)+'</option>';}}).join('');"#,
r#" }}).catch(function(){{}});"#,
r#" }}"#,
r#" inp.addEventListener('input',refresh);"#,
r#" inp.addEventListener('focus',refresh);"#,
r#"}})();</script>"#,
),
datalist = datalist_id,
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 visible = |f: &&'static FieldSchema| -> bool {
if f.auto && !pk_locked {
return false;
}
if !f.editable {
return false;
}
true
};
let fieldsets_ctx: Vec<serde_json::Value> = if admin_cfg.fieldsets.is_empty() {
let rows: Vec<serde_json::Value> = model
.scalar_fields()
.filter(visible)
.map(row_for_field)
.collect();
vec![serde_json::json!({ "title": "", "rows": rows })]
} else {
admin_cfg
.fieldsets
.iter()
.map(|set| {
let rows: Vec<serde_json::Value> = set
.fields
.iter()
.filter_map(|name| model.field(name))
.filter(visible)
.map(row_for_field)
.collect();
serde_json::json!({ "title": set.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) 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,
)
}