Skip to main content

laterite_admin/
relation.rs

1//! Related records on a form: a resource declares which other entity's rows
2//! belong to each of its records, and the edit page carries a section for them.
3//!
4//! A section is the list engine scoped to the parent, with its own New, Edit
5//! and Delete; a related record's form is the form engine mounted under the
6//! parent's path, its foreign key forced from the URL rather than trusted from
7//! the client. A many-to-many relation lists the records a pivot table joins
8//! to the parent: Attach picks one and writes the pivot row, Edit changes
9//! what the pivot row carries, Remove deletes the pivot row and leaves the
10//! record. Nothing binds before the parent exists: a create page names the
11//! sections it will have, and "Save and continue editing" lands on them.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use askama::Template;
17use axum::extract::{Form, Path, Query, State};
18use axum::response::{IntoResponse, Response};
19use axum::Extension;
20use laterite_auth::AuthenticatedUser;
21use laterite_core::query::{build, text_cast};
22use laterite_core::{t, Actor, Db, Text};
23use sea_query::{Alias, Expr, Query as Sql};
24use serde::{Deserialize, Serialize};
25
26use crate::bulk::{self, BulkContext};
27use crate::form::{self, FormConfig, PreparedForm, RelationSection};
28use crate::list::{self, ListConfig, ListParams};
29use crate::session::{FlashLevel, SessionHandle};
30use crate::{not_found, render_error, AdminState, Shell};
31
32/// One relation a resource declares, listed and edited on the parent's edit
33/// page: rows of `entity` whose `foreign_key` holds the parent's id (has-many),
34/// or rows of `entity` a `pivot` table joins to the parent (many-to-many).
35#[derive(Clone, Debug, Serialize, Deserialize)]
36#[serde(deny_unknown_fields, default)]
37#[non_exhaustive]
38pub struct RelationConfig {
39    /// The relation's name: the key in a descriptor, the path segment under
40    /// the parent (`/products/7/variants`). Not `new`, `edit`, `delete`,
41    /// `import`, `export` or `columns`, which the parent's own routes use.
42    pub name: String,
43    /// The section heading, plural (`Variants`). Defaults to the name,
44    /// humanized.
45    pub title: Text,
46    /// One related record, singular (`Variant`), for the New or Attach button
47    /// and the child form's title.
48    pub label: Text,
49    /// The related table.
50    pub entity: String,
51    /// Has-many: the column on the related table holding the parent's id.
52    pub foreign_key: Option<String>,
53    /// Many-to-many: the table joining the parent to the related table.
54    pub pivot: Option<PivotConfig>,
55    /// The section's list over the related table.
56    pub list: ListConfig,
57    /// Has-many: the related record's form; the foreign key is written by the
58    /// framework, and a field of that name here is kept hidden. Many-to-many:
59    /// the fields kept on the pivot row, asked for on Attach and editable from
60    /// the section; none means a membership carries nothing of its own.
61    pub form: FormConfig,
62    /// A permission the operator must hold for the section and its pages,
63    /// besides the parent's. `None` asks for the parent's alone.
64    pub permission: Option<String>,
65}
66
67/// The table joining a many-to-many relation's two sides, one row per
68/// membership.
69#[derive(Clone, Debug, Serialize, Deserialize)]
70#[serde(deny_unknown_fields, default)]
71#[non_exhaustive]
72pub struct PivotConfig {
73    /// The pivot table.
74    pub table: String,
75    /// The column holding the parent's id.
76    pub parent_key: String,
77    /// The column holding the related record's id.
78    pub related_key: String,
79    /// The picker source offering records to attach: a registered
80    /// [`PickerSourceReg`](crate::picker::PickerSourceReg) name.
81    pub source: String,
82    /// The pivot row's id column. Default `id`.
83    pub id_field: String,
84}
85
86impl PivotConfig {
87    /// A pivot table, with records to attach chosen from `source`.
88    pub fn new(
89        table: impl Into<String>,
90        parent_key: impl Into<String>,
91        related_key: impl Into<String>,
92        source: impl Into<String>,
93    ) -> Self {
94        Self {
95            table: table.into(),
96            parent_key: parent_key.into(),
97            related_key: related_key.into(),
98            source: source.into(),
99            id_field: "id".to_string(),
100        }
101    }
102
103    /// The pivot row's id column.
104    pub fn id_field(mut self, id_field: impl Into<String>) -> Self {
105        self.id_field = id_field.into();
106        self
107    }
108}
109
110impl Default for PivotConfig {
111    fn default() -> Self {
112        Self::new("", "", "", "")
113    }
114}
115
116impl RelationConfig {
117    /// A has-many relation: rows of `entity` whose `foreign_key` names the
118    /// parent, listed by `list` and edited through `form`.
119    pub fn has_many(
120        name: impl Into<String>,
121        label: impl Into<Text>,
122        entity: impl Into<String>,
123        foreign_key: impl Into<String>,
124        list: ListConfig,
125        form: FormConfig,
126    ) -> Self {
127        let name = name.into();
128        Self {
129            title: crate::descriptor::humanize(&name),
130            name,
131            label: label.into(),
132            entity: entity.into(),
133            foreign_key: Some(foreign_key.into()),
134            pivot: None,
135            list,
136            form,
137            permission: None,
138        }
139    }
140
141    /// A many-to-many relation: rows of `entity` that `pivot` joins to the
142    /// parent, listed by `list`. Fields kept on the pivot row go on [`form`].
143    ///
144    /// [`form`]: RelationConfig::form
145    pub fn belongs_to_many(
146        name: impl Into<String>,
147        label: impl Into<Text>,
148        entity: impl Into<String>,
149        pivot: PivotConfig,
150        list: ListConfig,
151    ) -> Self {
152        let name = name.into();
153        Self {
154            title: crate::descriptor::humanize(&name),
155            name,
156            label: label.into(),
157            entity: entity.into(),
158            foreign_key: None,
159            pivot: Some(pivot),
160            list,
161            form: FormConfig::default(),
162            permission: None,
163        }
164    }
165
166    /// The fields kept on a many-to-many relation's pivot row.
167    pub fn form(mut self, form: FormConfig) -> Self {
168        self.form = form;
169        self
170    }
171
172    /// The section heading.
173    pub fn title(mut self, title: impl Into<Text>) -> Self {
174        self.title = title.into();
175        self
176    }
177
178    /// A permission the operator must also hold.
179    pub fn permission(mut self, code: impl Into<String>) -> Self {
180        self.permission = Some(code.into());
181        self
182    }
183}
184
185impl Default for RelationConfig {
186    fn default() -> Self {
187        Self {
188            name: String::new(),
189            title: Text::from(""),
190            label: Text::from(""),
191            entity: String::new(),
192            foreign_key: None,
193            pivot: None,
194            list: ListConfig::default(),
195            form: FormConfig::default(),
196            permission: None,
197        }
198    }
199}
200
201impl crate::keyed::Keyed for RelationConfig {
202    fn key(&self) -> &str {
203        &self.name
204    }
205    fn set_key(&mut self, key: String) {
206        if self.title.source().is_empty() || self.title.source() == self.name {
207            self.title = crate::descriptor::humanize(&key);
208        }
209        self.name = key;
210    }
211}
212
213/// The names a relation may not take: the parent's own routes under `{id}`.
214pub(crate) const RESERVED: [&str; 6] = ["new", "edit", "delete", "import", "export", "columns"];
215
216/// How a relation's rows are tied to the parent.
217enum Kind {
218    HasMany {
219        foreign_key: String,
220    },
221    ManyToMany {
222        pivot: PivotConfig,
223        /// The Attach form: the pivot fields plus a picker for the record.
224        attach: Arc<PreparedForm>,
225    },
226}
227
228/// A relation resolved at boot: its forms prepared, its delete context built,
229/// ready to be scoped to a parent per request.
230pub(crate) struct PreparedRelation {
231    pub config: RelationConfig,
232    /// The parent's path under the admin mount (`/admin/products`).
233    pub parent_base: String,
234    pub parent_entity: String,
235    pub parent_id_field: String,
236    kind: Kind,
237    /// Has-many: the child form. Many-to-many: the pivot row's form.
238    form: Arc<PreparedForm>,
239    bulk: Arc<BulkContext>,
240}
241
242/// Keeps a key field of this name out of sight: a declared one is hidden, an
243/// undeclared one is added hidden so the submission carries it. Either way it
244/// is stored as an id (a number), which a bigint column takes on every
245/// backend; a declared text field is retyped, any other type is kept.
246fn hide(form: &mut FormConfig, name: &str) {
247    match form.fields.iter_mut().find(|f| f.name == name) {
248        Some(field) => {
249            field.hidden = true;
250            if field.field_type == "text" {
251                field.field_type = "id".to_string();
252            }
253        }
254        None => {
255            let mut field = form::FormField::of(name, name.to_string(), "id");
256            field.hidden = true;
257            form.fields.push(field);
258        }
259    }
260}
261
262impl PreparedRelation {
263    pub(crate) fn prepare(
264        parent: &crate::Resource,
265        mut config: RelationConfig,
266        field_types: &crate::field::FieldRegistry,
267        column_types: &list::ColumnRegistry,
268        permission_codes: &std::collections::HashSet<String>,
269        persisters: &crate::persist::PersisterRegistry,
270        listeners: &[laterite_core::ModelListenerReg],
271    ) -> Result<Self, String> {
272        let name = config.name.clone();
273        let fail = |message: String| format!("relation `{name}`: {message}");
274        if !crate::sql::valid_ident(&config.name) || RESERVED.contains(&config.name.as_str()) {
275            return Err(fail("the name is one the parent's own routes use".into()));
276        }
277        config.list.entity = config.entity.clone();
278        list::check(&config.list, column_types, permission_codes).map_err(fail)?;
279        if config.form.title.source().is_empty() {
280            config.form.title = config.label.clone();
281        }
282        let prepare = |form: FormConfig| {
283            PreparedForm::prepare(form, field_types, persisters, listeners).map_err(fail)
284        };
285        let (kind, form) = match (config.foreign_key.clone(), config.pivot.clone()) {
286            (Some(foreign_key), None) => {
287                if !crate::sql::valid_ident(&foreign_key) {
288                    return Err(fail("the foreign key is not a valid identifier".into()));
289                }
290                config.form.entity = config.entity.clone();
291                config.form.id_field = config.list.id_field.clone();
292                // The foreign key is the framework's to write.
293                hide(&mut config.form, &foreign_key);
294                (Kind::HasMany { foreign_key }, prepare(config.form.clone())?)
295            }
296            (None, Some(pivot)) => {
297                if ![
298                    &pivot.table,
299                    &pivot.parent_key,
300                    &pivot.related_key,
301                    &pivot.id_field,
302                ]
303                .iter()
304                .all(|ident| crate::sql::valid_ident(ident))
305                {
306                    return Err(fail("the pivot names an invalid identifier".into()));
307                }
308                if pivot.source.is_empty() {
309                    return Err(fail("the pivot names no picker source".into()));
310                }
311                config.form.entity = pivot.table.clone();
312                config.form.id_field = pivot.id_field.clone();
313                hide(&mut config.form, &pivot.parent_key);
314                // Attach asks which record, through the picker; afterwards the
315                // membership is fixed and the form hides it.
316                let mut attach = config.form.clone();
317                if !attach.fields.iter().any(|f| f.name == pivot.related_key) {
318                    attach.fields.insert(
319                        0,
320                        form::FormField::reference(
321                            &pivot.related_key,
322                            config.label.clone(),
323                            &pivot.source,
324                        )
325                        .required(),
326                    );
327                }
328                hide(&mut config.form, &pivot.related_key);
329                let attach = Arc::new(prepare(attach)?);
330                (
331                    Kind::ManyToMany { pivot, attach },
332                    prepare(config.form.clone())?,
333                )
334            }
335            _ => return Err(fail("declare exactly one of foreign_key and pivot".into())),
336        };
337        let persister = form.persister();
338        let entity = form.config.entity.clone();
339        let bulk = Arc::new(BulkContext {
340            base_path: String::new(),
341            listeners: listeners
342                .iter()
343                .filter(|reg| reg.matches(&entity))
344                .map(|reg| reg.listener.clone())
345                .collect(),
346            entity,
347            persister,
348        });
349        Ok(Self {
350            parent_base: parent.base_path.clone(),
351            parent_entity: parent.list.entity.clone(),
352            parent_id_field: parent.list.id_field.clone(),
353            config,
354            kind,
355            form: Arc::new(form),
356            bulk,
357        })
358    }
359
360    /// The section's path under a parent record.
361    pub(crate) fn path(&self, parent_id: &str) -> String {
362        format!("{}/{}/{}", self.parent_base, parent_id, self.config.name)
363    }
364
365    /// The parent's edit page, where a save returns to.
366    fn parent_edit(&self, parent_id: &str) -> String {
367        format!("{}/{}/edit", self.parent_base, parent_id)
368    }
369
370    /// A form mounted under this parent.
371    fn rebase(&self, form: &PreparedForm, parent_id: &str) -> Arc<PreparedForm> {
372        let mut form = form.clone();
373        form.config.base_path = self.path(parent_id);
374        form.config.done_path = Some(self.parent_edit(parent_id));
375        Arc::new(form)
376    }
377
378    /// The form behind New (has-many) or Attach (many-to-many).
379    fn new_form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
380        match &self.kind {
381            Kind::HasMany { .. } => self.rebase(&self.form, parent_id),
382            Kind::ManyToMany { attach, .. } => self.rebase(attach, parent_id),
383        }
384    }
385
386    /// The form behind a row's Edit.
387    fn form_for(&self, parent_id: &str) -> Arc<PreparedForm> {
388        self.rebase(&self.form, parent_id)
389    }
390
391    /// The section's list scoped to this parent.
392    fn list_for(&self, parent_id: &str) -> ListConfig {
393        let mut list = self.config.list.clone();
394        list.remember = false;
395        match &self.kind {
396            Kind::HasMany { foreign_key } => {
397                list.edit_base = Some(self.path(parent_id));
398                list.creatable = true;
399                // A section is one parent's rows, never a tree of its own.
400                list.tree = None;
401                list.scope = Some(list::Scope::Column {
402                    column: foreign_key.clone(),
403                    value: Some(parent_id.to_string()),
404                });
405            }
406            Kind::ManyToMany { pivot, .. } => {
407                // The order would be the pivot's to hold, and the list is not
408                // over the pivot.
409                list.reorder = None;
410                // A membership with nothing of its own has nothing to edit.
411                list.edit_base =
412                    (!self.config.form.fields.is_empty()).then(|| self.path(parent_id));
413                list.creatable = false;
414                list.scope = Some(list::Scope::Pivot {
415                    table: pivot.table.clone(),
416                    parent_key: pivot.parent_key.clone(),
417                    related_key: pivot.related_key.clone(),
418                    id_field: pivot.id_field.clone(),
419                    value: parent_id.to_string(),
420                });
421            }
422        }
423        list
424    }
425
426    /// Whether the section's rows keep an order of their own: a has-many
427    /// whose list declares `reorder`.
428    pub(crate) fn reorderable(&self) -> bool {
429        matches!(self.kind, Kind::HasMany { .. }) && self.config.list.reorder.is_some()
430    }
431
432    /// The per-row action: Delete for a record, Remove for a membership.
433    fn row_action(&self, path: &str, shell: &Shell) -> list::RowAction {
434        let (label, confirm, icon) = match &self.kind {
435            Kind::HasMany { .. } => (t!("Delete"), t!("Delete this record?"), "trash-2"),
436            Kind::ManyToMany { .. } => (t!("Remove"), t!("Remove this record?"), "unlink"),
437        };
438        list::RowAction {
439            path: format!("{path}/delete"),
440            label: shell.tt(&label),
441            confirm: shell.tt(&confirm),
442            icon,
443        }
444    }
445
446    /// The New or Attach button's label.
447    fn new_label(&self, shell: &Shell) -> String {
448        let label = shell.tt(&self.config.label);
449        match &self.kind {
450            Kind::HasMany { .. } => shell.tt(&t!("New {label}", label = label)),
451            Kind::ManyToMany { .. } => shell.tt(&t!("Attach {label}", label = label)),
452        }
453    }
454
455    /// Whether one row of `table` has every column equal to its value.
456    async fn exists(
457        &self,
458        db: &Db,
459        table: &str,
460        pairs: &[(&str, &str)],
461    ) -> Result<bool, sqlx::Error> {
462        let (sql, values) = {
463            let cast = text_cast(db.backend);
464            let mut select = Sql::select();
465            select.expr(Expr::val(1).count()).from(Alias::new(table));
466            for (column, value) in pairs {
467                select.and_where(
468                    Expr::col(Alias::new(*column))
469                        .cast_as(Alias::new(cast))
470                        .eq(value.to_string()),
471                );
472            }
473            build(db.backend, select)
474        };
475        let (n,): (i64,) = laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
476            .fetch_one(&db.pool)
477            .await?;
478        Ok(n > 0)
479    }
480
481    /// Whether the parent record exists; a child page under a parent that does
482    /// not is a 404, never a write with a dangling key.
483    async fn parent_exists(&self, db: &Db, parent_id: &str) -> Result<bool, sqlx::Error> {
484        self.exists(
485            db,
486            &self.parent_entity,
487            &[(&self.parent_id_field, parent_id)],
488        )
489        .await
490    }
491
492    /// Whether `child_id` is this parent's: a has-many's related record, or a
493    /// many-to-many's pivot row. A child page under the wrong parent is a 404,
494    /// so an id cannot be walked across parents.
495    async fn owned(&self, db: &Db, parent_id: &str, child_id: &str) -> Result<bool, sqlx::Error> {
496        match &self.kind {
497            Kind::HasMany { foreign_key } => {
498                self.exists(
499                    db,
500                    &self.config.entity,
501                    &[
502                        (&self.config.list.id_field, child_id),
503                        (foreign_key, parent_id),
504                    ],
505                )
506                .await
507            }
508            Kind::ManyToMany { pivot, .. } => {
509                self.exists(
510                    db,
511                    &pivot.table,
512                    &[(&pivot.id_field, child_id), (&pivot.parent_key, parent_id)],
513                )
514                .await
515            }
516        }
517    }
518
519    /// The related record a pivot row names, when the row is this parent's.
520    async fn pivot_related(
521        &self,
522        db: &Db,
523        pivot: &PivotConfig,
524        parent_id: &str,
525        child_id: &str,
526    ) -> Result<Option<String>, sqlx::Error> {
527        let (sql, values) = {
528            let cast = text_cast(db.backend);
529            build(
530                db.backend,
531                Sql::select()
532                    .expr_as(
533                        Expr::col(Alias::new(&pivot.related_key)).cast_as(Alias::new(cast)),
534                        Alias::new("related"),
535                    )
536                    .from(Alias::new(&pivot.table))
537                    .and_where(
538                        Expr::col(Alias::new(&pivot.id_field))
539                            .cast_as(Alias::new(cast))
540                            .eq(child_id.to_string()),
541                    )
542                    .and_where(
543                        Expr::col(Alias::new(&pivot.parent_key))
544                            .cast_as(Alias::new(cast))
545                            .eq(parent_id.to_string()),
546                    )
547                    .to_owned(),
548            )
549        };
550        let row: Option<(String,)> =
551            laterite_core::query::bind_values_as(sqlx::query_as(&sql), values)
552                .fetch_optional(&db.pool)
553                .await?;
554        Ok(row.map(|(related,)| related))
555    }
556
557    /// The section as the parent's edit page shows it: page one, unsorted.
558    pub(crate) async fn section(
559        &self,
560        state: &AdminState,
561        parent_id: &str,
562        user: &AuthenticatedUser,
563        shell: &Shell,
564    ) -> Option<RelationSection> {
565        if let Some(code) = &self.config.permission {
566            if !user.permissions.allows(code) {
567                return None;
568            }
569        }
570        let path = self.path(parent_id);
571        let html = list::fragment(
572            state,
573            &self.list_for(parent_id),
574            &path,
575            ListParams::default(),
576            &HashMap::new(),
577            user,
578            shell.clone(),
579            &format!("lat-rel-{}", self.config.name),
580            Some(self.row_action(&path, shell)),
581        )
582        .await
583        .ok()?;
584        Some(RelationSection {
585            title: shell.tt(&self.config.title),
586            new_href: Some(format!("{path}/new")),
587            new_label: self.new_label(shell),
588            html,
589        })
590    }
591}
592
593/// The handlers, each over one relation under one parent.
594#[allow(clippy::too_many_arguments)]
595pub(crate) async fn index(
596    State(state): State<AdminState>,
597    Extension(shell): Extension<Shell>,
598    Extension(user): Extension<AuthenticatedUser>,
599    Path(parent_id): Path<String>,
600    Query(params): Query<ListParams>,
601    Query(raw): Query<HashMap<String, String>>,
602    headers: axum::http::HeaderMap,
603    rel: Arc<PreparedRelation>,
604) -> Response {
605    match rel.parent_exists(&state.db, &parent_id).await {
606        Ok(true) => {}
607        Ok(false) => return not_found(),
608        Err(_) => return render_error(),
609    }
610    let path = rel.path(&parent_id);
611    let html = match list::fragment(
612        &state,
613        &rel.list_for(&parent_id),
614        &path,
615        params,
616        &raw,
617        &user,
618        shell.clone(),
619        &format!("lat-rel-{}", rel.config.name),
620        Some(rel.row_action(&path, &shell)),
621    )
622    .await
623    {
624        Ok(html) => html,
625        Err(()) => return render_error(),
626    };
627    if form::is_htmx(&headers) {
628        return axum::response::Html(html.into_string()).into_response();
629    }
630    // Without scripting, a sort or pager link lands here: the section on a
631    // page of its own, under the parent.
632    let page = SectionPage {
633        shell: &shell,
634        back: rel.parent_edit(&parent_id),
635        new_href: format!("{path}/new"),
636        new_label: rel.new_label(&shell),
637        html,
638    };
639    match crate::html::Markup::from_template(&page) {
640        Ok(body) => shell.page(&rel.config.title, body),
641        Err(_) => render_error(),
642    }
643}
644
645/// A section on a page of its own, for a request without scripting.
646#[derive(Template)]
647#[template(path = "relation_section.html")]
648struct SectionPage<'a> {
649    shell: &'a Shell,
650    back: String,
651    new_href: String,
652    new_label: String,
653    html: crate::html::Markup,
654}
655
656pub(crate) async fn new_form(
657    State(state): State<AdminState>,
658    Extension(shell): Extension<Shell>,
659    Path(parent_id): Path<String>,
660    rel: Arc<PreparedRelation>,
661) -> Response {
662    match rel.parent_exists(&state.db, &parent_id).await {
663        Ok(true) => {}
664        Ok(false) => return not_found(),
665        Err(_) => return render_error(),
666    }
667    form::new_form(&state, &rel.new_form_for(&parent_id), shell, Vec::new())
668}
669
670#[allow(clippy::too_many_arguments)]
671pub(crate) async fn create(
672    State(state): State<AdminState>,
673    Extension(shell): Extension<Shell>,
674    Extension(user): Extension<AuthenticatedUser>,
675    Extension(session): Extension<SessionHandle>,
676    Path(parent_id): Path<String>,
677    headers: axum::http::HeaderMap,
678    Form(mut data): Form<HashMap<String, String>>,
679    rel: Arc<PreparedRelation>,
680) -> Response {
681    match rel.parent_exists(&state.db, &parent_id).await {
682        Ok(true) => {}
683        Ok(false) => return not_found(),
684        Err(_) => return render_error(),
685    }
686    // The parent is the URL's, whatever the submission said.
687    match &rel.kind {
688        Kind::HasMany { foreign_key } => {
689            data.insert(foreign_key.clone(), parent_id.clone());
690        }
691        Kind::ManyToMany { pivot, .. } => {
692            data.insert(pivot.parent_key.clone(), parent_id.clone());
693            // A blank is the form's to refuse, as a required field.
694            let related = data
695                .get(&pivot.related_key)
696                .map(|v| v.trim().to_string())
697                .filter(|v| !v.is_empty());
698            if let Some(related) = related {
699                let present = rel
700                    .exists(
701                        &state.db,
702                        &rel.config.entity,
703                        &[(&rel.config.list.id_field, &related)],
704                    )
705                    .await;
706                let attached = rel
707                    .exists(
708                        &state.db,
709                        &pivot.table,
710                        &[
711                            (&pivot.parent_key, &parent_id),
712                            (&pivot.related_key, &related),
713                        ],
714                    )
715                    .await;
716                let refusal = match (present, attached) {
717                    (Ok(false), _) => Some(t!("There is no such record to attach.")),
718                    (_, Ok(true)) => Some(t!("That record is already attached.")),
719                    (Err(_), _) | (_, Err(_)) => return render_error(),
720                    _ => None,
721                };
722                if let Some(message) = refusal {
723                    session.push_flash(FlashLevel::Error, message);
724                    let back = format!("{}/new", rel.path(&parent_id));
725                    return form::saved_response(form::is_htmx(&headers), &back);
726                }
727            }
728        }
729    }
730    form::create(
731        &state,
732        &rel.new_form_for(&parent_id),
733        data,
734        shell,
735        &user,
736        &session,
737        &headers,
738    )
739    .await
740}
741
742pub(crate) async fn edit_form(
743    State(state): State<AdminState>,
744    Extension(shell): Extension<Shell>,
745    Path((parent_id, child_id)): Path<(String, String)>,
746    rel: Arc<PreparedRelation>,
747) -> Response {
748    match rel.owned(&state.db, &parent_id, &child_id).await {
749        Ok(true) => {}
750        Ok(false) => return not_found(),
751        Err(_) => return render_error(),
752    }
753    form::edit_form(
754        &state,
755        &rel.form_for(&parent_id),
756        child_id,
757        shell,
758        Vec::new(),
759    )
760    .await
761}
762
763#[allow(clippy::too_many_arguments)]
764pub(crate) async fn update(
765    State(state): State<AdminState>,
766    Extension(shell): Extension<Shell>,
767    Extension(user): Extension<AuthenticatedUser>,
768    Extension(session): Extension<SessionHandle>,
769    Path((parent_id, child_id)): Path<(String, String)>,
770    headers: axum::http::HeaderMap,
771    Form(mut data): Form<HashMap<String, String>>,
772    rel: Arc<PreparedRelation>,
773) -> Response {
774    // The parent is the URL's and the membership is the row's, whatever the
775    // submission said.
776    match &rel.kind {
777        Kind::HasMany { foreign_key } => {
778            match rel.owned(&state.db, &parent_id, &child_id).await {
779                Ok(true) => {}
780                Ok(false) => return not_found(),
781                Err(_) => return render_error(),
782            }
783            data.insert(foreign_key.clone(), parent_id.clone());
784        }
785        Kind::ManyToMany { pivot, .. } => {
786            match rel
787                .pivot_related(&state.db, pivot, &parent_id, &child_id)
788                .await
789            {
790                Ok(Some(related)) => {
791                    data.insert(pivot.related_key.clone(), related);
792                }
793                Ok(None) => return not_found(),
794                Err(_) => return render_error(),
795            }
796            data.insert(pivot.parent_key.clone(), parent_id.clone());
797        }
798    }
799    form::update(
800        &state,
801        &rel.form_for(&parent_id),
802        child_id,
803        data,
804        shell,
805        &user,
806        &session,
807        &headers,
808    )
809    .await
810}
811
812/// Moves one related record within its parent's section.
813#[allow(clippy::too_many_arguments)]
814pub(crate) async fn reorder(
815    State(state): State<AdminState>,
816    Extension(shell): Extension<Shell>,
817    Extension(user): Extension<AuthenticatedUser>,
818    Extension(session): Extension<SessionHandle>,
819    Path(parent_id): Path<String>,
820    headers: axum::http::HeaderMap,
821    Form(pairs): Form<Vec<(String, String)>>,
822    rel: Arc<PreparedRelation>,
823) -> Response {
824    match rel.parent_exists(&state.db, &parent_id).await {
825        Ok(true) => {}
826        Ok(false) => return not_found(),
827        Err(_) => return render_error(),
828    }
829    let path = rel.path(&parent_id);
830    let row_action = Some(rel.row_action(&path, &shell));
831    list::reorder_handler(
832        &state,
833        &rel.list_for(&parent_id),
834        &path,
835        &format!("lat-rel-{}", rel.config.name),
836        row_action,
837        &user,
838        &session,
839        shell,
840        &headers,
841        &pairs,
842    )
843    .await
844}
845
846/// Removes the selected rows (a has-many's records, a many-to-many's
847/// memberships), then shows the parent page again.
848#[allow(clippy::too_many_arguments)]
849pub(crate) async fn delete(
850    State(state): State<AdminState>,
851    Extension(shell): Extension<Shell>,
852    Extension(user): Extension<AuthenticatedUser>,
853    Extension(session): Extension<SessionHandle>,
854    Path(parent_id): Path<String>,
855    headers: axum::http::HeaderMap,
856    Form(pairs): Form<Vec<(String, String)>>,
857    rel: Arc<PreparedRelation>,
858) -> Response {
859    let ids: Vec<String> = pairs
860        .iter()
861        .filter(|(k, _)| k == "id")
862        .map(|(_, v)| v.trim().to_string())
863        .filter(|v| !v.is_empty())
864        .collect();
865    let mut owned = Vec::new();
866    for id in &ids {
867        match rel.owned(&state.db, &parent_id, id).await {
868            Ok(true) => owned.push(id.clone()),
869            Ok(false) => {}
870            Err(_) => return render_error(),
871        }
872    }
873    let actor = Actor::from(&user);
874    let outcome = bulk::delete_selected(&state, &rel.bulk, &owned, &actor).await;
875    if outcome.deleted > 0 {
876        let n = outcome.deleted as i64;
877        let message = match &rel.kind {
878            Kind::HasMany { .. } => {
879                laterite_core::tn!("Deleted {n} record.", "Deleted {n} records.", n = n)
880            }
881            Kind::ManyToMany { .. } => {
882                laterite_core::tn!("Removed {n} record.", "Removed {n} records.", n = n)
883            }
884        };
885        session.push_flash(FlashLevel::Success, message);
886    }
887    for message in outcome.refused {
888        session.push_flash(FlashLevel::Error, message);
889    }
890    if outcome.failed > 0 {
891        session.push_flash(FlashLevel::Error, t!("Some records could not be deleted."));
892    }
893    // Back to the parent page whole, so the flash saying what happened shows.
894    let _ = shell;
895    form::saved_response(form::is_htmx(&headers), &rel.parent_edit(&parent_id))
896}