use crate::core::{
FieldSchema, Filter, ModelEntry, ModelSchema, NullsOrder, Op, OrderItem, SelectQuery, SqlValue,
WhereExpr, WriteKind,
};
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> {
let panels = render_for_parent_in(pool, parent_model, parent_pk, None, None).await?;
Ok(panels.into_iter().map(|(p, _)| p).collect())
}
pub(crate) const DETAIL_ROWS: usize = 100;
pub(crate) async fn render_for_parent_in(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
parts: Option<&Parts>,
cap: Option<usize>,
) -> Result<Vec<(InlinePanel, Option<String>)>, 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 (mut rows, fetched) = visible_child_rows(
pool,
SelectQuery {
order_by: order_pk,
limit: cap.map(|c| c 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 = cap.and_then(|c| {
cut_rows(
&mut rows,
fetched,
c,
&[(fk_name, parent_pk.to_display_string())],
)
});
let field_labels = display_fields.iter().map(|f| f.name.to_owned()).collect();
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.name).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_pk_text(row, pk_field);
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();
let panel = 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,
};
panels.push((panel, more));
}
Ok(panels)
}
pub async fn render_generic_for_parent(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
) -> Result<Vec<InlinePanel>, ExecError> {
let panels = render_generic_for_parent_in(pool, parent_model, parent_pk, None, None).await?;
Ok(panels.into_iter().map(|(p, _)| p).collect())
}
pub(crate) async fn render_generic_for_parent_in(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
parts: Option<&Parts>,
cap: Option<usize>,
) -> Result<Vec<(InlinePanel, Option<String>)>, 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 (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: cap.map(|c| c 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 = cap.and_then(|c| {
cut_rows(
&mut rows,
fetched,
c,
&[
(name_of(inline.ct_column), ct_id.to_string()),
(name_of(inline.pk_column), parent_pk_i64.to_string()),
],
)
});
let field_labels = display_fields.iter().map(|f| f.name.to_owned()).collect();
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.name).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_pk_text(row, pk_field);
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();
let panel = 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,
};
panels.push((panel, more));
}
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::get_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, None).await
}
pub(crate) async fn render_form_for_parent_in(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
parts: Option<&Parts>,
submitted: Option<&HashMap<String, String>>,
) -> 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 child_cfg = admin_config_or_default(child_model);
let (total_forms, rendered_rows) = form_panel_rows(
&child_cfg,
&display_fields,
pk_field,
&prefix,
&rows,
extra,
submitted,
);
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 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 form_panel_rows(
child_cfg: &crate::core::AdminConfig,
display_fields: &[&'static FieldSchema],
pk_field: Option<&'static FieldSchema>,
prefix: &str,
rows: &[serde_json::Value],
extra: usize,
submitted: Option<&HashMap<String, String>>,
) -> (usize, Vec<serde_json::Value>) {
let initial_forms = rows.len();
let pk_field_name = pk_field.map_or("id", |p| p.name);
let posted = submitted.and_then(|form| {
let total = crate::forms::formset::total_forms(form, prefix).ok()?;
Some(PostedSlots::new(form, prefix, pk_field_name, total))
});
let total_forms = posted.as_ref().map_or(initial_forms + extra, |p| {
(initial_forms + p.new.len()).min(crate::forms::formset::MAX_FORMS)
});
let typed = |slot: Option<usize>, f: &FieldSchema, kind: WriteKind| -> Option<String> {
let form = submitted.filter(|_| f.accepts_input(kind))?;
Some(
form.get(&format!("{prefix}-{}-{}", slot?, f.name))
.cloned()
.unwrap_or_default(),
)
};
let mut rendered_rows: Vec<serde_json::Value> = Vec::with_capacity(total_forms);
for (idx, row) in rows.iter().enumerate() {
let pk_text = row_pk_text(row, pk_field);
let slot = posted.as_ref().and_then(|p| p.slot_of(&pk_text));
let cells: Vec<serde_json::Value> = display_fields
.iter()
.map(|f| {
let raw_str = typed(slot, f, WriteKind::Update)
.unwrap_or_else(|| crate::admin::render::render_value_for_input_json(row, f));
let input_html =
render_prefixed_input(child_cfg, f, &raw_str, (prefix, idx, WriteKind::Update));
serde_json::json!({
"label": f.display_label(),
"input_html": input_html,
})
})
.collect();
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 deleting = slot.is_some_and(|s| posted.as_ref().is_some_and(|p| p.deleting(s)));
let delete_input_html = format!(
r#"<input type="checkbox" name="{p}-{i}-DELETE" value="on"{c}>"#,
p = html_escape(prefix),
i = idx,
c = if deleting { " checked" } else { "" },
);
rendered_rows.push(serde_json::json!({
"pk": pk_text,
"cells": cells,
"hidden_pk": hidden_pk,
"delete_input_html": delete_input_html,
}));
}
if let Some(p) = &posted {
let shown: HashSet<String> = rows.iter().map(|r| row_pk_text(r, pk_field)).collect();
for (slot, pk) in &p.existing {
if shown.contains(pk) || p.deleting(*slot) {
continue;
}
rendered_rows.push(serde_json::json!({
"pk": pk,
"error": format!(
"Row {pk} is not in this list (deleted, or past the rows shown here); \
its changes were not saved."
),
"cells": [],
"hidden_pk": "",
"delete_input_html": "",
}));
}
}
for idx in initial_forms..total_forms {
let slot = posted
.as_ref()
.and_then(|p| p.new.get(idx - initial_forms).copied());
let cells: Vec<serde_json::Value> = display_fields
.iter()
.map(|f| {
let raw_str = typed(slot, f, WriteKind::Insert).unwrap_or_default();
let input_html =
render_prefixed_input(child_cfg, f, &raw_str, (prefix, idx, WriteKind::Insert));
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": "",
}));
}
(total_forms, rendered_rows)
}
struct PostedSlots<'a> {
form: &'a HashMap<String, String>,
prefix: &'a str,
existing: Vec<(usize, String)>,
new: Vec<usize>,
}
impl<'a> PostedSlots<'a> {
fn new(
form: &'a HashMap<String, String>,
prefix: &'a str,
pk_name: &str,
total: usize,
) -> Self {
let initial = crate::forms::formset::initial_forms(form, prefix);
let (mut existing, mut new) = (Vec::new(), Vec::new());
for slot in 0..total {
let pk = form
.get(&format!("{prefix}-{slot}-{pk_name}"))
.map_or("", String::as_str);
if slot_is_new(initial, slot, pk) {
new.push(slot);
} else {
existing.push((slot, pk.trim().to_owned()));
}
}
Self {
form,
prefix,
existing,
new,
}
}
fn slot_of(&self, pk: &str) -> Option<usize> {
self.existing.iter().find(|(_, p)| p == pk).map(|(s, _)| *s)
}
fn deleting(&self, slot: usize) -> bool {
self.form
.get(&format!("{}-{slot}-DELETE", self.prefix))
.is_some_and(|v| !v.is_empty())
}
}
fn slot_is_new(initial: Option<usize>, idx: usize, raw_pk: &str) -> bool {
initial.map_or(raw_pk.trim().is_empty(), |n| idx >= n)
}
fn render_prefixed_input(
cfg: &crate::core::AdminConfig,
field: &FieldSchema,
value: &str,
(prefix, idx, kind): (&str, usize, crate::core::WriteKind),
) -> String {
if !field.accepts_input(kind) {
let shown = if is_secret_field(cfg, field.name) {
super::helpers::render_secret_value(Some(&serde_json::Value::String(value.to_owned())))
} else {
html_escape(value)
};
return format!(r#"<span class="readonly">{shown}</span>"#);
}
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)
}
#[deprecated(since = "0.61.12", note = "no API returns it any more")]
#[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")]
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>,
add_locked: Vec<&'static str>,
}
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.accepts_input(WriteKind::Update)
&& 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,
add_locked: add.superuser_only,
})
}
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> {
use crate::forms::{absent_takes_default, Encoding};
self.writable
.iter()
.filter(|f| {
if edit {
!(self.secrets.contains(&f.name)
&& row.get(f.name).is_none_or(String::is_empty))
} else {
!(absent_takes_default(f, Encoding::Form) && !row.contains_key(f.name))
}
})
.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_many(
&self,
pool: &Pool,
parts: &Parts,
pks: Vec<SqlValue>,
) -> Result<OwnRows, ExecError> {
let mut out = OwnRows(HashMap::new());
if pks.is_empty() {
return Ok(out);
}
let in_pks = |pks: Vec<SqlValue>| Filter::new(self.pk.column, Op::In, SqlValue::List(pks));
let mut filters = vec![in_pks(pks.clone())];
filters.extend(self.scope.filters());
let fields: Vec<&'static FieldSchema> = self.child.scalar_fields().collect();
let query = SelectQuery {
where_clause: self.rows.constrain(WhereExpr::and_predicates(filters)),
..SelectQuery::new(self.child)
};
for row in select_rows_as_json(pool, &query, &fields).await? {
let visible = crate::admin::object_permissions::is_allowed(
self.child.table,
"view",
parts,
Some(&row),
);
let key = pk_key(&row, self.pk);
out.0
.insert(key, if visible { Own::Row(row) } else { Own::Foreign });
}
let rest: Vec<SqlValue> = pks
.into_iter()
.filter(|pk| !out.0.contains_key(&pk.to_display_string()))
.collect();
if rest.is_empty() {
return Ok(out);
}
let any_parent = SelectQuery {
where_clause: WhereExpr::Predicate(in_pks(rest)),
..SelectQuery::new(self.child)
};
for row in select_rows_as_json(pool, &any_parent, &[self.pk]).await? {
out.0.insert(pk_key(&row, self.pk), Own::Foreign);
}
Ok(out)
}
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)
})
}
}
enum Own {
Row(serde_json::Value),
Foreign,
}
struct OwnRows(HashMap<String, Own>);
impl OwnRows {
fn get(
&self,
child: &'static ModelSchema,
pk: &SqlValue,
raw_pk: &str,
) -> Result<Option<serde_json::Value>, AdminError> {
match self.0.get(&pk.to_display_string()) {
Some(Own::Row(row)) => Ok(Some(row.clone())),
Some(Own::Foreign) => Err(AdminError::RowNotFound {
table: child.table.to_owned(),
pk: raw_pk.to_owned(),
}),
None => Ok(None),
}
}
}
fn row_pk_text(row: &serde_json::Value, pk: Option<&FieldSchema>) -> String {
pk.map_or_else(
|| row.get("id").map(stringify_pk).unwrap_or_default(),
|pk| pk_key(row, pk),
)
}
fn pk_key(row: &serde_json::Value, pk: &FieldSchema) -> String {
row.get(pk.name)
.or_else(|| row.get(pk.column))
.map(stringify_pk)
.unwrap_or_default()
}
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>,
parent: (&'static ModelSchema, SqlValue),
}
struct TargetPlan {
child: &'static ModelSchema,
pk: &'static FieldSchema,
existing: Vec<InlineWrite>,
inserts: Vec<InlineInsert>,
cap: Option<(usize, WhereExpr)>,
}
enum InlineWrite {
Update {
query: crate::core::UpdateQuery,
pk: SqlValue,
audit: ChildAudit,
#[cfg(feature = "tenancy")]
claims: crate::tenancy::org_host::AdminClaims,
},
Delete {
query: InlineRemove,
pk: String,
},
}
pub(crate) enum ChildSignal {
Save {
table: &'static str,
pk: String,
change: bool,
},
Delete {
table: &'static str,
pk: String,
},
}
impl ChildSignal {
pub(crate) async fn send(self, pre: bool, state: &AppState) {
use crate::signals::admin::{self as sig, AdminDeleteContext, AdminSaveContext};
let tenant = state.config.tenant.clone();
match self {
Self::Save { table, pk, change } => {
let ctx = AdminSaveContext::new(table, pk, change).with_tenant(tenant);
if pre {
sig::send_admin_pre_save(ctx).await;
} else {
sig::send_admin_post_save(ctx).await;
}
}
Self::Delete { table, pk } => {
let ctx = AdminDeleteContext::new(table, pk).with_tenant(tenant);
if pre {
sig::send_admin_pre_delete(ctx).await;
} else {
sig::send_admin_post_delete(ctx).await;
}
}
}
}
}
impl InlinePlan {
pub(crate) fn planned_rows(&self) -> Vec<ChildSignal> {
let mut out = Vec::new();
for t in &self.targets {
let table = t.child.table;
for w in &t.existing {
out.push(match w {
InlineWrite::Update { pk, .. } => ChildSignal::Save {
table,
pk: pk.to_display_string(),
change: true,
},
InlineWrite::Delete { pk, .. } => ChildSignal::Delete {
table,
pk: pk.clone(),
},
});
}
for i in &t.inserts {
let pk = i
.query
.columns
.iter()
.position(|c| *c == t.pk.column)
.map(|n| i.query.values[n].to_display_string())
.unwrap_or_default();
out.push(ChildSignal::Save {
table,
pk,
change: false,
});
}
}
out
}
}
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: HashMap<String, String>,
#[cfg(feature = "tenancy")]
claims: crate::tenancy::org_host::AdminClaims,
}
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(),
parent: (parent_model, parent_pk.clone()),
};
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 mut out = TargetPlan {
child: target.child,
pk: target.pk,
existing: Vec::new(),
inserts: Vec::new(),
cap: None,
};
let is_new = |idx: usize, raw_pk: &str| slot_is_new(initial, idx, raw_pk);
let rows = crate::forms::formset::row_payloads(form, table, total_forms);
let existing_raw: Vec<String> = rows
.iter()
.enumerate()
.filter_map(|(idx, row)| {
let raw_pk = row.get(target.pk.name)?;
(!is_new(idx, raw_pk)).then(|| raw_pk.clone())
})
.collect();
let stored: HashMap<&str, SqlValue> = super::helpers::parse_keys(
state,
target.child,
target.pk,
existing_raw.iter().map(String::as_str),
)
.await?
.into_iter()
.collect();
let own = target
.fetch_own_many(&state.pool, parts, stored.values().cloned().collect())
.await?;
let mut deleted: HashSet<String> = HashSet::new();
for (idx, row) in rows.into_iter().enumerate() {
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 is_new(idx, &raw_pk) {
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))?;
#[cfg(feature = "tenancy")]
let claims = crate::tenancy::org_host::AdminClaims::of(table, &mut values, None)
.map_err(|e| InlinePlanError::Rejected(format!("{name} row {}: {e}", idx + 1)))?;
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));
}
super::object_permissions::default_locked_flags(
target.child,
&target.add_locked,
&mut values,
);
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 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 = {
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);
query.returning = vec![target.pk.column];
out.inserts.push(InlineInsert {
query,
audit,
#[cfg(feature = "tenancy")]
claims,
});
continue;
}
let pk = match stored.get(raw_pk.as_str()) {
Some(key) => key.clone(),
None => {
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) = own.get(target.child, &pk, &raw_pk)? else {
continue;
};
if !crate::admin::object_permissions::is_allowed(table, "delete", parts, Some(&before))
{
return Err(refused("delete").into());
}
let pk_text = 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, pk: pk_text });
continue;
}
let mut values = target.values(&row, true).map_err(|e| bad_row(idx, e))?;
if values.is_empty() {
continue;
}
let Some(before) = own.get(target.child, &pk, &raw_pk)? else {
return Err(InlinePlanError::Rejected(format!(
"{name} row {raw_pk} was deleted after this page loaded. Reload the page and try again."
)));
};
#[cfg(feature = "tenancy")]
let claims = crate::tenancy::org_host::AdminClaims::of(table, &mut values, Some(&before))
.map_err(|e| InlinePlanError::Rejected(format!("{name} row {raw_pk}: {e}")))?;
if target.unchanged(&before, &values) {
continue;
}
super::views::keep_echoed_lossy(target.child, &row, Some(&before), &mut values);
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 = 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.clone())),
pk,
audit,
#[cfg(feature = "tenancy")]
claims,
});
}
if let Some(max) = target.max_num.filter(|_| !out.inserts.is_empty()) {
let live = crate::soft_delete::compose_with_active(target.child, target.scope.all_where());
let count = crate::core::CountQuery {
model: target.child,
where_clause: live.clone(),
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();
let deletes_needed = existing
.saturating_add(out.inserts.len())
.saturating_sub(max);
out.cap = Some((max, live));
if 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 },
Forbidden {
child: &'static ModelSchema,
action: &'static str,
},
#[cfg(feature = "tenancy")]
Claim {
child: &'static ModelSchema,
error: crate::tenancy::org_host::ClaimError,
},
}
pub(crate) async fn apply_plan_tx(
tx: &mut crate::audit::PoolBoundTx<'_>,
parts: &Parts,
plan: InlinePlan,
) -> Result<InlineApplied, InlineApplyError> {
use super::views::Written;
use crate::audit::{AuditOp, DiffEmit, RowDiffWrite};
let mut after_commit = Vec::new();
let mut written_rows = Vec::new();
if let Some(first) = plan.targets.iter().find(|t| t.cap.is_some()) {
let (model, pk) = plan.parent;
if let Some(pk_field) = model.primary_key() {
let parent = SelectQuery {
lock_mode: Some(crate::core::LockMode {
silent_on_sqlite: true,
..crate::core::LockMode::default()
}),
..SelectQuery::by_pk(model, pk_field.column, pk)
};
crate::sql::select_one_row_as_json_tx(tx, &parent, &[pk_field])
.await
.map_err(|error| InlineApplyError::Write {
child: first.child,
error,
})?;
}
}
for target in plan.targets {
let child = target.child;
let table = child.table;
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();
for write in target.existing {
match write {
InlineWrite::Update {
query,
pk,
audit,
#[cfg(feature = "tenancy")]
claims,
} => {
let written = crate::audit::update_one_with_row_diff_tx(
tx,
&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 } = written {
let key = pk.to_display_string();
#[cfg(feature = "tenancy")]
claim_written(tx, child, &claims, &pk).await?;
admit_written(tx, parts, child, target.pk, pk, Written::Edited).await?;
entries.extend(deferred);
written_rows.push(ChildSignal::Save {
table,
pk: key,
change: true,
});
}
}
InlineWrite::Delete { query, pk } => {
let select = locked(query.where_clause());
let before = crate::sql::select_one_row_as_json_tx(tx, &select, &fields)
.await
.map_err(refused)?;
if query.run(tx).await.map_err(refused)? == 0 {
continue;
}
if let Some(row) = before {
entries.push(super::audit::admin_row_snapshot_entry(
child,
pk.clone(),
query.op(),
&row,
None,
));
}
written_rows.push(ChildSignal::Delete { table, pk });
}
}
}
if let Some((max, live)) = &target.cap {
let live_rows = if tx.dialect().plain_reads_see_snapshot() {
crate::sql::select_rows_as_json_tx(tx, &locked(live), &[target.pk])
.await
.map_err(refused)?
.len()
} else {
let count = crate::core::CountQuery {
model: child,
where_clause: live.clone(),
search: None,
source: None,
};
let n = crate::sql::count_rows_on(&mut **tx, &count)
.await
.map_err(refused)?;
usize::try_from(n).unwrap_or(usize::MAX)
};
if live_rows.saturating_add(target.inserts.len()) > *max {
return Err(InlineApplyError::MaxNum { child });
}
}
for insert in &target.inserts {
let (pk, entry) = crate::audit::insert_one_with_entry_tx(
tx,
&insert.query,
target.pk,
|pk| {
let pk = pk.to_display_string();
super::audit::admin_audit_entry(child, &pk, AuditOp::Create, &insert.audit)
},
DiffEmit::Deferred,
)
.await
.map_err(refused)?;
entries.extend(entry);
let key = pk.to_display_string();
#[cfg(feature = "tenancy")]
claim_written(tx, child, &insert.claims, &pk).await?;
admit_written(tx, parts, child, target.pk, pk, Written::Created).await?;
written_rows.push(ChildSignal::Save {
table,
pk: key,
change: false,
});
}
match DiffEmit::for_model(child) {
DiffEmit::InTx if !entries.is_empty() => {
crate::audit::emit_in_tx(tx, &entries)
.await
.map_err(refused)?;
}
DiffEmit::InTx => {}
DiffEmit::Deferred => after_commit.extend(entries),
}
}
Ok(InlineApplied {
signals: written_rows,
audit: after_commit,
})
}
pub(crate) struct InlineApplied {
pub(crate) signals: Vec<ChildSignal>,
pub(crate) audit: Vec<crate::audit::PendingEntry>,
}
#[cfg(feature = "tenancy")]
async fn claim_written(
tx: &mut crate::sql::PoolTx<'_>,
child: &'static ModelSchema,
claims: &crate::tenancy::org_host::AdminClaims,
pk: &SqlValue,
) -> Result<(), InlineApplyError> {
claims
.after_write(tx, pk)
.await
.map_err(|error| InlineApplyError::Claim { child, error })
}
async fn admit_written(
tx: &mut crate::sql::PoolTx<'_>,
parts: &Parts,
child: &'static ModelSchema,
pk_field: &'static FieldSchema,
pk: SqlValue,
written: super::views::Written,
) -> Result<(), InlineApplyError> {
match super::views::admits_written(tx, parts, child, pk_field, pk, written).await {
Ok(true) => Ok(()),
Ok(false) => Err(InlineApplyError::Forbidden {
child,
action: written.action(),
}),
Err(error) => Err(InlineApplyError::Write { child, error }),
}
}
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, None).await
}
pub(crate) async fn render_form_generic_for_parent_in(
pool: &Pool,
parent_model: &'static ModelSchema,
parent_pk: SqlValue,
parts: Option<&Parts>,
submitted: Option<&HashMap<String, String>>,
) -> 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 child_cfg = admin_config_or_default(child_model);
let (total_forms, rendered_rows) = form_panel_rows(
&child_cfg,
&display_fields,
pk_field,
&prefix,
&rows,
extra,
submitted,
);
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 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::*;
#[derive(crate::Model, Debug)]
#[rustango(table = "inl_ct_parent", app = "inl_ct")]
#[allow(dead_code)]
pub struct CtParent {
#[rustango(primary_key)]
pub id: i64,
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn parent_content_type_lookup_is_cached() {
use crate::core::Model as _;
let pool = Pool::Sqlite(
crate::sql::sqlx::SqlitePool::connect("sqlite::memory:")
.await
.unwrap(),
);
crate::contenttypes::ensure_seeded(&pool).await.unwrap();
let first = resolve_ct_id_for_schema(&pool, CtParent::SCHEMA)
.await
.unwrap();
assert!(first.is_some());
let (again, queries) = crate::test_assertions::QueryCounter::scope(async {
let id = resolve_ct_id_for_schema(&pool, CtParent::SCHEMA)
.await
.unwrap();
(id, crate::test_assertions::QueryCounter::current())
})
.await;
assert_eq!((again, queries), (first, 0));
}
#[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 a_read_only_secret_is_not_echoed() {
let f = FieldSchema {
name: "password_hash",
column: "password_hash",
ty: crate::core::FieldType::String,
nullable: false,
primary_key: false,
auto: false,
auto_now: false,
unique: false,
max_length: None,
min: None,
max: None,
default: None,
relation: None,
generated_as: Some("'x'"),
help_text: None,
choices: None,
db_comment: None,
verbose_name: None,
editable: true,
blank: false,
case_insensitive: false,
fk_on_delete: None,
validators: &[],
cast: None,
};
let cfg = crate::core::AdminConfig::DEFAULT;
let html = render_prefixed_input(&cfg, &f, "s3cret", ("c", 0, WriteKind::Update));
assert!(!html.contains("s3cret"), "{html}");
assert!(html.contains("<em>set</em>"), "{html}");
}
#[test]
fn a_posted_row_outside_the_window_is_not_called_deleted() {
let form: HashMap<String, String> = [
("c-TOTAL_FORMS", "1"),
("c-INITIAL_FORMS", "1"),
("c-0-id", "1002"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
let cfg = crate::core::AdminConfig::DEFAULT;
let (_, rows) = form_panel_rows(&cfg, &[], None, "c", &[], 0, Some(&form));
let error = rows[0]["error"].as_str().expect("an error row");
assert!(error.contains("1002"), "{error}");
assert!(!error.contains("was deleted"), "{error}");
}
#[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)), "");
}
}