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::{
build_fk_joins, chrome_context, fk_map_from_joined_rows_json, lookup_model, pager_suffix,
render_cell_json, render_form,
};
use super::render;
use super::templates::render_with_chrome;
use super::urls::AppState;
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 mut ctx = serde_json::json!({
"groups": groups_ctx,
"models": flat_models_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"];
const FACET_TRUNCATE: usize = 15;
#[allow(clippy::too_many_lines)] pub(crate) async fn table_view(
Path(table): Path<String>,
Query(params): Query<HashMap<String, String>>,
State(state): State<AppState>,
) -> Result<Html<String>, AdminError> {
let model = lookup_model(&state, &table).ok_or(AdminError::TableNotFound { table })?;
let pk_field = model.primary_key();
let admin_cfg = model
.admin
.copied()
.unwrap_or(crate::core::AdminConfig::DEFAULT);
let page_size: i64 = if admin_cfg.list_per_page == 0 {
DEFAULT_PAGE_SIZE
} else {
admin_cfg.list_per_page as i64
};
let page = params
.get("page")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(1)
.max(1);
let offset = (page - 1) * page_size;
let q = params
.get("q")
.map(String::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned);
let mut filters: Vec<Filter> = Vec::new();
let mut active_field_filters: Vec<(&'static str, String)> = Vec::new();
for (key, value) in ¶ms {
if RESERVED_PARAMS.contains(&key.as_str()) {
continue;
}
if value.is_empty() {
continue;
}
let Some(field) = model.field(key) else {
continue;
};
let Ok(v) = forms::parse_form_value(field, Some(value)) else {
continue;
};
filters.push(Filter {
column: field.column,
op: Op::Eq,
value: v,
});
active_field_filters.push((field.name, value.clone()));
}
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(filters.clone());
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(),
},
)
.await?
};
let joins = build_fk_joins(&state, model);
let order_by: Vec<crate::core::OrderClause> = if admin_cfg.ordering.is_empty() {
Vec::new()
} else {
admin_cfg
.ordering
.iter()
.filter_map(|(name, desc)| {
model.field(name).map(|f| crate::core::OrderClause {
column: f.column,
desc: *desc,
})
})
.collect()
};
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_pool(
&state.pool,
&SelectQuery {
model,
where_clause,
search: search.clone(),
joins,
order_by,
limit: Some(fetch_limit),
offset: Some(offset),
},
&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),
}
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)
})
})
.collect()
};
let columns_ctx: Vec<serde_json::Value> = display_items
.iter()
.map(|item| {
let label = match item {
DisplayItem::Field(f) => {
if f.primary_key {
format!("{} <small>(pk)</small>", render::escape(f.name))
} else {
render::escape(f.name)
}
}
DisplayItem::Computed(m) => {
render::escape(if m.label.is_empty() { m.name } else { m.label })
}
};
serde_json::json!({ "label": label })
})
.collect();
let rows_ctx: Vec<serde_json::Value> = rows
.iter()
.map(|row| {
let cells: Vec<String> = display_items
.iter()
.map(|item| match item {
DisplayItem::Field(f) => render_cell_json(row, f, &fk_map),
DisplayItem::Computed(m) => (m.render)(row),
})
.collect();
let pk =
pk_field.map(|pk| render::escape(&render::render_value_for_input_json(row, pk)));
serde_json::json!({ "cells": cells, "pk": pk })
})
.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 pager_suffix_str = pager_suffix(q.as_deref(), &active_field_filters);
let show_all_facet = params.get("facet_show_all").map(String::as_str);
let facets_ctx: Vec<serde_json::Value> = compute_facets(
&state,
model,
&admin_cfg,
&active_field_filters,
q.as_deref(),
show_all_facet,
)
.await?;
let actions_ctx: Vec<serde_json::Value> = admin_cfg
.actions
.iter()
.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 },
"total": total,
"plural": if total == 1 { "" } else { "s" },
"read_only": read_only,
"has_searchable": !search_columns.is_empty(),
"q": q.unwrap_or_default(),
"active_filters": active_filters_ctx,
"facets": facets_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,
});
Ok(Html(render_with_chrome(
"list.html",
&mut ctx,
chrome_context(&state, Some(model.table)),
)))
}
async fn compute_facets(
state: &AppState,
model: &'static crate::core::ModelSchema,
admin_cfg: &crate::core::AdminConfig,
active_field_filters: &[(&'static str, String)],
q: Option<&str>,
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 active_value: Option<&str> = active_field_filters
.iter()
.find(|(k, _)| k == &field.name)
.map(|(_, v)| v.as_str());
let fk_join: Option<(&'static str, &'static str, &'static str)> =
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)?;
let display_field = target.display_field()?;
Some((target.table, on, display_field.column))
}
});
let dialect = state.pool.dialect();
let sql = if let Some((target_table, target_pk, display_col)) = fk_join {
let src_t = dialect.quote_ident(model.table);
let src_c = dialect.quote_ident(field.column);
let tgt_t = dialect.quote_ident(target_table);
let tgt_pk = dialect.quote_ident(target_pk);
let tgt_disp = dialect.quote_ident(display_col);
format!(
"SELECT {src_t}.{src_c} AS facet_value, \
{tgt_t}.{tgt_disp} AS facet_display, \
COUNT(*) AS facet_count \
FROM {src_t} \
LEFT JOIN {tgt_t} ON {tgt_t}.{tgt_pk} = {src_t}.{src_c} \
GROUP BY {src_t}.{src_c}, {tgt_t}.{tgt_disp} \
ORDER BY facet_count DESC, {tgt_t}.{tgt_disp}"
)
} else {
let t = dialect.quote_ident(model.table);
let c = dialect.quote_ident(field.column);
format!(
"SELECT {c} AS facet_value, COUNT(*) AS facet_count \
FROM {t} \
GROUP BY {c} \
ORDER BY facet_count DESC, {c}"
)
};
let facet_rows = fetch_facet_rows(&state.pool, &sql, fk_join.is_some())
.await
.map_err(|e| AdminError::Internal(e.to_string()))?;
let mut values = Vec::with_capacity(facet_rows.len());
for (raw_value, display_text, count) in &facet_rows {
let raw = raw_value.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 is_active = active_value.map(|v| v == raw).unwrap_or(false);
let mut params: Vec<(String, String)> = Vec::new();
if let Some(qv) = q {
params.push(("q".into(), qv.into()));
}
for (k, v) in active_field_filters {
if *k == field.name {
continue; }
params.push(((*k).into(), v.clone()));
}
if !is_active {
params.push((field.name.into(), raw.clone()));
}
let toggle_url =
build_query_url(state.config.admin_prefix.as_str(), model.table, ¶ms);
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 {
let mut params: Vec<(String, String)> = Vec::new();
if let Some(qv) = q {
params.push(("q".into(), qv.into()));
}
for (k, v) in active_field_filters {
params.push(((*k).into(), v.clone()));
}
params.push(("facet_show_all".into(), field.name.into()));
Some(build_query_url(
state.config.admin_prefix.as_str(),
model.table,
¶ms,
))
} else {
None
};
let clear_url = if fk_join.is_some() {
let mut params: Vec<(String, String)> = Vec::new();
if let Some(qv) = q {
params.push(("q".into(), qv.into()));
}
for (k, v) in active_field_filters {
if *k == field.name {
continue;
}
params.push(((*k).into(), v.clone()));
}
Some(build_query_url(
state.config.admin_prefix.as_str(),
model.table,
¶ms,
))
} else {
None
};
out.push(serde_json::json!({
"field": field.name,
"is_fk": fk_join.is_some(),
"values": values,
"more_count": more_count,
"show_all_url": show_all_url,
"clear_url": clear_url,
}));
}
Ok(out)
}
async fn fetch_facet_rows(
pool: &crate::sql::Pool,
sql: &str,
expect_display: bool,
) -> Result<Vec<(String, Option<String>, i64)>, sqlx::Error> {
use sqlx::Row as _;
match pool {
#[cfg(feature = "postgres")]
crate::sql::Pool::Postgres(pg) => {
let rows = sqlx::query(sql).fetch_all(pg).await?;
let mut out = Vec::with_capacity(rows.len());
for r in rows {
let raw = stringify_facet_value_pg(&r);
let display = if expect_display {
r.try_get::<Option<String>, _>("facet_display")
.ok()
.flatten()
} else {
None
};
let count: i64 = r.try_get("facet_count").unwrap_or(0);
out.push((raw, display, count));
}
Ok(out)
}
#[cfg(feature = "mysql")]
crate::sql::Pool::Mysql(my) => {
let rows = sqlx::query(sql).fetch_all(my).await?;
let mut out = Vec::with_capacity(rows.len());
for r in rows {
let raw = stringify_facet_value_my(&r);
let display = if expect_display {
r.try_get::<Option<String>, _>("facet_display")
.ok()
.flatten()
} else {
None
};
let count: i64 = r.try_get("facet_count").unwrap_or(0);
out.push((raw, display, count));
}
Ok(out)
}
#[cfg(feature = "sqlite")]
crate::sql::Pool::Sqlite(sq) => {
let rows = sqlx::query(sql).fetch_all(sq).await?;
let mut out = Vec::with_capacity(rows.len());
for r in rows {
let raw = stringify_facet_value_sqlite(&r);
let display = if expect_display {
r.try_get::<Option<String>, _>("facet_display")
.ok()
.flatten()
} else {
None
};
let count: i64 = r.try_get("facet_count").unwrap_or(0);
out.push((raw, display, count));
}
Ok(out)
}
}
}
#[cfg(feature = "postgres")]
fn stringify_facet_value_pg(row: &sqlx::postgres::PgRow) -> String {
use sqlx::Row as _;
if let Ok(Some(s)) = row.try_get::<Option<String>, _>("facet_value") {
return s;
}
if let Ok(Some(n)) = row.try_get::<Option<i64>, _>("facet_value") {
return n.to_string();
}
if let Ok(Some(n)) = row.try_get::<Option<i32>, _>("facet_value") {
return n.to_string();
}
if let Ok(Some(b)) = row.try_get::<Option<bool>, _>("facet_value") {
return b.to_string();
}
String::new()
}
#[cfg(feature = "mysql")]
fn stringify_facet_value_my(row: &sqlx::mysql::MySqlRow) -> String {
use sqlx::Row as _;
if let Ok(Some(s)) = row.try_get::<Option<String>, _>("facet_value") {
return s;
}
if let Ok(Some(n)) = row.try_get::<Option<i64>, _>("facet_value") {
return n.to_string();
}
if let Ok(Some(n)) = row.try_get::<Option<i32>, _>("facet_value") {
return n.to_string();
}
if let Ok(Some(b)) = row.try_get::<Option<bool>, _>("facet_value") {
return b.to_string();
}
String::new()
}
#[cfg(feature = "sqlite")]
fn stringify_facet_value_sqlite(row: &sqlx::sqlite::SqliteRow) -> String {
use sqlx::Row as _;
if let Ok(Some(s)) = row.try_get::<Option<String>, _>("facet_value") {
return s;
}
if let Ok(Some(n)) = row.try_get::<Option<i64>, _>("facet_value") {
return n.to_string();
}
if let Ok(Some(n)) = row.try_get::<Option<i32>, _>("facet_value") {
return n.to_string();
}
if let Ok(Some(b)) = row.try_get::<Option<bool>, _>("facet_value") {
return b.to_string();
}
String::new()
}
fn build_query_url(admin_prefix: &str, table: &str, params: &[(String, String)]) -> String {
if params.is_empty() {
format!("{admin_prefix}/{table}")
} else {
let qs: Vec<String> = params
.iter()
.map(|(k, v)| format!("{}={}", url_encode(k), url_encode(v)))
.collect();
format!("{admin_prefix}/{table}?{}", qs.join("&"))
}
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
' ' => out.push_str("%20"),
'&' => out.push_str("%26"),
'=' => out.push_str("%3D"),
'?' => out.push_str("%3F"),
'#' => out.push_str("%23"),
'+' => out.push_str("%2B"),
'%' => out.push_str("%25"),
_ => out.push(c),
}
}
out
}
pub(crate) async fn detail_view(
Path((table, pk_raw)): Path<(String, String)>,
State(state): State<AppState>,
) -> Result<Html<String>, AdminError> {
let model = lookup_model(&state, &table).ok_or(AdminError::TableNotFound {
table: table.clone(),
})?;
let pk_field = model.primary_key().ok_or_else(|| {
AdminError::Internal(format!("model `{}` has no primary key", model.name))
})?;
let pk_value = forms::parse_pk_string(pk_field, &pk_raw).map_err(AdminError::Form)?;
let detail_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let row = crate::sql::select_one_row_as_json_pool(
&state.pool,
&SelectQuery {
model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_value,
}),
search: None,
joins: build_fk_joins(&state, model),
order_by: vec![],
limit: None,
offset: None,
},
&detail_fields,
)
.await?
.ok_or(AdminError::RowNotFound {
table: table.clone(),
pk: pk_raw.clone(),
})?;
let fk_map = fk_map_from_joined_rows_json(&state, model, std::slice::from_ref(&row));
let mut cells_ctx: Vec<serde_json::Value> = model
.scalar_fields()
.map(|f| {
serde_json::json!({
"label": f.name,
"value": render_cell_json(&row, f, &fk_map),
})
})
.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_pool(&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 audit_entries_ctx: Vec<serde_json::Value> =
match crate::audit::fetch_for_entity_pool(&state.pool, model.table, &pk_raw).await {
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 },
"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,
});
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(
Path(table): Path<String>,
State(state): State<AppState>,
) -> Result<Html<String>, AdminError> {
let model = lookup_model(&state, &table).ok_or(AdminError::TableNotFound { table })?;
if !state.can_add(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
Ok(Html(render_form(
&state, model, None, false, None,
)))
}
pub(crate) async fn create_submit(
Path(table): Path<String>,
State(state): State<AppState>,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AdminError> {
let model = lookup_model(&state, &table).ok_or(AdminError::TableNotFound {
table: table.clone(),
})?;
if !state.can_add(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
let pk_field = model.primary_key().ok_or_else(|| {
AdminError::Internal(format!("model `{}` has no primary key", model.name))
})?;
let admin_cfg = model
.admin
.copied()
.unwrap_or(crate::core::AdminConfig::DEFAULT);
let mut skip: Vec<&str> = admin_cfg.readonly_fields.to_vec();
if pk_field.auto {
skip.push(pk_field.name);
}
let collected = match forms::collect_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());
}
};
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 pk_value = match crate::sql::insert_returning_pool(&state.pool, &query).await {
#[cfg(feature = "postgres")]
Ok(crate::sql::InsertReturningPool::PgRow(row)) => {
render::read_value_as_string(&row, pk_field).unwrap_or_default()
}
#[cfg(feature = "mysql")]
Ok(crate::sql::InsertReturningPool::MySqlAutoId(id)) => id.to_string(),
#[cfg(feature = "sqlite")]
Ok(crate::sql::InsertReturningPool::SqliteRow(row)) => {
let row_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let json = crate::sql::row_to_json_sqlite(&row, &row_fields);
render::read_value_as_string_json(&json, pk_field).unwrap_or_default()
}
Err(e) => {
let html = render_form(&state, model, Some(&form), false, Some(&e.to_string()));
return Ok(Html(html).into_response());
}
};
super::audit::emit_admin_audit(
&state,
model,
&pk_value,
crate::audit::AuditOp::Create,
&form,
)
.await;
Ok(Redirect::to(&format!(
"{}/{}/{}",
state.config.admin_prefix, model.table, pk_value
))
.into_response())
}
pub(crate) async fn edit_form(
Path((table, pk_raw)): Path<(String, String)>,
State(state): State<AppState>,
) -> Result<Html<String>, AdminError> {
let model = lookup_model(&state, &table).ok_or(AdminError::TableNotFound {
table: table.clone(),
})?;
let pk_field = model.primary_key().ok_or_else(|| {
AdminError::Internal(format!("model `{}` has no primary key", model.name))
})?;
let pk_value = forms::parse_pk_string(pk_field, &pk_raw).map_err(AdminError::Form)?;
let edit_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let row = crate::sql::select_one_row_as_json_pool(
&state.pool,
&SelectQuery {
model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_value,
}),
search: None,
joins: vec![],
order_by: vec![],
limit: None,
offset: None,
},
&edit_fields,
)
.await?
.ok_or(AdminError::RowNotFound {
table: table.clone(),
pk: pk_raw.clone(),
})?;
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),
);
}
Ok(Html(render_form(&state, model, Some(&prefill), true, None)))
}
pub(crate) async fn update_submit(
Path((table, pk_raw)): Path<(String, String)>,
State(state): State<AppState>,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AdminError> {
let model = lookup_model(&state, &table).ok_or(AdminError::TableNotFound {
table: table.clone(),
})?;
if state.is_read_only(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
let pk_field = model.primary_key().ok_or_else(|| {
AdminError::Internal(format!("model `{}` has no primary key", model.name))
})?;
let pk_value = forms::parse_pk_string(pk_field, &pk_raw).map_err(AdminError::Form)?;
let admin_cfg = model
.admin
.copied()
.unwrap_or(crate::core::AdminConfig::DEFAULT);
let mut skip: Vec<&'static str> = vec![pk_field.name];
skip.extend(admin_cfg.readonly_fields.iter().copied());
let 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());
}
};
let assignments: Vec<Assignment> = collected
.into_iter()
.map(|(column, value)| Assignment { column, value })
.collect();
let before_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let before_row = crate::sql::select_one_row_as_json_pool(
&state.pool,
&SelectQuery {
model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_value.clone(),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: None,
offset: None,
},
&before_fields,
)
.await
.ok()
.flatten();
let query = UpdateQuery {
model,
set: assignments,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_value,
}),
};
if let Err(e) = crate::sql::update_pool(&state.pool, &query).await {
let html = render_form(&state, model, Some(&form), true, Some(&e.to_string()));
return Ok(Html(html).into_response());
}
super::audit::emit_admin_audit_diff(&state, model, &pk_raw, before_row.as_ref(), &form).await;
Ok(Redirect::to(&format!(
"{}/{}/{}",
state.config.admin_prefix, model.table, pk_raw
))
.into_response())
}
pub(crate) async fn delete_submit(
Path((table, pk_raw)): Path<(String, String)>,
State(state): State<AppState>,
) -> Result<Response, AdminError> {
let model = lookup_model(&state, &table).ok_or(AdminError::TableNotFound {
table: table.clone(),
})?;
if !state.can_delete(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
let pk_field = model.primary_key().ok_or_else(|| {
AdminError::Internal(format!("model `{}` has no primary key", model.name))
})?;
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_pool(
&state.pool,
&SelectQuery {
model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_value.clone(),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: None,
offset: None,
},
&delete_fields,
)
.await
.ok()
.flatten();
let audit_op = if model.soft_delete_column.is_some() {
crate::audit::AuditOp::SoftDelete
} else {
crate::audit::AuditOp::Delete
};
if let Some(col) = model.soft_delete_column {
crate::sql::update_pool(
&state.pool,
&UpdateQuery {
model,
set: vec![Assignment {
column: col,
value: SqlValue::from(chrono::Utc::now()),
}],
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_value,
}),
},
)
.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?;
}
let pairs: Vec<(&str, serde_json::Value)> = before_row
.as_ref()
.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",
);
}
Ok(Redirect::to(&format!("{}/{}", state.config.admin_prefix, model.table)).into_response())
}
pub(crate) async fn action_submit(
Path(table): Path<String>,
State(state): State<AppState>,
body: axum::body::Bytes,
) -> Result<Response, AdminError> {
let model = lookup_model(&state, &table).ok_or(AdminError::TableNotFound {
table: table.clone(),
})?;
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();
for (k, v) in pairs {
if k == "action" {
action_name = Some(v);
} else if k == "_selected" {
selected_raw.push(v);
}
}
let Some(action) = action_name.filter(|s| !s.is_empty()) else {
return Ok(
Redirect::to(&format!("{}/{}", state.config.admin_prefix, model.table)).into_response(),
);
};
if selected_raw.is_empty() {
return Ok(
Redirect::to(&format!("{}/{}", state.config.admin_prefix, model.table)).into_response(),
);
}
let admin_cfg = model
.admin
.copied()
.unwrap_or(crate::core::AdminConfig::DEFAULT);
if !admin_cfg.actions.iter().any(|a| *a == action) {
return Err(AdminError::Internal(format!(
"action `{action}` not registered for `{}`",
model.name
)));
}
let pk_field = model.primary_key().ok_or_else(|| {
AdminError::Internal(format!("model `{}` has no primary key", model.name))
})?;
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 Ok(
Redirect::to(&format!("{}/{}", state.config.admin_prefix, model.table)).into_response(),
);
}
let action_fields: Vec<&'static FieldSchema> = model.scalar_fields().collect();
let before_rows = crate::sql::select_rows_as_json_pool(
&state.pool,
&SelectQuery {
model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::In,
value: SqlValue::List(pk_values.clone()),
}),
search: None,
joins: vec![],
order_by: vec![],
limit: None,
offset: None,
},
&action_fields,
)
.await
.unwrap_or_default();
let audit_op = if action == "delete_selected" {
if model.soft_delete_column.is_some() {
crate::audit::AuditOp::SoftDelete
} else {
crate::audit::AuditOp::Delete
}
} else if action == "restore_selected" {
crate::audit::AuditOp::Update
} else {
crate::audit::AuditOp::Update
};
if action == "delete_selected" {
if !state.can_delete(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
if let Some(col) = model.soft_delete_column {
crate::sql::update_pool(
&state.pool,
&UpdateQuery {
model,
set: vec![Assignment {
column: col,
value: SqlValue::from(chrono::Utc::now()),
}],
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::In,
value: SqlValue::List(pk_values),
}),
},
)
.await?;
} else {
crate::sql::delete_pool(
&state.pool,
&DeleteQuery {
model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::In,
value: SqlValue::List(pk_values),
}),
},
)
.await?;
}
} else if action == "restore_selected" {
if state.is_read_only(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
if let Some(col) = model.soft_delete_column {
crate::sql::update_pool(
&state.pool,
&UpdateQuery {
model,
set: vec![Assignment {
column: col,
value: SqlValue::Null,
}],
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::In,
value: SqlValue::List(pk_values),
}),
},
)
.await?;
}
} else if let Some(handler) = state.action_handler(model.table, &action) {
if state.is_read_only(model.table) {
return Err(AdminError::ReadOnly {
table: model.table.to_owned(),
});
}
handler(&state.pool, &pk_values).await?;
} else {
return 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
)));
}
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 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 action != "delete_selected" {
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",
);
}
}
Ok(Redirect::to(&format!("{}/{}", state.config.admin_prefix, model.table)).into_response())
}