use std::collections::HashMap;
use crate::core::{
Assignment, CountQuery, DeleteQuery, FieldSchema, Filter, InsertQuery, ModelEntry, Op,
SearchClause, SelectQuery, SqlValue, UpdateQuery, WhereExpr,
};
use axum::extract::{Form, Path, Query, State};
use axum::response::{Html, IntoResponse, Redirect, Response};
use super::errors::AdminError;
use super::forms;
use super::helpers::{
admin_config_or_default, build_fk_joins, chrome_context, fk_map_from_joined_rows_json,
is_secret_field, lookup_model, primary_key_or_internal, render_cell_json, render_form,
render_secret_cell, resolve_model, resolve_model_and_pk, url_filterable, FormLayout, ListQuery,
};
use super::queryset_hooks::RowScope;
use super::render;
use super::templates::render_with_chrome;
use super::urls::{AppState, CREATE_SEGMENT};
fn render_json_path_cell(
row: &serde_json::Value,
field: &'static crate::core::FieldSchema,
key: &str,
) -> String {
let mut node = match row.get(field.column).or_else(|| row.get(field.name)) {
Some(v) => v,
None => return "<em>NULL</em>".to_owned(),
};
for seg in key.split('.') {
if seg.is_empty() {
continue;
}
let next = if let Ok(idx) = seg.parse::<usize>() {
node.as_array().and_then(|a| a.get(idx))
} else {
node.as_object().and_then(|o| o.get(seg))
};
node = match next {
Some(n) => n,
None => return "<em>NULL</em>".to_owned(),
};
}
match node {
serde_json::Value::Null => "<em>NULL</em>".to_owned(),
serde_json::Value::String(s) => render::escape(s),
serde_json::Value::Bool(true) => {
r#"<span class="rcms-bool yes" aria-label="true">☑</span>"#.to_owned()
}
serde_json::Value::Bool(false) => {
r#"<span class="rcms-bool no" aria-label="false">☐</span>"#.to_owned()
}
serde_json::Value::Number(n) => n.to_string(),
other => render::escape(&other.to_string()),
}
}
fn render_gfk_cell(
row: &serde_json::Value,
gr: &crate::core::GenericRelation,
ct_map: &HashMap<i64, crate::contenttypes::ContentType>,
admin_prefix: &str,
) -> String {
let ct_id = row
.get(gr.ct_column)
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
let object_pk = row
.get(gr.pk_column)
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
if ct_id == 0 && object_pk == 0 {
return "<em>NULL</em>".to_owned();
}
let Some(ct) = ct_map.get(&ct_id) else {
return format!("<em>(ct={ct_id}, pk={object_pk})</em>");
};
let label = format!("{}.{}", ct.app_label, ct.model_name);
let table_esc = render::escape(&ct.table);
let label_esc = render::escape(&label);
format!(
r#"<a href="{prefix}/{table}/{pk}">{label} #{pk}</a>"#,
prefix = render::escape(admin_prefix),
table = table_esc,
pk = object_pk,
label = label_esc,
)
}
pub(crate) async fn index(State(state): State<AppState>) -> Html<String> {
let mut entries: Vec<&'static ModelEntry> = super::helpers::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),
});
let groups_ctx: Vec<serde_json::Value> = groups
.into_iter()
.map(|(label, items)| {
let models_ctx: Vec<serde_json::Value> = items
.into_iter()
.map(|e| {
serde_json::json!({
"name": e.schema.name,
"table": e.schema.table,
"field_count": e.schema.scalar_fields().count(),
})
})
.collect();
serde_json::json!({ "app": label, "models": models_ctx })
})
.collect();
let flat_models_ctx: Vec<serde_json::Value> = groups_ctx
.iter()
.flat_map(|g| {
g.get("models")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
})
.collect();
let recent = match state.audit_reader() {
Some(reader) => reader
.list(&crate::audit::AuditFilter::default(), 10, 0)
.await
.unwrap_or_default(),
None => Vec::new(),
};
let recent_actions_ctx: Vec<serde_json::Value> = recent
.into_iter()
.map(|entry| {
let action_url = format!(
"{}/{}/{}",
state.config.admin_prefix,
crate::url_codec::url_encode(&entry.entity_table),
crate::url_codec::url_encode(&entry.entity_pk),
);
serde_json::json!({
"table": entry.entity_table,
"pk": entry.entity_pk,
"operation": entry.operation,
"source": entry.source,
"occurred_at": entry.occurred_at.to_rfc3339(),
"url": action_url,
})
})
.collect();
let mut ctx = serde_json::json!({
"groups": groups_ctx,
"models": flat_models_ctx,
"recent_actions": recent_actions_ctx,
});
Html(render_with_chrome(
"index.html",
&mut ctx,
chrome_context(&state, None),
))
}
const DEFAULT_PAGE_SIZE: i64 = 50;
const RESERVED_PARAMS: &[&str] = &[
"page",
"q",
"facet_show_all",
"count",
"year",
"month",
"day",
"trashed",
];
const MAX_IN_KEYS: usize = 10_000;
const ISNULL_SUFFIX: &str = "__isnull";
fn facet_param(field: &str, key: &SqlValue, raw: &str) -> (String, String) {
if matches!(key, SqlValue::Null) {
(format!("{field}{ISNULL_SUFFIX}"), "1".to_owned())
} else {
(field.to_owned(), raw.to_owned())
}
}
const FACET_TRUNCATE: usize = 15;
#[allow(clippy::too_many_lines)] pub(crate) async fn table_view(
parts: axum::http::request::Parts,
Path(table): Path<String>,
Query(params): Query<HashMap<String, String>>,
State(state): State<AppState>,
) -> Result<Html<String>, AdminError> {
let model = resolve_model(&state, &table)?;
let pk_field = model.primary_key();
let admin_cfg = admin_config_or_default(model);
let page_size: i64 = if admin_cfg.list_per_page == 0 {
DEFAULT_PAGE_SIZE
} else {
admin_cfg.list_per_page as i64
};
let page = crate::list_params::parse_page(¶ms);
let offset = crate::list_params::page_offset(page, page_size);
let q = params
.get("q")
.map(String::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let mut field_filters: Vec<(&'static str, Filter)> = Vec::new();
let mut filters: Vec<Filter> = Vec::new();
let mut active_field_filters: Vec<(String, String)> = Vec::new();
let custom_filter_names: Vec<&'static str> = crate::admin::list_filters::for_table(model.table)
.map(|f| f.parameter_name)
.collect();
for (key, value) in ¶ms {
if RESERVED_PARAMS.contains(&key.as_str()) {
continue;
}
if custom_filter_names.contains(&key.as_str()) {
continue;
}
if value.is_empty() {
continue;
}
let (name, is_null) = match key.strip_suffix(ISNULL_SUFFIX) {
Some(base) => (base, true),
None => (key.as_str(), false),
};
let Some(field) = model
.field(name)
.filter(|f| url_filterable(model, &admin_cfg, f))
else {
continue;
};
let (op, v) = if is_null {
if value != "1" {
continue;
}
(Op::IsNull, SqlValue::Bool(true))
} else {
let Ok(v) = forms::parse_form_value(field, Some(value)) else {
continue;
};
(Op::Eq, v)
};
field_filters.push((
field.name,
Filter {
column: field.column,
op,
value: v,
},
));
active_field_filters.push((key.clone(), value.clone()));
}
active_field_filters.sort();
let mut active_custom_filters: Vec<(&'static str, String)> = Vec::new();
for cf in crate::admin::list_filters::for_table(model.table) {
if let Some(value) = params.get(cf.parameter_name) {
if value.is_empty() {
continue;
}
filters.extend((cf.to_filters)(value));
active_custom_filters.push((cf.parameter_name, value.clone()));
}
}
let trashed = model.soft_delete_column.is_some()
&& params.get("trashed").map(String::as_str) == Some("1");
let scope = if trashed {
RowScope::trashed(model, &parts)
} else {
RowScope::of(model, &parts)
};
filters.extend(scope.filters().iter().cloned());
let date_sel = crate::admin::date_hierarchy::DateSelection::parse(¶ms);
if !admin_cfg.date_hierarchy.is_empty() {
filters.extend(crate::admin::date_hierarchy::predicates(
model,
admin_cfg.date_hierarchy,
date_sel,
));
}
let search_columns: Vec<&'static str> = if admin_cfg.search_fields.is_empty() {
model.searchable_fields().map(|f| f.column).collect()
} else {
admin_cfg
.search_fields
.iter()
.filter_map(|name| model.field(name).map(|f| f.column))
.collect()
};
let search = q.as_ref().and_then(|qstr| {
if search_columns.is_empty() {
None
} else {
Some(SearchClause {
columns: search_columns.clone(),
query: qstr.clone(),
})
}
});
let where_clause = WhereExpr::and_predicates(
field_filters
.iter()
.map(|(_, f)| f.clone())
.chain(filters.iter().cloned())
.collect(),
);
let count_skipped = state.count_skipped_for_table(model.table)
|| matches!(
params.get("count").map(String::as_str),
Some("skip" | "0" | "false" | "no")
);
let total: i64 = if count_skipped {
0
} else {
crate::sql::count_rows_pool(
&state.pool,
&CountQuery {
model,
where_clause: where_clause.clone(),
search: search.clone(),
source: None,
},
)
.await?
};
let joins = build_fk_joins(&state, model, &parts);
let order_by = list_order_by(model, &admin_cfg);
let fetch_limit = if count_skipped {
page_size + 1
} else {
page_size
};
let scalar_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let mut rows = crate::sql::select_rows_as_json(
&state.pool,
&SelectQuery {
where_clause: where_clause.clone(),
search: search.clone(),
joins,
order_by,
limit: Some(fetch_limit),
offset: Some(offset),
..SelectQuery::new(model)
},
&scalar_fields,
)
.await?;
let has_next_skipped = if count_skipped && rows.len() as i64 > page_size {
rows.truncate(page_size as usize);
true
} else {
false
};
let fk_map = fk_map_from_joined_rows_json(&state, model, &rows);
let last_page = if count_skipped {
page
} else if total == 0 {
1
} else {
((total - 1) / page_size) + 1
};
let read_only = state.is_read_only(model.table);
enum DisplayItem {
Field(&'static FieldSchema),
Computed(&'static crate::admin::computed_fields::ComputedField),
GenericFk(&'static crate::core::GenericRelation),
JsonPath(&'static FieldSchema, &'static str),
}
let display_items: Vec<DisplayItem> = if admin_cfg.list_display.is_empty() {
model.scalar_fields().map(DisplayItem::Field).collect()
} else {
admin_cfg
.list_display
.iter()
.filter_map(|name| {
model
.field(name)
.map(DisplayItem::Field)
.or_else(|| {
crate::admin::computed_fields::find(model.table, name)
.map(DisplayItem::Computed)
})
.or_else(|| {
model
.generic_relations
.iter()
.find(|gr| gr.name == *name)
.map(DisplayItem::GenericFk)
})
.or_else(|| {
let (head, tail) = name.split_once('.')?;
let field = model.field(head)?;
if field.ty == crate::core::FieldType::Json {
Some(DisplayItem::JsonPath(field, tail))
} else {
None
}
})
.or_else(|| {
tracing::warn!(
table = model.table,
name = %name,
"list_display names `{name}`, which is not a field, a \
registered computed field, a generic_fk, or a dotted \
path into a JSON column on `{}` — the column is omitted. \
Check for a typo, a renamed field, or a \
register_admin_computed! that did not run.",
model.table,
);
None
})
})
.collect()
};
let gfk_ct_map: std::collections::HashMap<i64, crate::contenttypes::ContentType> = {
use std::collections::HashSet;
let mut needed: HashSet<i64> = HashSet::new();
for gr in model.generic_relations {
if display_items
.iter()
.any(|i| matches!(i, DisplayItem::GenericFk(g) if g.name == gr.name))
{
for row in &rows {
if let Some(id) = row.get(gr.ct_column).and_then(serde_json::Value::as_i64) {
needed.insert(id);
}
}
}
}
let mut map = std::collections::HashMap::with_capacity(needed.len());
for id in needed {
if let Ok(Some(ct)) = crate::contenttypes::ContentType::by_id(&state.pool, id).await {
map.insert(id, ct);
}
}
map
};
let columns_ctx: Vec<serde_json::Value> = display_items
.iter()
.map(|item| {
let label = match item {
DisplayItem::Field(f) => {
let caption = f.display_label();
if f.primary_key {
format!("{} <small>(pk)</small>", render::escape(caption))
} else {
render::escape(caption)
}
}
DisplayItem::Computed(m) => {
render::escape(if m.label.is_empty() { m.name } else { m.label })
}
DisplayItem::GenericFk(gr) => render::escape(gr.name),
DisplayItem::JsonPath(f, key) => {
render::escape(&format!("{}.{}", f.display_label(), key))
}
};
serde_json::json!({ "label": label })
})
.collect();
let link_columns: std::collections::HashSet<&str> =
admin_cfg.list_display_links.iter().copied().collect();
let cell_is_link: Vec<bool> = display_items
.iter()
.map(|item| {
let name = match item {
DisplayItem::Field(f) => f.name,
DisplayItem::Computed(m) => m.name,
DisplayItem::GenericFk(gr) => gr.name,
DisplayItem::JsonPath(_, _) => return false,
};
link_columns.contains(name)
})
.collect();
let rows_ctx: Vec<serde_json::Value> = rows
.iter()
.map(|row| {
let pk_raw = pk_field
.map(|pk| render::render_value_for_input_json(row, pk))
.unwrap_or_default();
let pk = if pk_raw.is_empty() {
None
} else {
Some(render::escape(&pk_raw))
};
let pk_path =
(!pk_raw.is_empty() && !trashed).then(|| crate::url_codec::url_encode(&pk_raw));
let detail_href = pk_path.as_ref().map(|pk| {
format!(
"{prefix}/{table}/{pk}",
prefix = state.config.admin_prefix,
table = model.table,
)
});
let cells: Vec<String> = display_items
.iter()
.enumerate()
.map(|(idx, item)| {
let inner = match item {
DisplayItem::Field(f) if is_secret_field(&admin_cfg, f.name) => {
render_secret_cell(row, f)
}
DisplayItem::Field(f) => {
render_cell_json(row, f, &fk_map, &state.config.admin_prefix)
}
DisplayItem::Computed(m) => (m.render)(row),
DisplayItem::GenericFk(gr) => {
render_gfk_cell(row, gr, &gfk_ct_map, &state.config.admin_prefix)
}
DisplayItem::JsonPath(f, key) => render_json_path_cell(row, f, key),
};
if let DisplayItem::Computed(cf) = item {
if let Some(link_fn) = cf.link {
if let Some(url) = link_fn(row) {
return format!(
"<a href=\"{href}\">{inner}</a>",
href = render::escape(&url),
);
}
}
}
match (
cell_is_link.get(idx).copied().unwrap_or(false),
&detail_href,
) {
(true, Some(href)) => format!(
"<a href=\"{href}\">{inner}</a>",
href = render::escape(href),
),
_ => inner,
}
})
.collect();
serde_json::json!({ "cells": cells, "pk": pk, "pk_path": pk_path })
})
.collect();
let active_filters_ctx: Vec<serde_json::Value> = active_field_filters
.iter()
.map(|(k, v)| serde_json::json!({ "key": k, "value": v }))
.collect();
let mut list_query = ListQuery::new(format!("{}/{}", state.config.admin_prefix, model.table));
if let Some(qv) = q.as_deref() {
list_query.push("q", qv);
}
for (k, v) in &active_field_filters {
list_query.push(k.clone(), v.clone());
}
for (k, v) in &active_custom_filters {
list_query.push(*k, v.clone());
}
if !admin_cfg.date_hierarchy.is_empty() {
push_date(&mut list_query, date_sel.year, date_sel.month, date_sel.day);
}
for key in ["count", "facet_show_all"] {
if let Some(v) = params.get(key).filter(|v| !v.is_empty()) {
list_query.push(key, v.clone());
}
}
if trashed {
list_query.push("trashed", "1");
}
let pager_suffix_str = list_query.suffix();
let hidden_params: Vec<serde_json::Value> = list_query
.without(&["q"])
.pairs()
.iter()
.map(|(k, v)| serde_json::json!({ "key": k, "value": v }))
.collect();
let show_all_facet = params.get("facet_show_all").map(String::as_str);
let facets_ctx: Vec<serde_json::Value> = compute_facets(
&state,
&parts,
model,
&field_filters,
&filters,
search.as_ref(),
&admin_cfg,
&active_field_filters,
&list_query,
show_all_facet,
)
.await?;
let date_hierarchy_ctx: Option<serde_json::Value> = if admin_cfg.date_hierarchy.is_empty() {
None
} else {
let source = SelectQuery {
where_clause: where_clause.clone(),
search: search.clone(),
..SelectQuery::new(model)
};
compute_date_hierarchy(&state, model, &admin_cfg, date_sel, &list_query, source).await?
};
let custom_filters_ctx: Vec<serde_json::Value> =
crate::admin::list_filters::for_table(model.table)
.map(|cf| {
let active_value: Option<&str> = active_custom_filters
.iter()
.find(|(k, _)| *k == cf.parameter_name)
.map(|(_, v)| v.as_str());
let cleared = list_query.without(&[cf.parameter_name]);
let clear_url = cleared.url();
let values: Vec<serde_json::Value> = cf
.lookups
.iter()
.map(|(value, label)| {
serde_json::json!({
"value": value,
"label": label,
"active": active_value == Some(*value),
"url": cleared.clone().with(cf.parameter_name, *value).url(),
})
})
.collect();
serde_json::json!({
"parameter_name": cf.parameter_name,
"title": cf.title,
"values": values,
"clear_url": clear_url,
})
})
.collect();
let actions_ctx: Vec<serde_json::Value> = admin_cfg
.actions
.iter()
.filter(|name| !trashed || **name == "restore_selected")
.map(|name| {
let label = match *name {
"delete_selected" => "Delete selected".to_owned(),
other => other.replace('_', " "),
};
serde_json::json!({ "name": name, "label": label })
})
.collect();
let mut ctx = serde_json::json!({
"model": {
"name": model.name,
"table": model.table,
"label": model.display_label(),
"label_plural": model.display_label_plural(),
},
"total": total,
"plural": if total == 1 { "" } else { "s" },
"read_only": read_only,
"can_add": state.can_add(model.table)
&& crate::admin::object_permissions::is_allowed(model.table, "add", &parts, None),
"has_searchable": !search_columns.is_empty(),
"search_help_text": admin_cfg.search_help_text,
"actions_on_top": admin_cfg.actions_on_top,
"actions_on_bottom": admin_cfg.actions_on_bottom,
"q": q.unwrap_or_default(),
"active_filters": active_filters_ctx,
"hidden_params": hidden_params,
"facets": facets_ctx,
"custom_filters": custom_filters_ctx,
"date_hierarchy": date_hierarchy_ctx,
"actions": actions_ctx,
"columns": columns_ctx,
"rows": rows_ctx,
"page": page,
"last_page": last_page,
"pager_suffix": pager_suffix_str,
"count_skipped": count_skipped,
"has_next": has_next_skipped,
"trashed": trashed,
"trash_toggle_url": model.soft_delete_column.map(|_| if trashed {
list_query.without(&["trashed", "page"]).url()
} else {
list_query.without(&["page"]).with("trashed", "1").url()
}),
});
Ok(Html(render_with_chrome(
"list.html",
&mut ctx,
chrome_context(&state, Some(model.table)),
)))
}
#[allow(clippy::too_many_arguments)]
async fn compute_facets(
state: &AppState,
parts: &axum::http::request::Parts,
model: &'static crate::core::ModelSchema,
field_filters: &[(&'static str, Filter)],
other_filters: &[Filter],
search: Option<&SearchClause>,
admin_cfg: &crate::core::AdminConfig,
active_field_filters: &[(String, String)],
list_query: &ListQuery,
show_all_facet: Option<&str>,
) -> Result<Vec<serde_json::Value>, AdminError> {
if admin_cfg.list_filter.is_empty() {
return Ok(Vec::new());
}
let mut out = Vec::with_capacity(admin_cfg.list_filter.len());
for filter_name in admin_cfg.list_filter {
let Some(field) = model.field(filter_name) else {
continue;
};
let isnull_key = format!("{}{ISNULL_SUFFIX}", field.name);
let facet_filters: Vec<Filter> = field_filters
.iter()
.filter(|(name, _)| *name != field.name)
.map(|(_, f)| f.clone())
.chain(other_filters.iter().cloned())
.collect();
let source = SelectQuery {
where_clause: WhereExpr::and_predicates(facet_filters),
search: search.cloned(),
..SelectQuery::new(model)
};
let is_bool = field.ty == crate::core::FieldType::Bool;
let mut facet_rows = fetch_facet_counts(state, source, field, is_bool).await?;
let fk_target = field.relation.and_then(|rel| match rel {
crate::core::Relation::Fk { to, on } | crate::core::Relation::O2O { to, on } => {
let target = lookup_model(state, to)?;
Some((target, target.field_by_column(on)?, target.display_field()?))
}
});
let is_fk = fk_target.is_some();
if let Some((target, on_field, display_field)) = fk_target {
let keys: Vec<SqlValue> = facet_rows
.iter()
.map(|r| r.key.clone())
.filter(|v| !matches!(v, SqlValue::Null))
.collect();
let names =
fk_display_names(state, parts, target, on_field, display_field, keys).await?;
for r in &mut facet_rows {
r.display = names.get(&r.raw).cloned();
}
facet_rows.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then_with(|| a.display.cmp(&b.display))
});
}
let mut values = Vec::with_capacity(facet_rows.len());
for FacetRow {
key,
raw,
display: display_text,
count,
} in &facet_rows
{
let raw = raw.clone();
let display = if raw.is_empty() {
"—".to_owned()
} else if let Some(d) = display_text.as_deref().filter(|s| !s.is_empty()) {
render::escape(d)
} else {
render::escape(&raw)
};
let count: i64 = *count;
let param = facet_param(field.name, key, &raw);
let is_active = active_field_filters.contains(¶m);
let mut toggle = list_query.without(&[field.name, &isnull_key]);
if !is_active {
toggle = toggle.with(param.0, param.1);
}
let toggle_url = toggle.url();
values.push(serde_json::json!({
"raw": raw,
"display": display,
"count": count,
"active": is_active,
"toggle_url": toggle_url,
}));
}
let show_all = show_all_facet == Some(field.name);
let total_values = values.len();
let mut more_count: usize = 0;
if !show_all && total_values > FACET_TRUNCATE {
let mut active_first: Vec<serde_json::Value> = Vec::new();
let mut rest: Vec<serde_json::Value> = Vec::new();
for v in values.into_iter() {
if v.get("active").and_then(|b| b.as_bool()).unwrap_or(false) {
active_first.push(v);
} else {
rest.push(v);
}
}
let cap = FACET_TRUNCATE.saturating_sub(active_first.len());
let kept_rest_len = rest.len().min(cap);
more_count = total_values - active_first.len() - kept_rest_len;
active_first.extend(rest.into_iter().take(cap));
values = active_first;
}
let show_all_url = if more_count > 0 {
Some(
list_query
.without(&["facet_show_all"])
.with("facet_show_all", field.name)
.url(),
)
} else {
None
};
let clear_url = if is_fk {
Some(list_query.without(&[field.name, &isnull_key]).url())
} else {
None
};
out.push(serde_json::json!({
"field": field.name,
"is_fk": is_fk,
"values": values,
"more_count": more_count,
"show_all_url": show_all_url,
"clear_url": clear_url,
}));
}
Ok(out)
}
struct FacetRow {
key: SqlValue,
raw: String,
display: Option<String>,
count: i64,
}
async fn fetch_facet_counts(
state: &AppState,
source: SelectQuery,
field: &'static FieldSchema,
is_bool: bool,
) -> Result<Vec<FacetRow>, AdminError> {
use crate::core::{AggregateExpr, AggregateQuery, Expr, OrderItem};
let mut agg = AggregateQuery::over_select(
source,
vec![("facet_count".into(), AggregateExpr::Count(None))],
);
agg.group_by = vec![field.column];
agg.order_by = vec![
OrderItem::expr(Expr::Aggregate(Box::new(AggregateExpr::Count(None))), true),
OrderItem::column(field.column, false),
];
let rows = crate::sql::fetch_aggregate_dict(&state.pool, &agg).await?;
Ok(rows
.into_iter()
.map(|mut row| {
let key = row.remove(field.column).unwrap_or(SqlValue::Null);
FacetRow {
raw: facet_value_string(&key, is_bool),
key,
display: None,
count: sql_int(row.get("facet_count")),
}
})
.collect())
}
async fn fk_display_names(
state: &AppState,
parts: &axum::http::request::Parts,
target: &'static crate::core::ModelSchema,
on_field: &'static FieldSchema,
display_field: &'static FieldSchema,
keys: Vec<SqlValue>,
) -> Result<HashMap<String, String>, AdminError> {
let scope = RowScope::of(target, parts);
let mut names = HashMap::new();
for chunk in keys.chunks(MAX_IN_KEYS) {
let rows = crate::sql::select_rows_as_json(
&state.pool,
&scope.by_pk_in(target, on_field.column, chunk.to_vec()),
&[on_field, display_field],
)
.await?;
names.extend(rows.iter().filter_map(|row| {
Some((
render::read_value_as_string_json(row, on_field)?,
render::read_value_as_string_json(row, display_field)?,
))
}));
}
Ok(names)
}
fn facet_value_string(v: &SqlValue, is_bool: bool) -> String {
match v {
SqlValue::Null => String::new(),
SqlValue::I16(n) if is_bool => (*n != 0).to_string(),
SqlValue::I32(n) if is_bool => (*n != 0).to_string(),
SqlValue::I64(n) if is_bool => (*n != 0).to_string(),
other => other.to_display_string(),
}
}
fn sql_int(v: Option<&SqlValue>) -> i64 {
match v {
Some(SqlValue::I64(n)) => *n,
Some(SqlValue::I32(n)) => i64::from(*n),
Some(SqlValue::I16(n)) => i64::from(*n),
_ => 0,
}
}
const MAX_YEAR_BUCKETS: i32 = 200;
async fn compute_date_hierarchy(
state: &AppState,
model: &'static crate::core::ModelSchema,
admin_cfg: &crate::core::AdminConfig,
sel: crate::admin::date_hierarchy::DateSelection,
list_query: &ListQuery,
source: SelectQuery,
) -> Result<Option<serde_json::Value>, AdminError> {
use crate::admin::date_hierarchy::{range, DateSelection, DrillLevel};
let field_name = admin_cfg.date_hierarchy;
let Some(field) = model.field(field_name) else {
return Ok(None);
};
if !matches!(
field.ty,
crate::core::FieldType::Date | crate::core::FieldType::DateTime
) {
return Ok(None);
}
let undated = list_query.without(&["year", "month", "day"]);
let url_for = |year: Option<i32>, month: Option<u32>, day: Option<u32>| -> String {
let mut q = undated.clone();
push_date(&mut q, year, month, day);
q.url()
};
const MONTH_NAMES: &[&str] = &[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
let mut crumbs: Vec<serde_json::Value> = vec![serde_json::json!({
"label": "All",
"url": url_for(None, None, None),
"active": sel.year.is_none(),
})];
if let Some(y) = sel.year {
crumbs.push(serde_json::json!({
"label": y.to_string(),
"url": url_for(Some(y), None, None),
"active": sel.month.is_none(),
}));
}
if let (Some(y), Some(m)) = (sel.year, sel.month) {
crumbs.push(serde_json::json!({
"label": MONTH_NAMES.get((m as usize).saturating_sub(1)).copied().unwrap_or(""),
"url": url_for(Some(y), Some(m), None),
"active": sel.day.is_none(),
}));
}
if let (Some(y), Some(m), Some(d)) = (sel.year, sel.month, sel.day) {
crumbs.push(serde_json::json!({
"label": d.to_string(),
"url": url_for(Some(y), Some(m), Some(d)),
"active": true,
}));
}
let Some(level) = DrillLevel::for_selection(sel) else {
return Ok(Some(serde_json::json!({
"field": field_name,
"crumbs": crumbs,
"buckets": Vec::<serde_json::Value>::new(),
})));
};
let children: Vec<DateSelection> = match level {
DrillLevel::Year => match year_span(state, &source, field).await? {
Some((lo, hi)) => (lo.max(hi - MAX_YEAR_BUCKETS + 1)..=hi)
.rev()
.map(|y| DateSelection {
year: Some(y),
month: None,
day: None,
})
.collect(),
None => Vec::new(),
},
DrillLevel::Month => (1..=12)
.map(|m| DateSelection {
month: Some(m),
..sel
})
.collect(),
DrillLevel::Day => (1..=31)
.map(|d| DateSelection {
day: Some(d),
..sel
})
.collect(),
};
let children: Vec<DateSelection> = children
.into_iter()
.filter(|c| range(*c).is_some())
.collect();
let bucket_of = |c: &DateSelection| match level {
DrillLevel::Year => c.year.unwrap_or(0),
DrillLevel::Month => c.month.map_or(0, |m| m as i32),
DrillLevel::Day => c.day.map_or(0, |d| d as i32),
};
let counts = bucket_counts(state, model, field_name, source, &children).await?;
let buckets_raw: Vec<(i32, i64)> = children
.iter()
.zip(counts)
.filter(|(_, n)| *n > 0)
.map(|(c, n)| (bucket_of(c), n))
.collect();
let buckets_ctx: Vec<serde_json::Value> = buckets_raw
.into_iter()
.filter_map(|(bucket, count)| {
let label;
let next_year;
let next_month;
let next_day;
match level {
DrillLevel::Year => {
label = bucket.to_string();
next_year = Some(bucket);
next_month = None;
next_day = None;
}
DrillLevel::Month => {
if !(1..=12).contains(&bucket) {
return None;
}
label = MONTH_NAMES
.get((bucket as usize).saturating_sub(1))
.copied()
.unwrap_or("")
.to_owned();
next_year = sel.year;
next_month = Some(bucket as u32);
next_day = None;
}
DrillLevel::Day => {
if !(1..=31).contains(&bucket) {
return None;
}
label = bucket.to_string();
next_year = sel.year;
next_month = sel.month;
next_day = Some(bucket as u32);
}
}
Some(serde_json::json!({
"label": label,
"value": bucket,
"count": count,
"url": url_for(next_year, next_month, next_day),
}))
})
.collect();
Ok(Some(serde_json::json!({
"field": field_name,
"crumbs": crumbs,
"buckets": buckets_ctx,
})))
}
async fn year_span(
state: &AppState,
source: &SelectQuery,
field: &'static FieldSchema,
) -> Result<Option<(i32, i32)>, AdminError> {
use crate::core::{AggregateExpr, AggregateQuery};
let agg = AggregateQuery::over_select(
source.clone(),
vec![
("lo".into(), AggregateExpr::Min(field.column)),
("hi".into(), AggregateExpr::Max(field.column)),
],
);
let row = crate::sql::fetch_aggregate_dict(&state.pool, &agg)
.await?
.into_iter()
.next();
let year = |key: &str| row.as_ref().and_then(|r| year_of(r.get(key)?));
Ok(year("lo").zip(year("hi")))
}
fn year_of(v: &SqlValue) -> Option<i32> {
use chrono::Datelike as _;
match v {
SqlValue::Date(d) => Some(d.year()),
SqlValue::DateTime(dt) => Some(dt.year()),
SqlValue::String(s) => s.get(..4)?.parse().ok(),
_ => None,
}
}
async fn bucket_counts(
state: &AppState,
model: &'static crate::core::ModelSchema,
field_name: &str,
source: SelectQuery,
children: &[crate::admin::date_hierarchy::DateSelection],
) -> Result<Vec<i64>, AdminError> {
if children.is_empty() {
return Ok(Vec::new());
}
let aggregates = children
.iter()
.enumerate()
.map(|(i, c)| {
let range = crate::admin::date_hierarchy::predicates(model, field_name, *c);
let count =
crate::core::aggregates::count_all().filter(WhereExpr::and_predicates(range));
(format!("b{i}").into(), count.into())
})
.collect();
let agg = crate::core::AggregateQuery::over_select(source, aggregates);
let row = crate::sql::fetch_aggregate_dict(&state.pool, &agg)
.await?
.into_iter()
.next()
.unwrap_or_default();
Ok((0..children.len())
.map(|i| sql_int(row.get(&format!("b{i}"))))
.collect())
}
fn list_order_by(
model: &'static crate::core::ModelSchema,
admin_cfg: &crate::core::AdminConfig,
) -> Vec<crate::core::OrderItem> {
let spec = if admin_cfg.ordering.is_empty() {
model.default_order
} else {
admin_cfg.ordering
};
let order = spec
.iter()
.filter_map(|(name, desc)| {
model
.field(name)
.or_else(|| model.field_by_column(name))
.map(|f| crate::core::OrderItem::column(f.column, *desc))
})
.collect();
model.with_pk_tiebreak(order)
}
pub(crate) fn post_save_redirect(
admin_prefix: &str,
table: &str,
pk_value: &str,
form: &HashMap<String, String>,
) -> String {
if form.contains_key("_continue") {
let pk = crate::url_codec::url_encode(pk_value);
format!("{admin_prefix}/{table}/{pk}")
} else if form.contains_key("_addanother") {
format!("{admin_prefix}/{table}/{CREATE_SEGMENT}")
} else {
format!("{admin_prefix}/{table}")
}
}
fn push_date(q: &mut ListQuery, year: Option<i32>, month: Option<u32>, day: Option<u32>) {
if let Some(y) = year {
q.push("year", y.to_string());
if let Some(m) = month {
q.push("month", m.to_string());
if let Some(d) = day {
q.push("day", d.to_string());
}
}
}
}
pub(crate) async fn autocomplete_view(
parts: axum::http::request::Parts,
Path(table): Path<String>,
Query(params): Query<HashMap<String, String>>,
State(state): State<AppState>,
) -> Result<axum::Json<serde_json::Value>, AdminError> {
let model = resolve_model(&state, &table)?;
let admin_cfg = admin_config_or_default(model);
let q = params
.get("q")
.map(String::as_str)
.unwrap_or("")
.trim()
.to_owned();
let limit: i64 = params
.get("limit")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(20)
.clamp(1, 100);
let pk_field = match model.primary_key() {
Some(f) => f,
None => {
return Ok(axum::Json(serde_json::json!({ "results": [] })));
}
};
let display_field = model.display_field().unwrap_or(pk_field);
let search_columns: Vec<&'static str> = if admin_cfg.search_fields.is_empty() {
model.searchable_fields().map(|f| f.column).collect()
} else {
admin_cfg
.search_fields
.iter()
.filter_map(|name| model.field(name).map(|f| f.column))
.collect()
};
let search = if q.is_empty() || search_columns.is_empty() {
None
} else {
Some(SearchClause {
columns: search_columns,
query: q.clone(),
})
};
let scalar_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let rows = crate::sql::select_rows_as_json(
&state.pool,
&SelectQuery {
where_clause: RowScope::of(model, &parts).constrain(WhereExpr::And(Vec::new())),
search,
order_by: vec![crate::core::OrderItem::column(display_field.column, false)],
limit: Some(limit),
offset: Some(0),
..SelectQuery::new(model)
},
&scalar_fields,
)
.await?;
let results: Vec<serde_json::Value> = rows
.into_iter()
.filter_map(|row| {
let id = row.get(pk_field.column)?.clone();
let text = row
.get(display_field.column)
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_else(|| id.to_string());
Some(serde_json::json!({ "id": id, "text": text }))
})
.collect();
Ok(axum::Json(serde_json::json!({ "results": results })))
}
pub(crate) async fn detail_view(
parts: axum::http::request::Parts,
Path((table, pk_raw)): Path<(String, String)>,
State(state): State<AppState>,
) -> Result<Html<String>, AdminError> {
let (model, pk_field, pk_value) = resolve_model_and_pk(&state, &table, &pk_raw)?;
let detail_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let row = crate::sql::select_one_row_as_json(
&state.pool,
&SelectQuery {
joins: build_fk_joins(&state, model, &parts),
..RowScope::of(model, &parts).by_pk(model, pk_field.column, pk_value.clone())
},
&detail_fields,
)
.await?
.ok_or(AdminError::RowNotFound {
table: table.clone(),
pk: pk_raw.clone(),
})?;
if !crate::admin::object_permissions::is_allowed(model.table, "view", &parts, Some(&row)) {
return Err(AdminError::Forbidden {
table: model.table.to_owned(),
action: "view",
});
}
let fk_map = fk_map_from_joined_rows_json(&state, model, std::slice::from_ref(&row));
let detail_cfg = admin_config_or_default(model);
let mut cells_ctx: Vec<serde_json::Value> = model
.scalar_fields()
.map(|f| {
let value = if is_secret_field(&detail_cfg, f.name) {
render_secret_cell(&row, f)
} else {
render_cell_json(&row, f, &fk_map, &state.config.admin_prefix)
};
serde_json::json!({ "label": f.display_label(), "value": value })
})
.collect();
for cf in crate::admin::computed_fields::for_table(model.table) {
cells_ctx.push(serde_json::json!({
"label": if cf.label.is_empty() { cf.name } else { cf.label },
"value": (cf.render)(&row),
}));
}
for gfk in model.generic_relations {
let ct_id = row
.get(gfk.ct_column)
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
let object_pk = row
.get(gfk.pk_column)
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
let g = crate::contenttypes::GenericForeignKey::new(ct_id, object_pk);
let html = crate::contenttypes::render_generic_fk_link(&state.pool, g)
.await
.unwrap_or_else(|_| format!("<em>(ct={ct_id}, pk={object_pk})</em>"));
cells_ctx.push(serde_json::json!({
"label": gfk.name,
"value": html,
}));
}
let mut inline_panels =
super::inlines::render_for_parent_in(&state.pool, model, pk_value.clone(), Some(&parts))
.await
.unwrap_or_default();
let generic_panels =
super::inlines::render_generic_for_parent_in(&state.pool, model, pk_value, Some(&parts))
.await
.unwrap_or_default();
inline_panels.extend(generic_panels);
inline_panels.retain(|p| lookup_model(&state, &p.child_table).is_some());
let inline_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 audit_entries = match state.audit_reader() {
Some(reader) => reader.for_entity(model.table, &pk_raw).await,
None => Ok(Vec::new()),
};
let audit_entries_ctx: Vec<serde_json::Value> = match audit_entries {
Ok(entries) => entries
.into_iter()
.map(|e| {
let (action_name, cleaned) = super::audit::split_action_marker(&e.changes);
serde_json::json!({
"id": e.id,
"operation": e.operation,
"action_name": action_name,
"source": e.source,
"occurred_at": e.occurred_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
"changes": serde_json::to_string_pretty(&cleaned)
.unwrap_or_default(),
})
})
.collect(),
Err(_) => Vec::new(),
};
#[cfg(feature = "tenancy")]
let user_roles_ctx: Option<serde_json::Value> = if model.table == "rustango_users" {
user_roles_panel_ctx(&state, &pk_raw).await
} else {
None
};
#[cfg(not(feature = "tenancy"))]
let user_roles_ctx: Option<serde_json::Value> = None;
let mut ctx = serde_json::json!({
"model": {
"name": model.name,
"table": model.table,
"label": model.display_label(),
"label_plural": model.display_label_plural(),
},
"pk": pk_raw,
"cells": cells_ctx,
"read_only": state.is_read_only(model.table),
"audit_entries": audit_entries_ctx,
"user_roles_panel": user_roles_ctx,
"inline_panels": inline_panels_ctx,
});
let html = render_with_chrome(
"detail.html",
&mut ctx,
chrome_context(&state, Some(model.table)),
);
Ok(Html(html))
}
#[cfg(feature = "tenancy")]
async fn user_roles_panel_ctx(state: &AppState, pk_raw: &str) -> Option<serde_json::Value> {
let user_id: i64 = pk_raw.parse().ok()?;
let roles = crate::tenancy::permissions::user_roles_qs_pool(user_id, &state.pool)
.await
.ok()?;
let perms = crate::tenancy::permissions::user_permissions_pool(user_id, &state.pool)
.await
.ok()?;
let roles_ctx: Vec<serde_json::Value> = roles
.into_iter()
.map(|r| {
serde_json::json!({
"id": r.id.get().copied().unwrap_or(0),
"name": r.name,
"description": r.description,
})
})
.collect();
Some(serde_json::json!({
"roles": roles_ctx,
"permissions": perms,
}))
}
pub(crate) async fn create_form(
parts: axum::http::request::Parts,
Path(table): Path<String>,
State(state): State<AppState>,
) -> Result<Html<String>, AdminError> {
let model = resolve_model(&state, &table)?;
if !state.can_add(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
if !crate::admin::object_permissions::is_allowed(model.table, "add", &parts, None) {
return Err(AdminError::Forbidden {
table: model.table.to_owned(),
action: "add",
});
}
let gfk_cts = preload_gfk_cts(&state, model).await;
Ok(Html(super::helpers::render_form_with_inlines_and_picker(
&state,
model,
None,
false,
None,
Vec::new(),
&gfk_cts,
)))
}
async fn preload_gfk_cts(
state: &AppState,
model: &'static crate::core::ModelSchema,
) -> Vec<crate::contenttypes::ContentType> {
if model.generic_relations.is_empty() {
return Vec::new();
}
crate::contenttypes::ContentType::all_ordered(&state.pool)
.await
.unwrap_or_default()
}
pub(crate) async fn create_submit(
parts: axum::http::request::Parts,
Path(table): Path<String>,
State(state): State<AppState>,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AdminError> {
let model = resolve_model(&state, &table)?;
if !state.can_add(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
if !crate::admin::object_permissions::is_allowed(model.table, "add", &parts, None) {
return Err(AdminError::Forbidden {
table: model.table.to_owned(),
action: "add",
});
}
let pk_field = primary_key_or_internal(model)?;
let admin_cfg = admin_config_or_default(model);
let mut skip: Vec<&str> = admin_cfg.readonly_fields.to_vec();
skip.extend(FormLayout::of(model, &admin_cfg, false).unrendered(model));
let mut collected = match forms::collect_insert_values(model, &form, &skip) {
Ok(v) => v,
Err(e) => {
let html = render_form(&state, model, Some(&form), false, Some(&e.to_string()));
return Ok(Html(html).into_response());
}
};
stamp_readonly_timestamps(model, &admin_cfg, &mut collected);
if let Err(e) = super::derived_fields::apply(model.table, &mut collected, None).await {
let html = render_form(&state, model, Some(&form), false, Some(e.as_str()));
return Ok(Html(html).into_response());
}
let audit_form = audit_form(model, &admin_cfg, &form, &collected);
let (columns, values): (Vec<&'static str>, Vec<SqlValue>) = collected.into_iter().unzip();
let query = InsertQuery {
model,
columns,
values,
returning: vec![pk_field.column],
on_conflict: None,
};
let typed_pk = query
.columns
.iter()
.position(|c| *c == pk_field.column)
.map(|i| query.values[i].to_display_string())
.unwrap_or_default();
crate::signals::admin::send_admin_pre_save(crate::signals::admin::AdminSaveContext {
table: model.table,
pk: typed_pk,
change: false,
})
.await;
let emit = if model.audit_track.is_some() {
crate::audit::DiffEmit::InTx
} else {
crate::audit::DiffEmit::AfterCommit
};
let written = crate::audit::insert_one_with_entry(
&state.pool,
&query,
pk_field,
|pk| {
let pk = pk.to_display_string();
super::audit::admin_audit_entry(model, &pk, crate::audit::AuditOp::Create, &audit_form)
},
emit,
)
.await;
let pk_value = match written {
Ok((pk, deferred)) => {
if let Some(entry) = deferred {
super::audit::emit_best_effort(&state, &entry).await;
}
pk.to_display_string()
}
Err(e) => {
let html = render_form(&state, model, Some(&form), false, Some(&write_error(&e)));
return Ok(Html(html).into_response());
}
};
crate::signals::admin::send_admin_post_save(crate::signals::admin::AdminSaveContext {
table: model.table,
pk: pk_value.clone(),
change: false,
})
.await;
let target = post_save_redirect(&state.config.admin_prefix, model.table, &pk_value, &form);
Ok(Redirect::to(&target).into_response())
}
fn write_error(e: &crate::sql::ExecError) -> String {
match super::errors::missing_table(e) {
Some(t) if t == crate::audit::AUDIT_TABLE => {
"audit table missing — run `manage migrate`".to_owned()
}
_ => e.to_string(),
}
}
fn stamp_readonly_timestamps(
model: &'static crate::core::ModelSchema,
admin_cfg: &crate::core::AdminConfig,
values: &mut Vec<(&'static str, SqlValue)>,
) {
let now = chrono::Utc::now();
for f in model.scalar_fields() {
if f.ty == crate::core::FieldType::DateTime
&& !f.nullable
&& !f.auto
&& f.default.is_none()
&& admin_cfg.readonly_fields.contains(&f.name)
&& !values.iter().any(|(c, _)| *c == f.column)
{
values.push((f.column, SqlValue::DateTime(now)));
}
}
}
fn keep_unchanged(
model: &'static crate::core::ModelSchema,
form: &HashMap<String, String>,
before: Option<&serde_json::Value>,
values: &mut Vec<(&'static str, SqlValue)>,
) {
for f in model.scalar_fields() {
let submitted = form.get(f.name).map(String::as_str);
let unchanged = f.ty == crate::core::FieldType::DateTime
&& before.zip(submitted).is_some_and(|(row, sent)| {
let shown = render::render_value_for_input_json(row, f);
sent == shown || shown.strip_suffix(":00") == Some(sent)
});
if unchanged {
values.retain(|(c, _)| *c != f.column);
}
if f.auto_now && !values.iter().any(|(c, _)| *c == f.column) {
values.push((f.column, SqlValue::DateTime(chrono::Utc::now())));
}
}
}
const SECRET_CHANGED: &str = "[changed]";
const SECRET_SET: &str = "[set]";
fn audit_form(
model: &'static crate::core::ModelSchema,
admin_cfg: &crate::core::AdminConfig,
form: &HashMap<String, String>,
written: &[(&'static str, SqlValue)],
) -> HashMap<String, String> {
let is_written = |name: &str| {
model
.field(name)
.is_some_and(|f| written.iter().any(|(c, _)| *c == f.column))
};
let mut out: HashMap<String, String> = form
.iter()
.filter(|(k, _)| !is_secret_field(admin_cfg, k) && is_written(k))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (column, value) in written {
let Some(f) = model.scalar_fields().find(|f| f.column == *column) else {
continue;
};
if is_secret_field(admin_cfg, f.name) {
out.insert(f.name.to_owned(), SECRET_CHANGED.to_owned());
} else if let SqlValue::DateTime(at) = value {
out.insert(f.name.to_owned(), at.to_rfc3339());
}
}
out
}
fn mask_secrets(
model: &'static crate::core::ModelSchema,
admin_cfg: &crate::core::AdminConfig,
row: &serde_json::Value,
) -> serde_json::Value {
let mut row = row.clone();
for f in model.scalar_fields() {
if is_secret_field(admin_cfg, f.name) {
if let Some(v) = row.get_mut(f.name).filter(|v| !v.is_null()) {
*v = serde_json::Value::String(SECRET_SET.to_owned());
}
}
}
row
}
pub(crate) async fn edit_form(
parts: axum::http::request::Parts,
Path((table, pk_raw)): Path<(String, String)>,
State(state): State<AppState>,
) -> Result<Html<String>, AdminError> {
let (model, pk_field, pk_value) = resolve_model_and_pk(&state, &table, &pk_raw)?;
let edit_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let row = crate::sql::select_one_row_as_json(
&state.pool,
&RowScope::of(model, &parts).by_pk(model, pk_field.column, pk_value.clone()),
&edit_fields,
)
.await?
.ok_or(AdminError::RowNotFound {
table: table.clone(),
pk: pk_raw.clone(),
})?;
if !crate::admin::object_permissions::is_allowed(model.table, "change", &parts, Some(&row)) {
return Err(AdminError::Forbidden {
table: model.table.to_owned(),
action: "change",
});
}
let mut prefill = HashMap::new();
for f in model.scalar_fields() {
prefill.insert(
f.name.to_owned(),
render::render_value_for_input_json(&row, f),
);
}
let mut inline_panels = super::inlines::render_form_for_parent_in(
&state.pool,
model,
pk_value.clone(),
Some(&parts),
)
.await
.unwrap_or_default();
let generic_panels = super::inlines::render_form_generic_for_parent_in(
&state.pool,
model,
pk_value,
Some(&parts),
)
.await
.unwrap_or_default();
inline_panels.extend(generic_panels);
inline_panels.retain(|p| {
lookup_model(&state, &p.child_table).is_some() && !state.is_read_only(&p.child_table)
});
let gfk_cts = preload_gfk_cts(&state, model).await;
Ok(Html(super::helpers::render_form_with_inlines_and_picker(
&state,
model,
Some(&prefill),
true,
None,
inline_panels,
&gfk_cts,
)))
}
pub(crate) async fn update_submit(
parts: axum::http::request::Parts,
Path((table, pk_raw)): Path<(String, String)>,
State(state): State<AppState>,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AdminError> {
let model = resolve_model(&state, &table)?;
if state.is_read_only(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
let pk_field = primary_key_or_internal(model)?;
let pk_value = forms::parse_pk_string(pk_field, &pk_raw).map_err(AdminError::Form)?;
let pre_update_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let pre_update_row = crate::sql::select_one_row_as_json(
&state.pool,
&RowScope::of(model, &parts).by_pk(model, pk_field.column, pk_value.clone()),
&pre_update_fields,
)
.await?;
if pre_update_row.is_none() {
return Err(AdminError::RowNotFound { table, pk: pk_raw });
}
if !crate::admin::object_permissions::is_allowed(
model.table,
"change",
&parts,
pre_update_row.as_ref(),
) {
return Err(AdminError::Forbidden {
table: model.table.to_owned(),
action: "change",
});
}
let admin_cfg = admin_config_or_default(model);
let mut skip: Vec<&'static str> = vec![pk_field.name];
skip.extend(admin_cfg.readonly_fields.iter().copied());
skip.extend(FormLayout::of(model, &admin_cfg, true).unrendered(model));
skip.extend(
model
.scalar_fields()
.filter(|f| is_secret_field(&admin_cfg, f.name))
.filter(|f| form.get(f.name).is_none_or(String::is_empty))
.map(|f| f.name),
);
let mut collected = match forms::collect_values(model, &form, &skip) {
Ok(v) => v,
Err(e) => {
let html = render_form(&state, model, Some(&form), true, Some(&e.to_string()));
return Ok(Html(html).into_response());
}
};
keep_unchanged(model, &form, pre_update_row.as_ref(), &mut collected);
if let Err(e) =
super::derived_fields::apply(model.table, &mut collected, pre_update_row.as_ref()).await
{
let html = render_form(&state, model, Some(&form), true, Some(e.as_str()));
return Ok(Html(html).into_response());
}
let audit_form = audit_form(model, &admin_cfg, &form, &collected);
let assignments: Vec<Assignment> = collected
.into_iter()
.map(|(column, value)| Assignment {
column,
value: value.into(),
})
.collect();
let inline_plan = match super::inlines::plan_post(&state, &parts, model, &pk_value, &form).await
{
Ok(plan) => plan,
Err(super::inlines::InlinePlanError::Admin(e)) => return Err(e),
Err(super::inlines::InlinePlanError::Rejected(msg)) => {
let html = render_form(&state, model, Some(&form), true, Some(&msg));
return Ok(Html(html).into_response());
}
Err(super::inlines::InlinePlanError::BadFormset(e)) => {
let html = render_form(&state, model, Some(&form), true, Some(&e.to_string()));
return Ok(Html(html).into_response());
}
};
let before_select = RowScope::of(model, &parts).by_pk(model, pk_field.column, pk_value.clone());
let query = UpdateQuery {
model,
set: assignments,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_value,
}),
};
crate::signals::admin::send_admin_pre_save(crate::signals::admin::AdminSaveContext {
table: model.table,
pk: pk_raw.clone(),
change: true,
})
.await;
let emit = if model.audit_track.is_some() {
crate::audit::DiffEmit::InTx
} else {
crate::audit::DiffEmit::AfterCommit
};
let written = crate::audit::update_one_with_row_diff(
&state.pool,
&query,
before_select,
&pre_update_fields,
|row| {
let row = mask_secrets(model, &admin_cfg, row);
super::audit::admin_audit_diff_entry(model, &pk_raw, &row, &audit_form)
},
emit,
)
.await;
match written {
Ok(crate::audit::RowDiffWrite::Written { deferred }) => {
if let Some(entry) = deferred {
super::audit::emit_best_effort(&state, &entry).await;
}
}
Ok(crate::audit::RowDiffWrite::Gone) => {
return Err(AdminError::RowNotFound { table, pk: pk_raw })
}
Err(e) => {
let html = render_form(&state, model, Some(&form), true, Some(&write_error(&e)));
return Ok(Html(html).into_response());
}
}
crate::signals::admin::send_admin_post_save(crate::signals::admin::AdminSaveContext {
table: model.table,
pk: pk_raw.clone(),
change: true,
})
.await;
let _ = super::inlines::apply_plan(&state.pool, inline_plan).await;
let target = post_save_redirect(&state.config.admin_prefix, model.table, &pk_raw, &form);
Ok(Redirect::to(&target).into_response())
}
pub(crate) async fn delete_submit(
parts: axum::http::request::Parts,
Path((table, pk_raw)): Path<(String, String)>,
State(state): State<AppState>,
) -> Result<Response, AdminError> {
let model = resolve_model(&state, &table)?;
if !state.can_delete(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
let pk_field = primary_key_or_internal(model)?;
let pk_value = forms::parse_pk_string(pk_field, &pk_raw).map_err(AdminError::Form)?;
let delete_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let before_row = crate::sql::select_one_row_as_json(
&state.pool,
&RowScope::of(model, &parts).by_pk(model, pk_field.column, pk_value.clone()),
&delete_fields,
)
.await?;
if before_row.is_none() {
return Err(AdminError::RowNotFound { table, pk: pk_raw });
}
if !crate::admin::object_permissions::is_allowed(
model.table,
"delete",
&parts,
before_row.as_ref(),
) {
return Err(AdminError::Forbidden {
table: model.table.to_owned(),
action: "delete",
});
}
let audit_op = if model.soft_delete_column.is_some() {
crate::audit::AuditOp::SoftDelete
} else {
crate::audit::AuditOp::Delete
};
crate::signals::admin::send_admin_pre_delete(crate::signals::admin::AdminDeleteContext {
table: model.table,
pk: pk_raw.clone(),
})
.await;
let list_url = format!("{}/{}", state.config.admin_prefix, model.table);
let affected = if let Some(col) = model.soft_delete_column {
let now = Some(chrono::Utc::now());
let stamp = crate::soft_delete::__mark_query(model, col, pk_field.column, pk_value, now);
crate::sql::update_pool(&state.pool, &stamp).await?
} else {
crate::sql::delete_pool(
&state.pool,
&DeleteQuery {
model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_value,
}),
},
)
.await?
};
if affected == 0 {
return Ok(Redirect::to(&list_url).into_response());
}
let delete_cfg = admin_config_or_default(model);
let pairs: Vec<(&str, serde_json::Value)> = before_row
.as_ref()
.map(|row| mask_secrets(model, &delete_cfg, row))
.map(|row| {
model
.scalar_fields()
.map(|f| (f.name, render::read_value_as_json_from_json(&row, f)))
.collect()
})
.unwrap_or_default();
let entry = crate::audit::PendingEntry {
entity_table: model.table,
entity_pk: pk_raw.clone(),
operation: audit_op,
source: crate::audit::current_source(),
changes: crate::audit::snapshot_changes(&pairs),
};
if let Err(e) = crate::audit::emit_one_pool(&state.pool, &entry).await {
tracing::warn!(
target: "rustango::admin::audit",
error = %e,
entity_table = %model.table,
entity_pk = %pk_raw,
"admin audit emit failed for delete",
);
}
crate::signals::admin::send_admin_post_delete(crate::signals::admin::AdminDeleteContext {
table: model.table,
pk: pk_raw.clone(),
})
.await;
Ok(Redirect::to(&list_url).into_response())
}
pub(crate) async fn action_submit(
parts: axum::http::request::Parts,
Path(table): Path<String>,
State(state): State<AppState>,
body: axum::body::Bytes,
) -> Result<Response, AdminError> {
let model = resolve_model(&state, &table)?;
let pairs: Vec<(String, String)> = serde_urlencoded::from_bytes(&body)
.map_err(|e| AdminError::Internal(format!("parse action form: {e}")))?;
let mut action_name: Option<String> = None;
let mut selected_raw: Vec<String> = Vec::new();
let mut trashed = false;
for (k, v) in pairs {
if (k == "action" || k == "action_bottom") && !v.is_empty() && action_name.is_none() {
action_name = Some(v);
} else if k == "_selected" {
selected_raw.push(v);
} else if k == "trashed" {
trashed = v == "1";
}
}
if selected_raw.len() > MAX_IN_KEYS {
return Err(AdminError::Form(forms::FormError::Parse {
field: "_selected".to_owned(),
ty: "selection",
value: selected_raw.len().to_string(),
detail: format!("an action takes at most {MAX_IN_KEYS} rows"),
}));
}
let list_url = if trashed && model.soft_delete_column.is_some() {
format!("{}/{}?trashed=1", state.config.admin_prefix, model.table)
} else {
format!("{}/{}", state.config.admin_prefix, model.table)
};
let back = || Ok(Redirect::to(&list_url).into_response());
let Some(action) = action_name.filter(|s| !s.is_empty()) else {
return back();
};
if selected_raw.is_empty() {
return back();
}
let admin_cfg = admin_config_or_default(model);
if !admin_cfg.actions.iter().any(|a| *a == action) {
return Err(AdminError::Internal(format!(
"action `{action}` not registered for `{}`",
model.name
)));
}
let Some(write) = BulkWrite::plan(&state, model, &action)? else {
return back();
};
let pk_field = primary_key_or_internal(model)?;
let pk_values: Vec<SqlValue> = selected_raw
.iter()
.filter_map(|raw| forms::parse_pk_string(pk_field, raw).ok())
.collect();
if pk_values.is_empty() {
return back();
}
let action_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let mut before_rows = crate::sql::select_rows_as_json(
&state.pool,
&if matches!(write, BulkWrite::Restore(_)) {
RowScope::trashed(model, &parts)
} else {
RowScope::of(model, &parts)
}
.by_pk_in(model, pk_field.column, pk_values.clone()),
&action_fields,
)
.await?;
let (perm, named) = row_perms(&action);
let allowed = |row: &serde_json::Value| {
let is_allowed = |p: &str| {
crate::admin::object_permissions::is_allowed(model.table, p, &parts, Some(row))
};
is_allowed(perm) && named.map_or(true, is_allowed)
};
if !before_rows.iter().all(allowed) {
return Err(AdminError::Forbidden {
table: model.table.to_owned(),
action: perm,
});
}
let pk_values = rows_with_pk(&mut before_rows, pk_field);
if pk_values.is_empty() {
return back();
}
let is_delete = matches!(write, BulkWrite::Delete);
let audit_op = match (is_delete, model.soft_delete_column) {
(true, Some(_)) => crate::audit::AuditOp::SoftDelete,
(true, None) => crate::audit::AuditOp::Delete,
(false, _) => crate::audit::AuditOp::Update,
};
let row_pks: Vec<String> = before_rows
.iter()
.map(|row| render::read_value_as_string_json(row, pk_field).unwrap_or_default())
.collect();
send_row_signals(model.table, &row_pks, is_delete, true).await;
let mark = |col, deleted_at| {
mark_each(
&state.pool,
model,
col,
pk_field.column,
&pk_values,
deleted_at,
)
};
let changed = match write {
BulkWrite::Delete => match model.soft_delete_column {
Some(col) => Some(mark(col, Some(chrono::Utc::now())).await?),
None => {
let query = DeleteQuery::by_pk_in(model, pk_field.column, pk_values.clone());
crate::sql::delete_pool(&state.pool, &query).await?;
None
}
},
BulkWrite::Restore(col) => Some(mark(col, None).await?),
BulkWrite::Custom(handler) => {
handler(&state.pool, &pk_values).await?;
None
}
};
let (before_rows, row_pks) = match changed {
Some(changed) => before_rows
.into_iter()
.zip(row_pks)
.zip(changed)
.filter_map(|(pair, changed)| changed.then_some(pair))
.unzip(),
None => (before_rows, row_pks),
};
let source = crate::audit::current_source();
let entries: Vec<crate::audit::PendingEntry> = before_rows
.iter()
.map(|row| {
let pk_str = render::read_value_as_string_json(row, pk_field).unwrap_or_default();
let row = &mask_secrets(model, &admin_cfg, row);
let mut pairs: Vec<(&str, serde_json::Value)> = model
.scalar_fields()
.map(|f| (f.name, render::read_value_as_json_from_json(row, f)))
.collect();
if !is_delete {
pairs.push(("__action", serde_json::Value::String(action.clone())));
}
crate::audit::PendingEntry {
entity_table: model.table,
entity_pk: pk_str,
operation: audit_op,
source: source.clone(),
changes: crate::audit::snapshot_changes(&pairs),
}
})
.collect();
if !entries.is_empty() {
if let Err(e) = crate::audit::emit_many_pool(&state.pool, &entries).await {
tracing::warn!(
target: "rustango::admin::audit",
error = %e,
entity_table = %model.table,
action = %action,
count = entries.len(),
"admin bulk-action audit emit failed",
);
}
}
send_row_signals(model.table, &row_pks, is_delete, false).await;
back()
}
enum BulkWrite {
Delete,
Restore(&'static str),
Custom(super::urls::AdminActionFn),
}
impl BulkWrite {
fn plan(
state: &AppState,
model: &'static crate::core::ModelSchema,
action: &str,
) -> Result<Option<Self>, AdminError> {
let read_only = || AdminError::ReadOnly {
table: model.table.to_owned(),
};
match action {
"delete_selected" if !state.can_delete(model.table) => Err(read_only()),
"delete_selected" => Ok(Some(Self::Delete)),
_ if state.is_read_only(model.table) => Err(read_only()),
"restore_selected" => Ok(model.soft_delete_column.map(Self::Restore)),
_ => match state.action_handler(model.table, action) {
Some(handler) => Ok(Some(Self::Custom(handler))),
None => Err(AdminError::Internal(format!(
"action `{action}` is in `admin.actions` but no handler is registered \
on the admin builder; register it via \
`admin::Builder::register_action(\"{}\", \"{action}\", ...)` (built-ins: \
delete_selected, restore_selected)",
model.table
))),
},
}
}
}
async fn send_row_signals(table: &'static str, pks: &[String], is_delete: bool, pre: bool) {
use crate::signals::admin::{self as sig, AdminDeleteContext, AdminSaveContext};
for pk in pks {
let pk = pk.clone();
match (is_delete, pre) {
(true, true) => sig::send_admin_pre_delete(AdminDeleteContext { table, pk }).await,
(true, false) => sig::send_admin_post_delete(AdminDeleteContext { table, pk }).await,
(false, true) => {
sig::send_admin_pre_save(AdminSaveContext {
table,
pk,
change: true,
})
.await;
}
(false, false) => {
sig::send_admin_post_save(AdminSaveContext {
table,
pk,
change: true,
})
.await;
}
}
}
}
fn row_perms(action: &str) -> (&'static str, Option<&str>) {
match action {
"delete_selected" => ("delete", None),
"restore_selected" => ("change", None),
custom => ("change", Some(custom)),
}
}
async fn mark_each(
pool: &crate::sql::Pool,
model: &'static crate::core::ModelSchema,
col: &'static str,
pk_column: &'static str,
pks: &[SqlValue],
deleted_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Vec<bool>, crate::sql::ExecError> {
let mut changed = Vec::with_capacity(pks.len());
for pk in pks {
let q = crate::soft_delete::__mark_query(model, col, pk_column, pk.clone(), deleted_at);
changed.push(crate::sql::update_pool(pool, &q).await? > 0);
}
Ok(changed)
}
fn rows_with_pk(rows: &mut Vec<serde_json::Value>, pk_field: &FieldSchema) -> Vec<SqlValue> {
let mut pks = Vec::with_capacity(rows.len());
rows.retain(|row| {
let pk = render::read_value_as_string_json(row, pk_field)
.and_then(|raw| forms::parse_pk_string(pk_field, &raw).ok());
pks.extend(pk.clone());
pk.is_some()
});
pks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_audit_form_holds_only_written_fields() {
use crate::core::Model as _;
let model = crate::admin::user::AdminUser::SCHEMA;
let form: HashMap<String, String> = [
("username", "alice"),
("sessions_revoked_at", "2020-01-01T00:00"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
let written = vec![("username", SqlValue::String("alice".into()))];
let out = audit_form(model, &crate::core::AdminConfig::DEFAULT, &form, &written);
assert_eq!(out.get("username").map(String::as_str), Some("alice"));
assert!(!out.contains_key("sessions_revoked_at"), "{out:?}");
}
#[test]
fn rows_with_pk_drops_the_audit_row_with_its_pk() {
let pk = FieldSchema::new("id", "id", crate::core::FieldType::I64);
let mut rows = vec![
serde_json::json!({"id": 1}),
serde_json::json!({"id": null}),
serde_json::json!({"id": 3}),
];
let pks = rows_with_pk(&mut rows, &pk);
assert_eq!(pks, vec![SqlValue::I64(1), SqlValue::I64(3)]);
assert_eq!(
rows,
vec![serde_json::json!({"id": 1}), serde_json::json!({"id": 3})]
);
}
fn form_with(field: &str) -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert(field.to_owned(), "1".to_owned());
m
}
#[test]
fn default_save_redirects_to_list_view() {
let url = post_save_redirect("/__admin", "post", "42", &form_with("_save"));
assert_eq!(url, "/__admin/post");
}
#[test]
fn save_with_no_button_name_redirects_to_list_view() {
let url = post_save_redirect("/__admin", "post", "42", &HashMap::new());
assert_eq!(url, "/__admin/post");
}
#[test]
fn continue_redirects_to_detail() {
let url = post_save_redirect("/__admin", "post", "42", &form_with("_continue"));
assert_eq!(url, "/__admin/post/42");
}
#[test]
fn addanother_redirects_to_create_form() {
let url = post_save_redirect("/__admin", "post", "42", &form_with("_addanother"));
assert_eq!(url, "/__admin/post/new");
}
#[test]
fn continue_takes_precedence_over_addanother() {
let mut form = form_with("_continue");
form.insert("_addanother".to_owned(), "1".to_owned());
let url = post_save_redirect("/__admin", "post", "42", &form);
assert_eq!(url, "/__admin/post/42");
}
fn order_cols(order: &[crate::core::OrderItem]) -> Vec<(&'static str, bool)> {
order
.iter()
.map(|o| (o.column_name().unwrap(), o.is_desc()))
.collect()
}
#[test]
fn list_order_falls_back_and_ends_on_the_pk() {
use crate::core::{AdminConfig, FieldSchema, FieldType, ModelSchema};
const FIELDS: &[FieldSchema] = &[
{
let mut f = FieldSchema::new("id", "id", FieldType::I64);
f.primary_key = true;
f
},
FieldSchema::new("rank", "rank", FieldType::I64),
FieldSchema::new("author", "author_id", FieldType::I64),
];
const BARE: &ModelSchema = &{
let mut s = ModelSchema::new("P", "p");
s.fields = FIELDS;
s
};
const DEFAULTED: &ModelSchema = &{
let mut s = ModelSchema::new("P", "p");
s.fields = FIELDS;
s.default_order = &[("rank", true)];
s
};
let none = AdminConfig::DEFAULT;
let by_rank = AdminConfig {
ordering: &[("rank", false)],
..AdminConfig::DEFAULT
};
let by_pk = AdminConfig {
ordering: &[("id", true)],
..AdminConfig::DEFAULT
};
assert_eq!(order_cols(&list_order_by(BARE, &none)), [("id", false)]);
assert_eq!(
order_cols(&list_order_by(DEFAULTED, &none)),
[("rank", true), ("id", false)]
);
assert_eq!(
order_cols(&list_order_by(DEFAULTED, &by_rank)),
[("rank", false), ("id", false)]
);
assert_eq!(order_cols(&list_order_by(BARE, &by_pk)), [("id", true)]);
let by_column = AdminConfig {
ordering: &[("author_id", true)],
..AdminConfig::DEFAULT
};
assert_eq!(
order_cols(&list_order_by(BARE, &by_column)),
[("author_id", true), ("id", false)]
);
}
#[test]
fn admin_prefix_is_honored() {
let url = post_save_redirect("/manage", "post", "42", &form_with("_continue"));
assert_eq!(url, "/manage/post/42");
}
}