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;
use super::errors::AdminError;
use super::helpers::lookup_model;
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);
#[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> {
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 select_fields: Vec<&'static FieldSchema> = match pk_field {
Some(pk) if !display_fields.iter().any(|f| f.column == pk.column) => {
let mut v = Vec::with_capacity(display_fields.len() + 1);
v.push(pk);
v.extend_from_slice(&display_fields);
v
}
_ => display_fields.clone(),
};
let order_pk: Vec<OrderItem> = pk_field
.map(|pk| OrderItem::Column {
column: pk.column,
desc: false,
nulls: NullsOrder::Default,
})
.into_iter()
.collect();
let rows = select_rows_as_json(
pool,
&SelectQuery {
order_by: order_pk,
limit: None,
..SelectQuery::by_pk(child_model, inline.fk_column, parent_pk.clone())
},
&select_fields,
)
.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 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_text(&raw),
})
})
.collect();
let pk_text = row.get(pk_column).map(stringify_pk).unwrap_or_default();
serde_json::json!({
"pk": 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> {
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 select_fields: Vec<&'static FieldSchema> = match pk_field {
Some(pk) if !display_fields.iter().any(|f| f.column == pk.column) => {
let mut v = Vec::with_capacity(display_fields.len() + 1);
v.push(pk);
v.extend_from_slice(&display_fields);
v
}
_ => display_fields.clone(),
};
let order_pk: Vec<OrderItem> = pk_field
.map(|pk| OrderItem::Column {
column: pk.column,
desc: false,
nulls: NullsOrder::Default,
})
.into_iter()
.collect();
let rows = select_rows_as_json(
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)
},
&select_fields,
)
.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 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_text(&raw),
})
})
.collect();
let pk_text = row.get(pk_column).map(stringify_pk).unwrap_or_default();
serde_json::json!({
"pk": 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 = inventory::iter::<ModelEntry>
.into_iter()
.find(|e| e.schema.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> {
inventory::iter::<ModelEntry>
.into_iter()
.find(|e| e.schema.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_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 async fn render_form_for_parent(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
) -> 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 select_fields: Vec<&'static FieldSchema> = match pk_field {
Some(pk) if !display_fields.iter().any(|f| f.column == pk.column) => {
let mut v = Vec::with_capacity(display_fields.len() + 1);
v.push(pk);
v.extend_from_slice(&display_fields);
v
}
_ => display_fields.clone(),
};
let order_pk: Vec<OrderItem> = pk_field
.map(|pk| OrderItem::Column {
column: pk.column,
desc: false,
nulls: NullsOrder::Default,
})
.into_iter()
.collect();
let rows = select_rows_as_json(
pool,
&SelectQuery {
order_by: order_pk,
limit: None,
..SelectQuery::by_pk(child_model, inline.fk_column, parent_pk.clone())
},
&select_fields,
)
.await?;
let prefix = child_model.table.to_owned();
let initial_forms = rows.len();
let total_forms = initial_forms + inline.extra;
let pk_column = pk_field.map(|p| p.column).unwrap_or("id");
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(f, &raw_str, &prefix, idx, false);
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(f, "", &prefix, idx, false);
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,
});
}
Ok(panels)
}
fn render_prefixed_input(
field: &FieldSchema,
value: &str,
prefix: &str,
idx: usize,
pk_locked: bool,
) -> String {
let base = crate::admin::render::render_input(field, value, pk_locked);
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,
pub failed: usize,
}
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.0
.iter()
.map(|(column, value)| Filter::new(column, Op::Eq, value.clone())),
);
WhereExpr::and_predicates(filters)
}
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,
}
impl InlineTarget {
fn new(
child: &'static ModelSchema,
display: &[&'static FieldSchema],
inline_readonly: &[&str],
scope: ParentScope,
) -> Option<Self> {
let pk = child.primary_key()?;
let admin_readonly = crate::admin::helpers::admin_config_or_default(child).readonly_fields;
let writable = display
.iter()
.copied()
.filter(|f| {
f.column != pk.column
&& !scope.pins(f.column)
&& !inline_readonly.contains(&f.name)
&& !admin_readonly.contains(&f.name)
})
.collect();
Some(Self {
child,
pk,
writable,
scope,
})
}
fn values(
&self,
row: &HashMap<String, String>,
) -> Result<Vec<(&'static str, SqlValue)>, crate::forms::FormError> {
self.writable
.iter()
.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,
pk: &SqlValue,
raw_pk: &str,
) -> Result<Option<serde_json::Value>, AdminError> {
let fields: Vec<&'static FieldSchema> = self.child.scalar_fields().collect();
let query = SelectQuery {
where_clause: self.scope.row_where(self.pk.column, pk.clone()),
..SelectQuery::new(self.child)
};
if let Some(row) = crate::sql::select_one_row_as_json(pool, &query, &fields).await? {
return Ok(Some(row));
}
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(AdminError::RowNotFound {
table: self.child.table.to_owned(),
pk: raw_pk.to_owned(),
}),
None => Ok(None),
}
}
fn unchanged(&self, before: &serde_json::Value, values: &[(&'static str, SqlValue)]) -> bool {
self.writable.iter().zip(values).all(|(f, (_, submitted))| {
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),
Gone(String),
}
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 {
writes: Vec<InlineWrite>,
failed: usize,
}
enum InlineWrite {
Update(crate::core::UpdateQuery),
Delete(crate::core::DeleteQuery),
Insert(crate::core::InsertQuery),
}
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 {
writes: Vec::new(),
failed: 0,
};
for target in inline_targets(state, parent_model, parent_pk).await? {
plan_target(state, parts, &target, form, &mut plan).await?;
}
Ok(plan)
}
async fn inline_targets(
state: &AppState,
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,
scope,
));
}
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,
scope,
));
}
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 Ok(total_forms) = crate::forms::formset::total_forms(form, table) else {
return Ok(());
};
let refused = |action: &'static str| AdminError::Forbidden {
table: table.to_owned(),
action,
};
let read_only = || AdminError::ReadOnly {
table: table.to_owned(),
};
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 raw_pk.trim().is_empty() {
let has_content = target
.writable
.iter()
.any(|f| row.get(f.name).is_some_and(|s| !s.trim().is_empty()));
if !has_content {
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 Ok(values) = target.values(&row) else {
plan.failed += 1;
continue;
};
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);
plan.writes
.push(InlineWrite::Insert(crate::core::InsertQuery::new(
target.child,
columns,
sql_values,
)));
continue;
}
let Ok(pk) = crate::forms::parse_pk_string(target.pk, &raw_pk) else {
plan.failed += 1;
continue;
};
if delete_flag {
if !state.can_delete(table) {
return Err(read_only().into());
}
let Some(before) = target.fetch_own(&state.pool, &pk, &raw_pk).await? else {
continue;
};
if !crate::admin::object_permissions::is_allowed(table, "delete", parts, Some(&before))
{
return Err(refused("delete").into());
}
plan.writes
.push(InlineWrite::Delete(crate::core::DeleteQuery::new(
target.child,
target.scope.row_where(target.pk.column, pk),
)));
continue;
}
let Ok(values) = target.values(&row) else {
plan.failed += 1;
continue;
};
if values.is_empty() {
continue;
}
let Some(before) = target.fetch_own(&state.pool, &pk, &raw_pk).await? else {
return Err(InlinePlanError::Gone(format!(
"{table} 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 set = values
.into_iter()
.map(|(column, value)| crate::core::Assignment::new(column, value))
.collect();
plan.writes
.push(InlineWrite::Update(crate::core::UpdateQuery::new(
target.child,
set,
target.scope.row_where(target.pk.column, pk),
)));
}
Ok(())
}
pub(crate) async fn apply_plan(pool: &Pool, plan: InlinePlan) -> InlineApplyOutcome {
let mut outcome = InlineApplyOutcome {
failed: plan.failed,
..InlineApplyOutcome::default()
};
for write in plan.writes {
match write {
InlineWrite::Update(q) => match crate::sql::update_pool(pool, &q).await {
Ok(_) => outcome.updated += 1,
Err(_) => outcome.failed += 1,
},
InlineWrite::Delete(q) => match crate::sql::delete_pool(pool, &q).await {
Ok(0) | Err(_) => outcome.failed += 1,
Ok(_) => outcome.deleted += 1,
},
InlineWrite::Insert(q) => match crate::sql::insert_pool(pool, &q).await {
Ok(()) => outcome.inserted += 1,
Err(_) => outcome.failed += 1,
},
}
}
outcome
}
pub async fn render_form_generic_for_parent(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
) -> 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 select_fields: Vec<&'static FieldSchema> = match pk_field {
Some(pk) if !display_fields.iter().any(|f| f.column == pk.column) => {
let mut v = Vec::with_capacity(display_fields.len() + 1);
v.push(pk);
v.extend_from_slice(&display_fields);
v
}
_ => display_fields.clone(),
};
let order_pk: Vec<OrderItem> = pk_field
.map(|pk| OrderItem::Column {
column: pk.column,
desc: false,
nulls: NullsOrder::Default,
})
.into_iter()
.collect();
let rows = select_rows_as_json(
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)
},
&select_fields,
)
.await?;
let prefix = child_model.table.to_owned();
let initial_forms = rows.len();
let total_forms = initial_forms + inline.extra;
let pk_column = pk_field.map(|p| p.column).unwrap_or("id");
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(f, &raw_str, &prefix, idx, false);
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(f, "", &prefix, idx, false);
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,
});
}
Ok(panels)
}
use crate::text::html_escape;
#[cfg(test)]
mod tests {
use super::*;
#[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)), "");
}
}