use crate::core::{
FieldSchema, Filter, ModelEntry, ModelSchema, NullsOrder, Op, OrderItem, SelectQuery, SqlValue,
WhereExpr,
};
use crate::sql::{select_rows_as_json, ExecError, Pool};
use std::collections::{HashMap, HashSet};
use super::errors::AdminError;
use super::helpers::{admin_config_or_default, is_secret_field, lookup_model};
use super::queryset_hooks::RowScope;
use super::urls::AppState;
use axum::http::request::Parts;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InlineKind {
Tabular,
Stacked,
}
pub struct InlineAdmin {
pub parent_table: &'static str,
pub child_table: &'static str,
pub fk_column: &'static str,
pub kind: InlineKind,
pub label: &'static str,
pub fields: &'static [&'static str],
pub extra: usize,
pub max_num: Option<usize>,
pub readonly_fields: &'static [&'static str],
}
inventory::collect!(InlineAdmin);
pub(crate) fn is_parent_pin(child_table: &str, column: &str) -> bool {
inventory::iter::<InlineAdmin>
.into_iter()
.any(|i| i.child_table == child_table && i.fk_column == column)
|| inventory::iter::<InlineAdminGeneric>.into_iter().any(|i| {
i.child_table == child_table && (i.ct_column == column || i.pk_column == column)
})
}
#[must_use]
pub fn for_parent_table(parent_table: &str) -> Vec<&'static InlineAdmin> {
inventory::iter::<InlineAdmin>
.into_iter()
.filter(|i| i.parent_table == parent_table)
.collect()
}
#[macro_export]
macro_rules! register_admin_inline {
(
parent = $parent:expr,
child = $child:expr,
fk = $fk:expr
$(, kind = $kind:expr)?
$(, label = $label:expr)?
$(, fields = $fields:expr)?
$(, extra = $extra:expr)?
$(, max_num = $max_num:expr)?
$(, readonly_fields = $ro:expr)?
$(,)?
) => {
$crate::inventory::submit! {
$crate::admin::inlines::InlineAdmin {
parent_table: $parent,
child_table: $child,
fk_column: $fk,
kind: $crate::register_admin_inline!(@or $($kind)?; $crate::admin::inlines::InlineKind::Tabular),
label: $crate::register_admin_inline!(@or $($label)?; ""),
fields: $crate::register_admin_inline!(@or $($fields)?; &[]),
extra: $crate::register_admin_inline!(@or $($extra)?; 0usize),
max_num: $crate::register_admin_inline!(@or $($max_num)?; ::core::option::Option::<usize>::None),
readonly_fields: $crate::register_admin_inline!(@or $($ro)?; &[]),
}
}
};
(@or $given:expr; $default:expr) => { $given };
(@or ; $default:expr) => { $default };
}
pub struct InlineAdminGeneric {
pub parent_table: &'static str,
pub child_table: &'static str,
pub ct_column: &'static str,
pub pk_column: &'static str,
pub kind: InlineKind,
pub label: &'static str,
pub fields: &'static [&'static str],
pub extra: usize,
pub max_num: Option<usize>,
pub readonly_fields: &'static [&'static str],
}
inventory::collect!(InlineAdminGeneric);
#[must_use]
pub fn generic_for_parent_table(parent_table: &str) -> Vec<&'static InlineAdminGeneric> {
inventory::iter::<InlineAdminGeneric>
.into_iter()
.filter(|i| i.parent_table == parent_table)
.collect()
}
#[macro_export]
macro_rules! register_admin_inline_generic {
(
parent = $parent:expr,
child = $child:expr,
ct = $ct:expr,
pk = $pk:expr
$(, kind = $kind:expr)?
$(, label = $label:expr)?
$(, fields = $fields:expr)?
$(, extra = $extra:expr)?
$(, max_num = $max_num:expr)?
$(, readonly_fields = $ro:expr)?
$(,)?
) => {
$crate::inventory::submit! {
$crate::admin::inlines::InlineAdminGeneric {
parent_table: $parent,
child_table: $child,
ct_column: $ct,
pk_column: $pk,
kind: $crate::register_admin_inline_generic!(@or $($kind)?; $crate::admin::inlines::InlineKind::Tabular),
label: $crate::register_admin_inline_generic!(@or $($label)?; ""),
fields: $crate::register_admin_inline_generic!(@or $($fields)?; &[]),
extra: $crate::register_admin_inline_generic!(@or $($extra)?; 0usize),
max_num: $crate::register_admin_inline_generic!(@or $($max_num)?; ::core::option::Option::<usize>::None),
readonly_fields: $crate::register_admin_inline_generic!(@or $($ro)?; &[]),
}
}
};
(@or $given:expr; $default:expr) => { $given };
(@or ; $default:expr) => { $default };
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct InlinePanel {
pub label: String,
pub child_table: String,
pub kind: String,
pub field_labels: Vec<String>,
pub rows: Vec<serde_json::Value>,
pub empty: bool,
}
pub async fn render_for_parent(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
) -> Result<Vec<InlinePanel>, ExecError> {
render_for_parent_in(pool, parent_model, parent_pk, None).await
}
pub(crate) async fn render_for_parent_in(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
parts: Option<&Parts>,
) -> Result<Vec<InlinePanel>, ExecError> {
let registrations = for_parent_table(parent_model.table);
if registrations.is_empty() {
return Ok(Vec::new());
}
let mut panels = Vec::with_capacity(registrations.len());
for inline in registrations {
let Some(child_model) = find_model_by_table(inline.child_table) else {
continue;
};
if child_model.field_by_column(inline.fk_column).is_none() {
continue;
}
let display_fields = resolve_render_fields(child_model, inline);
let pk_field = child_model.primary_key();
let order_pk: Vec<OrderItem> = pk_field
.map(|pk| OrderItem::Column {
column: pk.column,
desc: false,
nulls: NullsOrder::Default,
})
.into_iter()
.collect();
let (rows, _) = visible_child_rows(
pool,
SelectQuery {
order_by: order_pk,
limit: None,
..SelectQuery::by_pk(child_model, inline.fk_column, parent_pk.clone())
},
parts,
)
.await?;
let field_labels = display_fields.iter().map(|f| f.name.to_owned()).collect();
let pk_column = pk_field.map(|p| p.column).unwrap_or("id");
let child_cfg = admin_config_or_default(child_model);
let rendered_rows: Vec<serde_json::Value> = rows
.iter()
.map(|row| {
let cells: Vec<serde_json::Value> = display_fields
.iter()
.map(|f| {
let raw = row
.get(f.column)
.cloned()
.unwrap_or(serde_json::Value::Null);
serde_json::json!({
"label": f.display_label(),
"value": render_cell(&child_cfg, f, &raw),
})
})
.collect();
let pk_text = row.get(pk_column).map(stringify_pk).unwrap_or_default();
serde_json::json!({
"pk": pk_text,
"pk_path": crate::url_codec::url_encode(&pk_text),
"cells": cells,
})
})
.collect();
let label = if inline.label.is_empty() {
child_model.name.to_owned()
} else {
inline.label.to_owned()
};
let empty = rendered_rows.is_empty();
panels.push(InlinePanel {
label,
child_table: child_model.table.to_owned(),
kind: match inline.kind {
InlineKind::Tabular => "tabular".to_owned(),
InlineKind::Stacked => "stacked".to_owned(),
},
field_labels,
rows: rendered_rows,
empty,
});
}
Ok(panels)
}
pub async fn render_generic_for_parent(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
) -> Result<Vec<InlinePanel>, ExecError> {
render_generic_for_parent_in(pool, parent_model, parent_pk, None).await
}
pub(crate) async fn render_generic_for_parent_in(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
parts: Option<&Parts>,
) -> Result<Vec<InlinePanel>, ExecError> {
let registrations = generic_for_parent_table(parent_model.table);
if registrations.is_empty() {
return Ok(Vec::new());
}
let Some(ct_id) = resolve_ct_id_for_schema(pool, parent_model).await? else {
return Ok(Vec::new());
};
let parent_pk_i64 = match &parent_pk {
SqlValue::I64(v) => *v,
SqlValue::I32(v) => i64::from(*v),
SqlValue::I16(v) => i64::from(*v),
_ => return Ok(Vec::new()),
};
let mut panels = Vec::with_capacity(registrations.len());
for inline in registrations {
let Some(child_model) = find_model_by_table(inline.child_table) else {
continue;
};
if child_model.field_by_column(inline.ct_column).is_none()
|| child_model.field_by_column(inline.pk_column).is_none()
{
continue;
}
let display_fields = resolve_render_fields_generic(child_model, inline);
let pk_field = child_model.primary_key();
let order_pk: Vec<OrderItem> = pk_field
.map(|pk| OrderItem::Column {
column: pk.column,
desc: false,
nulls: NullsOrder::Default,
})
.into_iter()
.collect();
let (rows, _) = visible_child_rows(
pool,
SelectQuery {
where_clause: WhereExpr::And(vec![
WhereExpr::Predicate(Filter {
column: inline.ct_column,
op: Op::Eq,
value: SqlValue::I64(ct_id),
}),
WhereExpr::Predicate(Filter {
column: inline.pk_column,
op: Op::Eq,
value: SqlValue::I64(parent_pk_i64),
}),
]),
order_by: order_pk,
..SelectQuery::new(child_model)
},
parts,
)
.await?;
let field_labels = display_fields.iter().map(|f| f.name.to_owned()).collect();
let pk_column = pk_field.map(|p| p.column).unwrap_or("id");
let child_cfg = admin_config_or_default(child_model);
let rendered_rows: Vec<serde_json::Value> = rows
.iter()
.map(|row| {
let cells: Vec<serde_json::Value> = display_fields
.iter()
.map(|f| {
let raw = row
.get(f.column)
.cloned()
.unwrap_or(serde_json::Value::Null);
serde_json::json!({
"label": f.display_label(),
"value": render_cell(&child_cfg, f, &raw),
})
})
.collect();
let pk_text = row.get(pk_column).map(stringify_pk).unwrap_or_default();
serde_json::json!({
"pk": pk_text,
"pk_path": crate::url_codec::url_encode(&pk_text),
"cells": cells,
})
})
.collect();
let label = if inline.label.is_empty() {
child_model.name.to_owned()
} else {
inline.label.to_owned()
};
let empty = rendered_rows.is_empty();
panels.push(InlinePanel {
label,
child_table: child_model.table.to_owned(),
kind: match inline.kind {
InlineKind::Tabular => "tabular".to_owned(),
InlineKind::Stacked => "stacked".to_owned(),
},
field_labels,
rows: rendered_rows,
empty,
});
}
Ok(panels)
}
fn resolve_render_fields_generic(
child_model: &'static ModelSchema,
inline: &InlineAdminGeneric,
) -> Vec<&'static FieldSchema> {
if inline.fields.is_empty() {
return child_model
.scalar_fields()
.filter(|f| f.column != inline.ct_column && f.column != inline.pk_column)
.collect();
}
inline
.fields
.iter()
.filter_map(|name| child_model.field(name))
.collect()
}
async fn resolve_ct_id_for_schema(
pool: &Pool,
schema: &'static ModelSchema,
) -> Result<Option<i64>, ExecError> {
let entry = ModelEntry::for_table(schema.table);
let Some(entry) = entry else {
return Ok(None);
};
let app = entry.resolved_app_label().unwrap_or("project");
let name = schema.name.to_ascii_lowercase();
let ct = crate::contenttypes::ContentType::by_natural_key(pool, app, &name).await?;
Ok(ct.and_then(|c| c.id.get().copied()))
}
fn find_model_by_table(table: &str) -> Option<&'static ModelSchema> {
ModelEntry::for_table(table).map(|e| e.schema)
}
fn resolve_render_fields(
child_model: &'static ModelSchema,
inline: &InlineAdmin,
) -> Vec<&'static FieldSchema> {
if inline.fields.is_empty() {
return child_model
.scalar_fields()
.filter(|f| f.column != inline.fk_column)
.collect();
}
inline
.fields
.iter()
.filter_map(|name| child_model.field(name))
.collect()
}
fn render_cell(cfg: &crate::core::AdminConfig, f: &FieldSchema, raw: &serde_json::Value) -> String {
if is_secret_field(cfg, f.name) {
super::helpers::render_secret_value(Some(raw))
} else {
render_cell_text(raw)
}
}
fn render_cell_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => String::new(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => html_escape(s),
other => html_escape(&other.to_string()),
}
}
fn stringify_pk(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => s.clone(),
_ => String::new(),
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct InlineFormPanel {
pub label: String,
pub child_table: String,
pub kind: String,
pub prefix: String,
pub total_forms: usize,
pub initial_forms: usize,
pub max_num: Option<usize>,
pub field_labels: Vec<String>,
pub rows: Vec<serde_json::Value>,
pub more_rows_filter: Option<String>,
}
fn row_window(extra: usize) -> (usize, usize) {
let extra = extra.min(crate::forms::formset::MAX_FORMS);
(crate::forms::formset::MAX_FORMS - extra, extra)
}
fn cut_rows(
rows: &mut Vec<serde_json::Value>,
fetched: usize,
cap: usize,
filter: &[(&str, String)],
) -> Option<String> {
rows.truncate(cap);
if fetched <= cap {
return None;
}
serde_urlencoded::to_string(filter).ok()
}
pub async fn render_form_for_parent(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
) -> Result<Vec<InlineFormPanel>, ExecError> {
render_form_for_parent_in(pool, parent_model, parent_pk, None).await
}
pub(crate) async fn render_form_for_parent_in(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
parts: Option<&Parts>,
) -> Result<Vec<InlineFormPanel>, ExecError> {
let registrations = for_parent_table(parent_model.table);
if registrations.is_empty() {
return Ok(Vec::new());
}
let mut panels = Vec::with_capacity(registrations.len());
for inline in registrations {
let Some(child_model) = find_model_by_table(inline.child_table) else {
continue;
};
if child_model.field_by_column(inline.fk_column).is_none() {
continue;
}
let display_fields = resolve_render_fields(child_model, inline);
let pk_field = child_model.primary_key();
let order_pk: Vec<OrderItem> = pk_field
.map(|pk| OrderItem::Column {
column: pk.column,
desc: false,
nulls: NullsOrder::Default,
})
.into_iter()
.collect();
let (cap, extra) = row_window(inline.extra);
let (mut rows, fetched) = visible_child_rows(
pool,
SelectQuery {
order_by: order_pk,
limit: Some(cap as i64 + 1),
..SelectQuery::by_pk(child_model, inline.fk_column, parent_pk.clone())
},
parts,
)
.await?;
let fk_name = child_model
.field_by_column(inline.fk_column)
.map_or(inline.fk_column, |f| f.name);
let more_rows_filter = cut_rows(
&mut rows,
fetched,
cap,
&[(fk_name, parent_pk.to_display_string())],
);
let prefix = child_model.table.to_owned();
let initial_forms = rows.len();
let total_forms = initial_forms + extra;
let pk_column = pk_field.map(|p| p.column).unwrap_or("id");
let child_cfg = admin_config_or_default(child_model);
let mut field_labels: Vec<String> =
display_fields.iter().map(|f| f.name.to_owned()).collect();
if initial_forms > 0 {
field_labels.push("Delete".to_owned());
}
let mut rendered_rows: Vec<serde_json::Value> = Vec::with_capacity(total_forms);
for (idx, row) in rows.iter().enumerate() {
let pk_text = row.get(pk_column).map(stringify_pk).unwrap_or_default();
let cells: Vec<serde_json::Value> = display_fields
.iter()
.map(|f| {
let raw_str = crate::admin::render::render_value_for_input_json(row, f);
let input_html = render_prefixed_input(&child_cfg, f, &raw_str, &prefix, idx);
serde_json::json!({
"label": f.display_label(),
"input_html": input_html,
})
})
.collect();
let pk_field_name = pk_field.map(|p| p.name).unwrap_or("id");
let hidden_pk = format!(
r#"<input type="hidden" name="{p}-{i}-{n}" value="{v}">"#,
p = html_escape(&prefix),
i = idx,
n = html_escape(pk_field_name),
v = html_escape(&pk_text),
);
let delete_input_html = format!(
r#"<input type="checkbox" name="{p}-{i}-DELETE" value="on">"#,
p = html_escape(&prefix),
i = idx,
);
rendered_rows.push(serde_json::json!({
"pk": pk_text,
"cells": cells,
"hidden_pk": hidden_pk,
"delete_input_html": delete_input_html,
}));
}
for idx in initial_forms..total_forms {
let cells: Vec<serde_json::Value> = display_fields
.iter()
.map(|f| {
let input_html = render_prefixed_input(&child_cfg, f, "", &prefix, idx);
serde_json::json!({
"label": f.display_label(),
"input_html": input_html,
})
})
.collect();
rendered_rows.push(serde_json::json!({
"pk": "",
"cells": cells,
"hidden_pk": "",
"delete_input_html": "",
}));
}
let label = if inline.label.is_empty() {
child_model.name.to_owned()
} else {
inline.label.to_owned()
};
panels.push(InlineFormPanel {
label,
child_table: child_model.table.to_owned(),
kind: match inline.kind {
InlineKind::Tabular => "tabular".to_owned(),
InlineKind::Stacked => "stacked".to_owned(),
},
prefix,
total_forms,
initial_forms,
max_num: inline.max_num,
field_labels,
rows: rendered_rows,
more_rows_filter,
});
}
Ok(panels)
}
fn render_prefixed_input(
cfg: &crate::core::AdminConfig,
field: &FieldSchema,
value: &str,
prefix: &str,
idx: usize,
) -> String {
let base = if is_secret_field(cfg, field.name) {
crate::admin::render::render_secret_input(field, false, false)
} else {
let widget = cfg
.formfield_overrides
.iter()
.find(|(name, _)| *name == field.name)
.map(|(_, w)| *w);
crate::admin::render::render_input_with_widget(field, value, false, widget)
};
let target_name = format!(r#"name="{}""#, field.name);
let new_name = format!(r#"name="{prefix}-{idx}-{}""#, field.name);
let target_id = format!(r#"id="{}""#, field.name);
let new_id = format!(r#"id="{prefix}-{idx}-{}""#, field.name);
base.replacen(&target_name, &new_name, 1)
.replacen(&target_id, &new_id, 1)
}
#[derive(Debug, Default, Clone, Copy)]
pub struct InlineApplyOutcome {
pub updated: usize,
pub deleted: usize,
pub inserted: usize,
#[deprecated(since = "0.60.4", note = "always 0 since #2339; removed in 0.61.0")]
pub failed: usize,
}
async fn visible_child_rows(
pool: &Pool,
query: SelectQuery,
parts: Option<&Parts>,
) -> Result<(Vec<serde_json::Value>, usize), ExecError> {
let child = query.model;
let fields: Vec<&'static FieldSchema> = child.scalar_fields().collect();
let Some(parts) = parts else {
let rows = select_rows_as_json(pool, &query, &fields).await?;
let fetched = rows.len();
return Ok((rows, fetched));
};
let query = SelectQuery {
where_clause: RowScope::of(child, parts).constrain(query.where_clause),
..query
};
let mut rows = select_rows_as_json(pool, &query, &fields).await?;
let fetched = rows.len();
rows.retain(|row| {
crate::admin::object_permissions::is_allowed(child.table, "view", parts, Some(row))
});
Ok((rows, fetched))
}
struct ParentScope(Vec<(&'static str, SqlValue)>);
impl ParentScope {
fn row_where(&self, pk_column: &'static str, pk: SqlValue) -> WhereExpr {
let mut filters = vec![Filter::new(pk_column, Op::Eq, pk)];
filters.extend(self.filters());
WhereExpr::and_predicates(filters)
}
fn all_where(&self) -> WhereExpr {
WhereExpr::and_predicates(self.filters().collect())
}
fn filters(&self) -> impl Iterator<Item = Filter> + '_ {
self.0
.iter()
.map(|(column, value)| Filter::new(column, Op::Eq, value.clone()))
}
fn pins(&self, column: &str) -> bool {
self.0.iter().any(|(c, _)| *c == column)
}
}
struct InlineTarget {
child: &'static ModelSchema,
pk: &'static FieldSchema,
writable: Vec<&'static FieldSchema>,
scope: ParentScope,
rows: RowScope,
secrets: Vec<&'static str>,
max_num: Option<usize>,
}
impl InlineTarget {
fn new(
child: &'static ModelSchema,
display: &[&'static FieldSchema],
inline_readonly: &[&str],
max_num: Option<usize>,
scope: ParentScope,
state: &AppState,
parts: &Parts,
) -> Option<Self> {
let pk = child.primary_key()?;
let cfg = admin_config_or_default(child);
let add = state.locked_fields(child, super::object_permissions::AdminWrite::Add);
let change = state.locked_fields(child, super::object_permissions::AdminWrite::Change);
let secrets = display
.iter()
.filter(|f| is_secret_field(&cfg, f.name))
.map(|f| f.name)
.collect();
let writable = display
.iter()
.copied()
.filter(|f| {
f.column != pk.column
&& !scope.pins(f.column)
&& !inline_readonly.contains(&f.name)
&& !add.contains(f.name)
&& !change.contains(f.name)
})
.collect();
Some(Self {
child,
pk,
writable,
scope,
rows: RowScope::of(child, parts),
secrets,
max_num,
})
}
fn row_where(&self, pk: SqlValue) -> WhereExpr {
self.rows
.constrain(self.scope.row_where(self.pk.column, pk))
}
fn values(
&self,
row: &HashMap<String, String>,
edit: bool,
) -> Result<Vec<(&'static str, SqlValue)>, crate::forms::FormError> {
self.writable
.iter()
.filter(|f| {
!(edit
&& self.secrets.contains(&f.name)
&& row.get(f.name).is_none_or(String::is_empty))
})
.map(|f| {
let value = crate::forms::parse_form_value(f, row.get(f.name).map(String::as_str))?;
Ok((f.column, value))
})
.collect()
}
async fn fetch_own(
&self,
pool: &Pool,
parts: &Parts,
pk: &SqlValue,
raw_pk: &str,
) -> Result<Option<serde_json::Value>, AdminError> {
let not_found = || AdminError::RowNotFound {
table: self.child.table.to_owned(),
pk: raw_pk.to_owned(),
};
let fields: Vec<&'static FieldSchema> = self.child.scalar_fields().collect();
let query = SelectQuery {
where_clause: self.row_where(pk.clone()),
..SelectQuery::new(self.child)
};
if let Some(row) = crate::sql::select_one_row_as_json(pool, &query, &fields).await? {
let visible = crate::admin::object_permissions::is_allowed(
self.child.table,
"view",
parts,
Some(&row),
);
return if visible {
Ok(Some(row))
} else {
Err(not_found())
};
}
let any_parent = SelectQuery::by_pk(self.child, self.pk.column, pk.clone());
match crate::sql::select_one_row_as_json(pool, &any_parent, &[self.pk]).await? {
Some(_) => Err(not_found()),
None => Ok(None),
}
}
fn unchanged(&self, before: &serde_json::Value, values: &[(&'static str, SqlValue)]) -> bool {
values.iter().all(|(column, submitted)| {
let Some(f) = self.writable.iter().find(|f| f.column == *column) else {
return false;
};
if self.secrets.contains(&f.name) {
return false;
}
let stored = crate::admin::render::render_value_for_input_json(before, f);
crate::forms::parse_form_value(f, Some(&stored)).is_ok_and(|v| v == *submitted)
})
}
}
pub(crate) enum InlinePlanError {
Admin(AdminError),
Rejected(String),
BadFormset(crate::forms::formset::FormSetError),
}
impl From<AdminError> for InlinePlanError {
fn from(e: AdminError) -> Self {
Self::Admin(e)
}
}
impl From<ExecError> for InlinePlanError {
fn from(e: ExecError) -> Self {
Self::Admin(e.into())
}
}
pub(crate) struct InlinePlan {
targets: Vec<TargetPlan>,
}
struct TargetPlan {
child: &'static ModelSchema,
pk: &'static FieldSchema,
existing: Vec<InlineWrite>,
inserts: Vec<InlineInsert>,
deletes_needed: usize,
}
enum InlineWrite {
Update {
query: crate::core::UpdateQuery,
audit: Option<ChildAudit>,
},
Delete {
query: InlineRemove,
audit: Option<String>,
},
}
enum InlineRemove {
Hard(crate::core::DeleteQuery),
Soft(crate::core::UpdateQuery),
}
impl InlineRemove {
fn where_clause(&self) -> &WhereExpr {
match self {
Self::Hard(q) => &q.where_clause,
Self::Soft(q) => &q.where_clause,
}
}
fn op(&self) -> crate::audit::AuditOp {
match self {
Self::Hard(_) => crate::audit::AuditOp::Delete,
Self::Soft(_) => crate::audit::AuditOp::SoftDelete,
}
}
async fn run(&self, tx: &mut crate::sql::PoolTx<'_>) -> Result<u64, ExecError> {
match self {
Self::Hard(q) => crate::sql::delete_tx(tx, q).await,
Self::Soft(q) => crate::sql::update_tx(tx, q).await,
}
}
}
struct InlineInsert {
query: crate::core::InsertQuery,
audit: Option<HashMap<String, String>>,
}
struct ChildAudit {
pk: String,
form: HashMap<String, String>,
}
pub(crate) async fn plan_post(
state: &AppState,
parts: &Parts,
parent_model: &'static ModelSchema,
parent_pk: &SqlValue,
form: &HashMap<String, String>,
) -> Result<InlinePlan, InlinePlanError> {
let mut plan = InlinePlan {
targets: Vec::new(),
};
for target in inline_targets(state, parts, parent_model, parent_pk).await? {
plan_target(state, parts, &target, form, &mut plan).await?;
}
Ok(plan)
}
async fn inline_targets(
state: &AppState,
parts: &Parts,
parent_model: &'static ModelSchema,
parent_pk: &SqlValue,
) -> Result<Vec<InlineTarget>, ExecError> {
let mut out = Vec::new();
for inline in for_parent_table(parent_model.table) {
let Some(child) = lookup_model(state, inline.child_table) else {
continue;
};
if child.field_by_column(inline.fk_column).is_none() {
continue;
}
let scope = ParentScope(vec![(inline.fk_column, parent_pk.clone())]);
let display = resolve_render_fields(child, inline);
out.extend(InlineTarget::new(
child,
&display,
inline.readonly_fields,
inline.max_num,
scope,
state,
parts,
));
}
let generic = generic_for_parent_table(parent_model.table);
if generic.is_empty() {
return Ok(out);
}
let Some(ct_id) = resolve_ct_id_for_schema(&state.pool, parent_model).await? else {
return Ok(out);
};
let parent_pk_i64 = match parent_pk {
SqlValue::I64(v) => *v,
SqlValue::I32(v) => i64::from(*v),
SqlValue::I16(v) => i64::from(*v),
_ => return Ok(out),
};
for inline in generic {
let Some(child) = lookup_model(state, inline.child_table) else {
continue;
};
if child.field_by_column(inline.ct_column).is_none()
|| child.field_by_column(inline.pk_column).is_none()
{
continue;
}
let scope = ParentScope(vec![
(inline.ct_column, SqlValue::I64(ct_id)),
(inline.pk_column, SqlValue::I64(parent_pk_i64)),
]);
let display = resolve_render_fields_generic(child, inline);
out.extend(InlineTarget::new(
child,
&display,
inline.readonly_fields,
inline.max_num,
scope,
state,
parts,
));
}
Ok(out)
}
async fn plan_target(
state: &AppState,
parts: &Parts,
target: &InlineTarget,
form: &HashMap<String, String>,
plan: &mut InlinePlan,
) -> Result<(), InlinePlanError> {
let table = target.child.table;
let name = target.child.name;
let total_forms = match crate::forms::formset::total_forms(form, table) {
Ok(n) => n,
Err(crate::forms::formset::FormSetError::MissingTotalForms(_)) => return Ok(()),
Err(e) => return Err(InlinePlanError::BadFormset(e)),
};
let refused = |action: &'static str| AdminError::Forbidden {
table: table.to_owned(),
action,
};
let read_only = || AdminError::ReadOnly {
table: table.to_owned(),
};
let bad_row = |idx: usize, e: crate::forms::FormError| {
InlinePlanError::Rejected(format!("{name} row {}: {e}", idx + 1))
};
let initial = crate::forms::formset::initial_forms(form, table);
let natural_pk = (!target.pk.auto).then_some(target.pk);
let cfg = admin_config_or_default(target.child);
let audited = target.child.audit_track.is_some();
let mut out = TargetPlan {
child: target.child,
pk: target.pk,
existing: Vec::new(),
inserts: Vec::new(),
deletes_needed: 0,
};
let mut deleted: HashSet<String> = HashSet::new();
for idx in 0..total_forms {
let row = crate::forms::formset::row_payload(form, table, idx);
let raw_pk = row.get(target.pk.name).cloned().unwrap_or_default();
let delete_flag = row
.get("DELETE")
.is_some_and(|s| s == "on" || s == "true" || s == "1");
if initial.map_or(raw_pk.trim().is_empty(), |n| idx >= n) {
let has_content = target
.writable
.iter()
.chain(&natural_pk)
.any(|f| row.get(f.name).is_some_and(|s| !s.trim().is_empty()));
if !has_content || delete_flag {
continue;
}
if !state.can_add(table) {
return Err(read_only().into());
}
if !crate::admin::object_permissions::is_allowed(table, "add", parts, None) {
return Err(refused("add").into());
}
let mut values = target.values(&row, false).map_err(|e| bad_row(idx, e))?;
if let Some(pk) = natural_pk {
let value =
crate::forms::parse_pk_string(pk, &raw_pk).map_err(|e| bad_row(idx, e))?;
values.push((pk.column, value));
}
let (mut columns, mut sql_values): (Vec<&'static str>, Vec<SqlValue>) =
values.into_iter().unzip();
for (column, value) in &target.scope.0 {
columns.push(column);
sql_values.push(value.clone());
}
crate::forms::stamp_auto_timestamps(target.child, &mut columns, &mut sql_values);
if audited && target.pk.auto && !columns.contains(&target.pk.column) {
if let crate::core::FieldType::Uuid = target.pk.ty {
columns.push(target.pk.column);
sql_values.push(SqlValue::Uuid(uuid::Uuid::now_v7()));
}
}
let audit = audited.then(|| {
let written: Vec<(&'static str, SqlValue)> = columns
.iter()
.copied()
.zip(sql_values.iter().cloned())
.collect();
let mut row = row.clone();
for (column, value) in &target.scope.0 {
if let Some(f) = target.child.field_by_column(column) {
row.insert(f.name.to_owned(), value.to_display_string());
}
}
super::audit::audit_form(target.child, &cfg, &row, &written)
});
let mut query = crate::core::InsertQuery::new(target.child, columns, sql_values);
if audited {
query.returning = vec![target.pk.column];
}
out.inserts.push(InlineInsert { query, audit });
continue;
}
let pk = crate::forms::parse_pk_string(target.pk, &raw_pk).map_err(|e| bad_row(idx, e))?;
if delete_flag {
if !state.can_delete(table) {
return Err(read_only().into());
}
if !deleted.insert(pk.to_display_string()) {
continue;
}
let Some(before) = target.fetch_own(&state.pool, parts, &pk, &raw_pk).await? else {
continue;
};
if !crate::admin::object_permissions::is_allowed(table, "delete", parts, Some(&before))
{
return Err(refused("delete").into());
}
let audit = audited.then(|| pk.to_display_string());
let query = match target.child.soft_delete_column {
Some(col) => {
let stamp = SqlValue::DateTime(chrono::Utc::now());
let set = vec![crate::core::Assignment::new(col, stamp)];
InlineRemove::Soft(crate::core::UpdateQuery::new(
target.child,
set,
target.row_where(pk),
))
}
None => InlineRemove::Hard(crate::core::DeleteQuery::new(
target.child,
target.row_where(pk),
)),
};
out.existing.push(InlineWrite::Delete { query, audit });
continue;
}
let values = target.values(&row, true).map_err(|e| bad_row(idx, e))?;
if values.is_empty() {
continue;
}
let Some(before) = target.fetch_own(&state.pool, parts, &pk, &raw_pk).await? else {
return Err(InlinePlanError::Rejected(format!(
"{name} row {raw_pk} was deleted after this page loaded. Reload the page and try again."
)));
};
if target.unchanged(&before, &values) {
continue;
}
if state.is_read_only(table) {
return Err(read_only().into());
}
if !crate::admin::object_permissions::is_allowed(table, "change", parts, Some(&before)) {
return Err(refused("change").into());
}
let audit = audited.then(|| ChildAudit {
pk: pk.to_display_string(),
form: super::audit::audit_form(target.child, &cfg, &row, &values),
});
let set = values
.into_iter()
.map(|(column, value)| crate::core::Assignment::new(column, value))
.collect();
out.existing.push(InlineWrite::Update {
query: crate::core::UpdateQuery::new(target.child, set, target.row_where(pk)),
audit,
});
}
if let Some(max) = target.max_num.filter(|_| !out.inserts.is_empty()) {
let count = crate::core::CountQuery {
model: target.child,
where_clause: crate::soft_delete::compose_with_active(
target.child,
target.scope.all_where(),
),
search: None,
source: None,
};
let existing = usize::try_from(crate::sql::count_rows_pool(&state.pool, &count).await?)
.unwrap_or(usize::MAX);
let deletes = out
.existing
.iter()
.filter(|w| matches!(w, InlineWrite::Delete { .. }))
.count();
out.deletes_needed = existing
.saturating_add(out.inserts.len())
.saturating_sub(max);
if out.deletes_needed > deletes {
return Err(InlinePlanError::Rejected(format!(
"{name} allows at most {max} rows here."
)));
}
}
plan.targets.push(out);
Ok(())
}
pub(crate) enum InlineApplyError {
Write {
child: &'static ModelSchema,
error: ExecError,
},
MaxNum { child: &'static ModelSchema },
}
pub(crate) async fn apply_plan_tx(
tx: &mut crate::sql::PoolTx<'_>,
pool: &Pool,
plan: InlinePlan,
) -> Result<InlineApplyOutcome, InlineApplyError> {
use crate::audit::{AuditOp, DiffEmit, RowDiffWrite};
let mut outcome = InlineApplyOutcome::default();
for target in plan.targets {
let child = target.child;
let refused = |error| InlineApplyError::Write { child, error };
let fields: Vec<&'static FieldSchema> = child.scalar_fields().collect();
let locked = |where_clause: &WhereExpr| SelectQuery {
where_clause: where_clause.clone(),
lock_mode: Some(crate::core::LockMode {
silent_on_sqlite: true,
..crate::core::LockMode::default()
}),
..SelectQuery::new(child)
};
let mut entries: Vec<crate::audit::PendingEntry> = Vec::new();
let mut deleted = 0usize;
for write in target.existing {
match write {
InlineWrite::Update { query, audit: None } => {
crate::sql::update_tx(tx, &query).await.map_err(refused)?;
outcome.updated += 1;
}
InlineWrite::Update {
query,
audit: Some(audit),
} => {
let written = crate::audit::update_one_with_row_diff_tx(
tx,
pool,
&query,
locked(&query.where_clause),
&fields,
|row| {
super::audit::admin_audit_diff_entry(child, &audit.pk, row, &audit.form)
},
DiffEmit::Deferred,
)
.await
.map_err(refused)?;
if let RowDiffWrite::Written {
deferred: Some(entry),
} = written
{
entries.push(entry);
}
outcome.updated += 1;
}
InlineWrite::Delete { query, audit } => {
let before = match &audit {
Some(_) => {
let select = locked(query.where_clause());
crate::sql::select_one_row_as_json_tx(tx, &select, &fields)
.await
.map_err(refused)?
}
None => None,
};
if query.run(tx).await.map_err(refused)? == 0 {
continue;
}
deleted += 1;
if let (Some(pk), Some(row)) = (audit, before) {
let entry = super::audit::admin_row_snapshot_entry(
child,
pk,
query.op(),
&row,
None,
);
entries.push(entry);
}
}
}
}
outcome.deleted += deleted;
if deleted < target.deletes_needed {
return Err(InlineApplyError::MaxNum { child });
}
for insert in target.inserts {
match insert.audit {
None => crate::sql::insert_tx(tx, &insert.query)
.await
.map_err(refused)?,
Some(form) => {
let (_, entry) = crate::audit::insert_one_with_entry_tx(
tx,
pool,
&insert.query,
target.pk,
|pk| {
let pk = pk.to_display_string();
super::audit::admin_audit_entry(child, &pk, AuditOp::Create, &form)
},
DiffEmit::Deferred,
)
.await
.map_err(refused)?;
entries.extend(entry);
}
}
outcome.inserted += 1;
}
if !entries.is_empty() {
crate::audit::emit_in_tx(tx, pool, &entries)
.await
.map_err(refused)?;
}
}
Ok(outcome)
}
pub async fn render_form_generic_for_parent(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
) -> Result<Vec<InlineFormPanel>, ExecError> {
render_form_generic_for_parent_in(pool, parent_model, parent_pk, None).await
}
pub(crate) async fn render_form_generic_for_parent_in(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
parts: Option<&Parts>,
) -> Result<Vec<InlineFormPanel>, ExecError> {
let registrations = generic_for_parent_table(parent_model.table);
if registrations.is_empty() {
return Ok(Vec::new());
}
let Some(ct_id) = resolve_ct_id_for_schema(pool, parent_model).await? else {
return Ok(Vec::new());
};
let parent_pk_i64 = match &parent_pk {
SqlValue::I64(v) => *v,
SqlValue::I32(v) => i64::from(*v),
SqlValue::I16(v) => i64::from(*v),
_ => return Ok(Vec::new()),
};
let mut panels = Vec::with_capacity(registrations.len());
for inline in registrations {
let Some(child_model) = find_model_by_table(inline.child_table) else {
continue;
};
if child_model.field_by_column(inline.ct_column).is_none()
|| child_model.field_by_column(inline.pk_column).is_none()
{
continue;
}
let display_fields = resolve_render_fields_generic(child_model, inline);
let pk_field = child_model.primary_key();
let order_pk: Vec<OrderItem> = pk_field
.map(|pk| OrderItem::Column {
column: pk.column,
desc: false,
nulls: NullsOrder::Default,
})
.into_iter()
.collect();
let (cap, extra) = row_window(inline.extra);
let (mut rows, fetched) = visible_child_rows(
pool,
SelectQuery {
where_clause: WhereExpr::And(vec![
WhereExpr::Predicate(Filter {
column: inline.ct_column,
op: Op::Eq,
value: SqlValue::I64(ct_id),
}),
WhereExpr::Predicate(Filter {
column: inline.pk_column,
op: Op::Eq,
value: SqlValue::I64(parent_pk_i64),
}),
]),
order_by: order_pk,
limit: Some(cap as i64 + 1),
..SelectQuery::new(child_model)
},
parts,
)
.await?;
let name_of = |column: &'static str| {
child_model
.field_by_column(column)
.map_or(column, |f| f.name)
};
let more_rows_filter = cut_rows(
&mut rows,
fetched,
cap,
&[
(name_of(inline.ct_column), ct_id.to_string()),
(name_of(inline.pk_column), parent_pk_i64.to_string()),
],
);
let prefix = child_model.table.to_owned();
let initial_forms = rows.len();
let total_forms = initial_forms + extra;
let pk_column = pk_field.map(|p| p.column).unwrap_or("id");
let child_cfg = admin_config_or_default(child_model);
let mut field_labels: Vec<String> =
display_fields.iter().map(|f| f.name.to_owned()).collect();
if initial_forms > 0 {
field_labels.push("Delete".to_owned());
}
let mut rendered_rows: Vec<serde_json::Value> = Vec::with_capacity(total_forms);
for (idx, row) in rows.iter().enumerate() {
let pk_text = row.get(pk_column).map(stringify_pk).unwrap_or_default();
let cells: Vec<serde_json::Value> = display_fields
.iter()
.map(|f| {
let raw_str = crate::admin::render::render_value_for_input_json(row, f);
let input_html = render_prefixed_input(&child_cfg, f, &raw_str, &prefix, idx);
serde_json::json!({
"label": f.display_label(),
"input_html": input_html,
})
})
.collect();
let pk_field_name = pk_field.map(|p| p.name).unwrap_or("id");
let hidden_pk = format!(
r#"<input type="hidden" name="{p}-{i}-{n}" value="{v}">"#,
p = html_escape(&prefix),
i = idx,
n = html_escape(pk_field_name),
v = html_escape(&pk_text),
);
let delete_input_html = format!(
r#"<input type="checkbox" name="{p}-{i}-DELETE" value="on">"#,
p = html_escape(&prefix),
i = idx,
);
rendered_rows.push(serde_json::json!({
"pk": pk_text,
"cells": cells,
"hidden_pk": hidden_pk,
"delete_input_html": delete_input_html,
}));
}
for idx in initial_forms..total_forms {
let cells: Vec<serde_json::Value> = display_fields
.iter()
.map(|f| {
let input_html = render_prefixed_input(&child_cfg, f, "", &prefix, idx);
serde_json::json!({
"label": f.display_label(),
"input_html": input_html,
})
})
.collect();
rendered_rows.push(serde_json::json!({
"pk": "",
"cells": cells,
"hidden_pk": "",
"delete_input_html": "",
}));
}
let label = if inline.label.is_empty() {
child_model.name.to_owned()
} else {
inline.label.to_owned()
};
panels.push(InlineFormPanel {
label,
child_table: child_model.table.to_owned(),
kind: match inline.kind {
InlineKind::Tabular => "tabular".to_owned(),
InlineKind::Stacked => "stacked".to_owned(),
},
prefix,
total_forms,
initial_forms,
max_num: inline.max_num,
field_labels,
rows: rendered_rows,
more_rows_filter,
});
}
Ok(panels)
}
use crate::text::html_escape;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cut_rows_links_the_rest_only_past_the_cap() {
let mut rows = vec![serde_json::json!({}); 3];
let filter = [
("content_type", "4".to_owned()),
("object_id", "7".to_owned()),
];
assert_eq!(cut_rows(&mut rows, 3, 3, &filter), None);
assert_eq!(
cut_rows(&mut rows, 3, 2, &filter).as_deref(),
Some("content_type=4&object_id=7")
);
assert_eq!(rows.len(), 2);
assert!(cut_rows(&mut rows, 3, 2, &filter).is_some());
assert_eq!(row_window(5000), (0, crate::forms::formset::MAX_FORMS));
}
#[test]
fn html_escape_quotes_and_brackets() {
assert_eq!(
html_escape("<a href='x'>&"),
"<a href='x'>&"
);
}
#[test]
fn render_cell_text_handles_primitives() {
assert_eq!(render_cell_text(&serde_json::Value::Null), "");
assert_eq!(render_cell_text(&serde_json::json!(42)), "42");
assert_eq!(render_cell_text(&serde_json::json!(true)), "true");
assert_eq!(
render_cell_text(&serde_json::json!("hi <b>")),
"hi <b>"
);
}
#[test]
fn stringify_pk_supports_numeric_and_string_keys() {
assert_eq!(stringify_pk(&serde_json::json!(7)), "7");
assert_eq!(stringify_pk(&serde_json::json!("INV-1")), "INV-1");
assert_eq!(stringify_pk(&serde_json::json!(null)), "");
}
}