use std::collections::HashMap;
use serde::Serialize;
use umbral::migrate::{Column, ModelMeta};
use umbral::orm::{DynQuerySet, SqlType};
use crate::config::{AdminConfig, InlineModel};
use crate::error::AdminError;
use crate::view::{FormField, form_fields_for};
#[derive(Debug, Clone, Serialize)]
pub(crate) struct InlineView {
pub name: String,
pub label: String,
pub kind: &'static str,
pub can_delete: bool,
pub fields: Vec<FormField>,
pub rows: Vec<InlineRow>,
pub total: usize,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct InlineRow {
pub id: String,
pub fields: Vec<FormField>,
}
fn child_form_fields(
child: &ModelMeta,
inline: &InlineModel,
prefill: Option<&HashMap<String, String>>,
) -> Vec<FormField> {
let mut fields = form_fields_for(child, prefill, None);
fields.retain(|f| f.name != inline.fk_field);
if !inline.list_display.is_empty() {
fields.retain(|f| inline.list_display.iter().any(|d| d == &f.name));
}
for f in &mut fields {
if inline.readonly_fields.iter().any(|r| r == &f.name) {
f.readonly = true;
}
}
fields
}
fn validate_inline<'c>(
parent: &ModelMeta,
child: &'c ModelMeta,
inline: &InlineModel,
) -> Option<&'c Column> {
let fk = child.fields.iter().find(|c| c.name == inline.fk_field);
let Some(fk) = fk else {
tracing::warn!(
parent = %parent.table,
child = %child.table,
fk_field = %inline.fk_field,
"admin inline: declared fk_field is not a column on the child; skipping inline"
);
return None;
};
if !matches!(fk.ty, SqlType::ForeignKey) {
tracing::warn!(
parent = %parent.table,
child = %child.table,
fk_field = %inline.fk_field,
"admin inline: fk_field is not a ForeignKey column; skipping inline"
);
return None;
}
if let Some(target) = &fk.fk_target {
if target != &parent.table {
tracing::warn!(
parent = %parent.table,
child = %child.table,
fk_field = %inline.fk_field,
fk_target = %target,
"admin inline: fk_field points at a different table than the parent; skipping inline"
);
return None;
}
}
Some(fk)
}
pub(crate) async fn build_inline_views(
parent: &ModelMeta,
parent_pk: Option<&str>,
cfg: Option<&AdminConfig>,
) -> Result<Vec<InlineView>, AdminError> {
let Some(cfg) = cfg else {
return Ok(Vec::new());
};
if cfg.inlines.is_empty() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for inline in &cfg.inlines {
let Some((_, child)) = crate::discovery::find_model(&inline.model) else {
tracing::warn!(
parent = %parent.table,
child = %inline.model,
"admin inline: child table is not a registered model; skipping inline"
);
continue;
};
if validate_inline(parent, &child, inline).is_none() {
continue;
}
let header_fields = child_form_fields(&child, inline, None);
let mut rows: Vec<InlineRow> = Vec::new();
if let Some(pk) = parent_pk {
let child_pk_col = child.fields.iter().find(|c| c.primary_key);
let existing = DynQuerySet::for_meta(&child)
.filter_eq_string(&inline.fk_field, pk)
.fetch_as_strings()
.await?;
for row in existing {
let id = child_pk_col
.and_then(|c| row.get(&c.name))
.cloned()
.unwrap_or_default();
rows.push(InlineRow {
id,
fields: child_form_fields(&child, inline, Some(&row)),
});
}
}
for _ in 0..inline.extra {
rows.push(InlineRow {
id: String::new(),
fields: header_fields.clone(),
});
}
let total = rows.len();
out.push(InlineView {
name: child.table.clone(),
label: child.display.clone(),
kind: inline.kind.as_str(),
can_delete: inline.can_delete,
fields: header_fields,
rows,
total,
});
}
Ok(out)
}
pub(crate) fn build_inline_views_from_submitted(
parent: &ModelMeta,
cfg: Option<&AdminConfig>,
pairs: &[(String, String)],
) -> Vec<InlineView> {
let Some(cfg) = cfg else { return Vec::new() };
let mut out = Vec::new();
for inline in &cfg.inlines {
let Some((_, child)) = crate::discovery::find_model(&inline.model) else {
continue;
};
if validate_inline(parent, &child, inline).is_none() {
continue;
}
let header_fields = child_form_fields(&child, inline, None);
let parsed = parse_inline_rows(&child.table, pairs);
let mut rows: Vec<InlineRow> = Vec::with_capacity(parsed.len());
for p in &parsed {
rows.push(InlineRow {
id: p.id.clone(),
fields: child_form_fields(&child, inline, Some(&p.values)),
});
}
if rows.is_empty() {
for _ in 0..inline.extra.max(1) {
rows.push(InlineRow {
id: String::new(),
fields: header_fields.clone(),
});
}
}
let total = rows.len();
out.push(InlineView {
name: child.table.clone(),
label: child.display.clone(),
kind: inline.kind.as_str(),
can_delete: inline.can_delete,
fields: header_fields,
rows,
total,
});
}
out
}
#[derive(Debug)]
pub(crate) struct ParsedInlineRow {
pub id: String,
pub delete: bool,
pub values: HashMap<String, String>,
}
impl ParsedInlineRow {
fn is_blank(&self) -> bool {
self.id.is_empty() && self.values.values().all(|v| v.is_empty())
}
}
pub(crate) fn parse_inline_rows(
inline_name: &str,
pairs: &[(String, String)],
) -> Vec<ParsedInlineRow> {
let total_key = format!("inline-{inline_name}-TOTAL");
let total: usize = pairs
.iter()
.find(|(k, _)| k == &total_key)
.and_then(|(_, v)| v.parse().ok())
.unwrap_or(0);
let prefix = format!("inline-{inline_name}-");
let mut by_key: HashMap<&str, &str> = HashMap::new();
for (k, v) in pairs {
if k.starts_with(&prefix) {
by_key.insert(k.as_str(), v.as_str());
}
}
let mut rows = Vec::with_capacity(total);
for i in 0..total {
let id_key = format!("inline-{inline_name}-{i}-id");
let delete_key = format!("inline-{inline_name}-{i}-DELETE");
let id = by_key
.get(id_key.as_str())
.map(|s| s.to_string())
.unwrap_or_default();
let delete = by_key
.get(delete_key.as_str())
.map(|v| matches!(*v, "on" | "true" | "1"))
.unwrap_or(false);
let field_prefix = format!("inline-{inline_name}-{i}-");
let mut values = HashMap::new();
for (k, v) in &by_key {
if let Some(rest) = k.strip_prefix(field_prefix.as_str()) {
if rest == "id" || rest == "DELETE" {
continue;
}
values.insert(rest.to_string(), v.to_string());
}
}
rows.push(ParsedInlineRow { id, delete, values });
}
rows
}
pub(crate) async fn save_inlines_in_tx(
tx: &mut umbral::db::Transaction,
parent: &ModelMeta,
parent_pk: &str,
cfg: Option<&AdminConfig>,
pairs: &[(String, String)],
) -> Result<(), AdminError> {
let Some(cfg) = cfg else { return Ok(()) };
for inline in &cfg.inlines {
let Some((_, child)) = crate::discovery::find_model(&inline.model) else {
continue;
};
if validate_inline(parent, &child, inline).is_none() {
continue;
}
let Some(child_pk_col) = child.fields.iter().find(|c| c.primary_key) else {
continue;
};
let skip: Vec<String> = inline.readonly_fields.clone();
let parsed = parse_inline_rows(&child.table, pairs);
for (idx, row) in parsed.iter().enumerate() {
if row.delete && !row.id.is_empty() {
DynQuerySet::for_meta(&child)
.filter_eq_string(&child_pk_col.name, &row.id)
.delete_in_tx(tx)
.await
.map_err(|e| inline_err(&child.table, idx, e))?;
continue;
}
if row.is_blank() {
continue;
}
let mut form = row.values.clone();
form.insert(inline.fk_field.clone(), parent_pk.to_string());
if row.id.is_empty() {
DynQuerySet::for_meta(&child)
.insert_form_in_tx(tx, &form, &skip)
.await
.map_err(|e| inline_err(&child.table, idx, e))?;
} else {
DynQuerySet::for_meta(&child)
.filter_eq_string(&child_pk_col.name, &row.id)
.update_form_in_tx(tx, &form, &skip)
.await
.map_err(|e| inline_err(&child.table, idx, e))?;
}
}
}
Ok(())
}
fn inline_err(child_table: &str, row_idx: usize, e: umbral::orm::DynError) -> AdminError {
AdminError::BadInput(format!(
"Inline `{child_table}` row {} could not be saved: {e}",
row_idx + 1
))
}