use crate::core::{
FieldSchema, Filter, ModelEntry, ModelSchema, NullsOrder, Op, OrderItem, SelectQuery, SqlValue,
WhereExpr,
};
use crate::sql::{select_rows_as_json, ExecError, Pool};
#[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 {
model: child_model,
where_clause: WhereExpr::Predicate(Filter {
column: inline.fk_column,
op: Op::Eq,
value: parent_pk.clone(),
}),
search: None,
joins: vec![],
order_by: order_pk,
limit: None,
offset: None,
lock_mode: None,
compound: vec![],
projection: None,
},
&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.name,
"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 {
model: child_model,
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),
}),
]),
search: None,
joins: vec![],
order_by: order_pk,
limit: None,
offset: None,
lock_mode: None,
compound: vec![],
projection: None,
},
&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.name,
"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 {
model: child_model,
where_clause: WhereExpr::Predicate(Filter {
column: inline.fk_column,
op: Op::Eq,
value: parent_pk.clone(),
}),
search: None,
joins: vec![],
order_by: order_pk,
limit: None,
offset: None,
lock_mode: None,
compound: vec![],
projection: None,
},
&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 = row
.get(f.column)
.map(value_as_form_string)
.unwrap_or_default();
let input_html = render_prefixed_input(f, &raw_str, &prefix, idx, false);
serde_json::json!({
"label": f.name,
"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.name,
"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)
}
fn value_as_form_string(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) => s.clone(),
other => other.to_string(),
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct InlineApplyOutcome {
pub updated: usize,
pub deleted: usize,
pub inserted: usize,
pub failed: usize,
}
impl InlineApplyOutcome {
fn add(&mut self, other: Self) {
self.updated += other.updated;
self.deleted += other.deleted;
self.inserted += other.inserted;
self.failed += other.failed;
}
}
pub async fn apply_post(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
form: &std::collections::HashMap<String, String>,
) -> Result<InlineApplyOutcome, ExecError> {
let registrations = for_parent_table(parent_model.table);
let mut total = InlineApplyOutcome::default();
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 prefix = child_model.table;
let total_forms = match crate::forms::formset::total_forms(form, prefix) {
Ok(n) => n,
Err(_) => {
continue;
}
};
let outcome = apply_one_inline(
pool,
child_model,
inline,
prefix,
total_forms,
&parent_pk,
form,
)
.await;
total.add(outcome);
}
Ok(total)
}
async fn apply_one_inline(
pool: &Pool,
child_model: &'static ModelSchema,
inline: &InlineAdmin,
prefix: &str,
total_forms: usize,
parent_pk: &SqlValue,
form: &std::collections::HashMap<String, String>,
) -> InlineApplyOutcome {
let mut outcome = InlineApplyOutcome::default();
let pk_field = match child_model.primary_key() {
Some(p) => p,
None => return outcome,
};
let display_fields = resolve_render_fields(child_model, inline);
for idx in 0..total_forms {
let row = crate::forms::formset::row_payload(form, prefix, idx);
let raw_pk = row.get(pk_field.name).cloned().unwrap_or_default();
let has_pk = !raw_pk.trim().is_empty();
let delete_flag = row
.get("DELETE")
.map(|s| s == "on" || s == "true" || s == "1")
.unwrap_or(false);
if has_pk && delete_flag {
let pk_val = match crate::forms::parse_pk_string(pk_field, &raw_pk) {
Ok(v) => v,
Err(_) => {
outcome.failed += 1;
continue;
}
};
let q = crate::core::DeleteQuery {
model: child_model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_val,
}),
};
match crate::sql::delete_pool(pool, &q).await {
Ok(_) => outcome.deleted += 1,
Err(_) => outcome.failed += 1,
}
continue;
}
if has_pk {
let pk_val = match crate::forms::parse_pk_string(pk_field, &raw_pk) {
Ok(v) => v,
Err(_) => {
outcome.failed += 1;
continue;
}
};
let assignments = match build_assignments(&display_fields, &row, Some(inline.fk_column))
{
Ok(a) => a,
Err(_) => {
outcome.failed += 1;
continue;
}
};
if assignments.is_empty() {
continue;
}
let q = crate::core::UpdateQuery {
model: child_model,
set: assignments,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_val,
}),
};
match crate::sql::update_pool(pool, &q).await {
Ok(_) => outcome.updated += 1,
Err(_) => outcome.failed += 1,
}
continue;
}
let row_nonempty = display_fields.iter().any(|f| {
row.get(f.name)
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
});
if !row_nonempty {
continue;
}
let assignments = match build_assignments(&display_fields, &row, None) {
Ok(a) => a,
Err(_) => {
outcome.failed += 1;
continue;
}
};
let mut columns: Vec<&'static str> = assignments.iter().map(|a| a.column).collect();
let mut values: Vec<SqlValue> = assignments
.into_iter()
.map(|a| match a.value {
crate::core::Expr::Literal(v) => v,
_ => SqlValue::Null,
})
.collect();
if !columns.contains(&inline.fk_column) {
columns.push(inline.fk_column);
values.push(parent_pk.clone());
}
let q = crate::core::InsertQuery {
model: child_model,
columns,
values,
returning: vec![],
on_conflict: None,
};
match crate::sql::insert_pool(pool, &q).await {
Ok(_) => outcome.inserted += 1,
Err(_) => outcome.failed += 1,
}
}
outcome
}
fn build_assignments(
display_fields: &[&'static FieldSchema],
row: &std::collections::HashMap<String, String>,
skip_column: Option<&str>,
) -> Result<Vec<crate::core::Assignment>, crate::forms::FormError> {
let mut out = Vec::with_capacity(display_fields.len());
for f in display_fields {
if let Some(skip) = skip_column {
if f.column == skip {
continue;
}
}
let raw = row.get(f.name).map(String::as_str);
let value = crate::forms::parse_form_value(f, raw)?;
out.push(crate::core::Assignment {
column: f.column,
value: crate::core::Expr::Literal(value),
});
}
Ok(out)
}
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 {
model: child_model,
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),
}),
]),
search: None,
joins: vec![],
order_by: order_pk,
limit: None,
offset: None,
lock_mode: None,
compound: vec![],
projection: None,
},
&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 = row
.get(f.column)
.map(value_as_form_string)
.unwrap_or_default();
let input_html = render_prefixed_input(f, &raw_str, &prefix, idx, false);
serde_json::json!({
"label": f.name,
"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.name,
"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)
}
pub async fn apply_post_generic(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
form: &std::collections::HashMap<String, String>,
) -> Result<InlineApplyOutcome, ExecError> {
let registrations = generic_for_parent_table(parent_model.table);
if registrations.is_empty() {
return Ok(InlineApplyOutcome::default());
}
let Some(ct_id) = resolve_ct_id_for_schema(pool, parent_model).await? else {
return Ok(InlineApplyOutcome::default());
};
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(InlineApplyOutcome::default()),
};
let mut total = InlineApplyOutcome::default();
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 prefix = child_model.table;
let total_forms = match crate::forms::formset::total_forms(form, prefix) {
Ok(n) => n,
Err(_) => continue,
};
let outcome = apply_one_inline_generic(
pool,
child_model,
inline,
prefix,
total_forms,
ct_id,
parent_pk_i64,
form,
)
.await;
total.add(outcome);
}
Ok(total)
}
async fn apply_one_inline_generic(
pool: &Pool,
child_model: &'static ModelSchema,
inline: &InlineAdminGeneric,
prefix: &str,
total_forms: usize,
parent_ct_id: i64,
parent_pk_i64: i64,
form: &std::collections::HashMap<String, String>,
) -> InlineApplyOutcome {
let mut outcome = InlineApplyOutcome::default();
let pk_field = match child_model.primary_key() {
Some(p) => p,
None => return outcome,
};
let display_fields = resolve_render_fields_generic(child_model, inline);
for idx in 0..total_forms {
let row = crate::forms::formset::row_payload(form, prefix, idx);
let raw_pk = row.get(pk_field.name).cloned().unwrap_or_default();
let has_pk = !raw_pk.trim().is_empty();
let delete_flag = row
.get("DELETE")
.map(|s| s == "on" || s == "true" || s == "1")
.unwrap_or(false);
if has_pk && delete_flag {
let pk_val = match crate::forms::parse_pk_string(pk_field, &raw_pk) {
Ok(v) => v,
Err(_) => {
outcome.failed += 1;
continue;
}
};
let q = crate::core::DeleteQuery {
model: child_model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_val,
}),
};
match crate::sql::delete_pool(pool, &q).await {
Ok(_) => outcome.deleted += 1,
Err(_) => outcome.failed += 1,
}
continue;
}
if has_pk {
let pk_val = match crate::forms::parse_pk_string(pk_field, &raw_pk) {
Ok(v) => v,
Err(_) => {
outcome.failed += 1;
continue;
}
};
let assignments = match build_assignments_generic(
&display_fields,
&row,
inline.ct_column,
inline.pk_column,
) {
Ok(a) => a,
Err(_) => {
outcome.failed += 1;
continue;
}
};
if assignments.is_empty() {
continue;
}
let q = crate::core::UpdateQuery {
model: child_model,
set: assignments,
where_clause: WhereExpr::Predicate(Filter {
column: pk_field.column,
op: Op::Eq,
value: pk_val,
}),
};
match crate::sql::update_pool(pool, &q).await {
Ok(_) => outcome.updated += 1,
Err(_) => outcome.failed += 1,
}
continue;
}
let row_nonempty = display_fields.iter().any(|f| {
row.get(f.name)
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
});
if !row_nonempty {
continue;
}
let assignments = match build_assignments_generic(
&display_fields,
&row,
inline.ct_column,
inline.pk_column,
) {
Ok(a) => a,
Err(_) => {
outcome.failed += 1;
continue;
}
};
let mut columns: Vec<&'static str> = assignments.iter().map(|a| a.column).collect();
let mut values: Vec<SqlValue> = assignments
.into_iter()
.map(|a| match a.value {
crate::core::Expr::Literal(v) => v,
_ => SqlValue::Null,
})
.collect();
if !columns.contains(&inline.ct_column) {
columns.push(inline.ct_column);
values.push(SqlValue::I64(parent_ct_id));
}
if !columns.contains(&inline.pk_column) {
columns.push(inline.pk_column);
values.push(SqlValue::I64(parent_pk_i64));
}
let q = crate::core::InsertQuery {
model: child_model,
columns,
values,
returning: vec![],
on_conflict: None,
};
match crate::sql::insert_pool(pool, &q).await {
Ok(_) => outcome.inserted += 1,
Err(_) => outcome.failed += 1,
}
}
outcome
}
fn build_assignments_generic(
display_fields: &[&'static FieldSchema],
row: &std::collections::HashMap<String, String>,
ct_column: &str,
pk_column: &str,
) -> Result<Vec<crate::core::Assignment>, crate::forms::FormError> {
let mut out = Vec::with_capacity(display_fields.len());
for f in display_fields {
if f.column == ct_column || f.column == pk_column {
continue;
}
let raw = row.get(f.name).map(String::as_str);
let value = crate::forms::parse_form_value(f, raw)?;
out.push(crate::core::Assignment {
column: f.column,
value: crate::core::Expr::Literal(value),
});
}
Ok(out)
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
#[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)), "");
}
}