umbral-admin 0.0.7

Auto-generated CRUD admin UI for umbral models.
Documentation
//! Foreign-key combobox endpoints — paginated search (`fk_options`)
//! and pre-selected label resolution (`fk_options_resolve`).
//!
//! ⚠ Raw SQL. Same constraint as `palette_search`: the related table
//! and label column are resolved at request time from `ModelMeta`, so
//! the ORM's typed `QuerySet` can't express the query yet. A future
//! ORM extension (runtime-typed paginated SELECT over arbitrary
//! ModelMeta) takes this over.

use std::collections::HashMap;

use axum::extract::{Path, Query, State};
use umbral::orm::{DynQuerySet, SqlType};
use umbral::web::{HeaderMap, IntoResponse, Json, Response, StatusCode};

use crate::AdminState;
use crate::auth::require_staff;
use crate::discovery::{find_model, pk_column};
use crate::error::AdminError;
use crate::util::{html_escape, is_htmx, urlencoding_simple};

/// `GET /admin/api/{table}/{field}/options?search=&page=&page_size=20`
///
/// Returns paginated label+value options for an FK field. HTMX
/// requests get an HTML fragment (the combobox dropdown body); plain
/// requests get JSON for programmatic consumers.
///
/// URL semantics: `{table}` is the PARENT model's table; `{field}`
/// is the FK column on it. The handler looks the column up,
/// follows `col.fk_target` to find the target model, and serves
/// options from there. The templates must construct the URL with
/// the parent's table — not the FK target — or the lookup 404s
/// with "no field `<fk_col>` on `<target_table>`" (the latter
/// table has no such column, by design).
pub(crate) async fn fk_options(
    State(state): State<AdminState>,
    headers: HeaderMap,
    Path((table, field)): Path<(String, String)>,
    Query(params): Query<HashMap<String, String>>,
) -> Response {
    let path = format!(
        "{}/api/{table}/{field}/options",
        crate::branding::current().base_path
    );
    let who = match require_staff(&headers, &path).await {
        Ok(u) => u,
        Err(r) => return r,
    };
    let Some((plugin_name, model)) = find_model(&table) else {
        return AdminError::NotFound(format!("no model `{table}`")).into_response();
    };
    // WEB-7: the FK autocomplete returns rows of a model; without a
    // permission gate any staff user could enumerate a model they have no
    // `view_<model>` right to. Require View on the parent table, mirroring
    // the CRUD/list handlers (a no-permissions install still passes).
    if let Err(r) =
        crate::permcheck::require(&who, &plugin_name, &table, crate::permcheck::Action::View).await
    {
        return r;
    }
    // Resolve `field` against both regular columns (FK case) and the
    // model's M2M relations. M2M fields aren't in `model.fields` —
    // they live in `model.m2m_relations` and target a different table.
    // Either path yields the same `related_table` the picker queries.
    let related_table = if let Some(col) = model.fields.iter().find(|c| c.name == field) {
        col.fk_target
            .clone()
            .unwrap_or_else(|| field.trim_end_matches("_id").to_string())
    } else if let Some(rel) = model.m2m_relations.iter().find(|r| r.field_name == field) {
        rel.target_table.clone()
    } else {
        return AdminError::NotFound(format!("no field `{field}` on `{table}`")).into_response();
    };
    let Some((_, related_model)) = find_model(&related_table) else {
        return (
            StatusCode::FORBIDDEN,
            format!("related model `{related_table}` not found or not viewable"),
        )
            .into_response();
    };

    let search = params.get("search").map(|s| s.as_str()).unwrap_or("");
    let page: usize = params
        .get("page")
        .and_then(|p| p.parse().ok())
        .unwrap_or(1)
        .max(1);
    let page_size: usize = params
        .get("page_size")
        .and_then(|p| p.parse().ok())
        .unwrap_or(20)
        .clamp(1, 100);
    let offset = (page - 1) * page_size;

    // Pick a label column: first non-PK text column.
    let label_col = related_model
        .fields
        .iter()
        .find(|c| !c.primary_key && matches!(c.ty, SqlType::Text))
        .map(|c| c.name.as_str())
        .unwrap_or("id");

    // Related model's search_fields from the admin config if registered.
    let rel_cfg = state.config_for(&related_table);
    let search_cols: Vec<String> = rel_cfg
        .filter(|c| !c.search_fields.is_empty())
        .map(|c| c.search_fields.clone())
        .unwrap_or_else(|| vec![label_col.to_string()]);

    let pk_col_name = pk_column(&related_model)
        .map(|c| c.name.clone())
        .unwrap_or_else(|| "id".to_string());
    let label_col_owned = label_col.to_string();
    let select_cols = if pk_col_name == label_col_owned {
        vec![pk_col_name.clone()]
    } else {
        vec![pk_col_name.clone(), label_col_owned.clone()]
    };

    // COUNT(*) and the paginated SELECT share the same WHERE clause —
    // build the chain once, count off a clone, page off the original.
    let base = DynQuerySet::for_meta(&related_model).search(&search_cols, search);
    let total: i64 = match base.count().await {
        Ok(t) => t,
        Err(e) => return AdminError::from(e).into_response(),
    };

    let rows = match DynQuerySet::for_meta(&related_model)
        .search(&search_cols, search)
        .select_cols(&select_cols)
        .order_by_col(&pk_col_name, true)
        .limit(page_size as u64)
        .offset(offset as u64)
        .fetch_as_strings()
        .await
    {
        Ok(r) => r,
        Err(e) => return AdminError::from(e).into_response(),
    };

    // PK lift Pass B: render the PK in whatever shape it lives in
    // the DB. Pre-fix, `let value: i64 = raw_pk.parse().unwrap_or(0)`
    // silently rewrote every non-integer PK to 0 — so a String-PK
    // model (e.g. `permissions_permission` keyed by codename) ended
    // up with every row sharing `value: 0` and the picker becoming
    // unusable. The JSON payload now emits the raw value (Number for
    // integer PKs, String for codename / slug / UUID); the HTML
    // template renders it via `Display`.
    let pk_col_type = related_model
        .fields
        .iter()
        .find(|c| c.name == pk_col_name)
        .map(|c| c.ty);
    let items: Vec<serde_json::Value> = rows
        .iter()
        .map(|r| {
            let raw_pk = r.get(&pk_col_name).cloned().unwrap_or_default();
            let value_json = pk_string_to_json(&raw_pk, pk_col_type);
            let label: String = r
                .get(&label_col_owned)
                .cloned()
                .unwrap_or_else(|| format!("#{raw_pk}"));
            serde_json::json!({ "value": value_json, "label": label })
        })
        .collect();

    let has_more = (offset + page_size) < total as usize;

    if is_htmx(&headers) {
        let mut html = String::new();
        html.push_str(r#"<div class="py-xs">"#);
        for item in &items {
            // `value` may be a Number or a String (PK lift Pass B).
            // `data-fk-value` is a string attribute either way, so
            // render via Display on the underlying JSON value.
            let value_display = match &item["value"] {
                serde_json::Value::Number(n) => n.to_string(),
                serde_json::Value::String(s) => s.clone(),
                other => other.to_string(),
            };
            let label = item["label"].as_str().unwrap_or("");
            let escaped_value = html_escape(&value_display);
            let escaped_label = html_escape(label);
            html.push_str(&format!(
                r##"<button type="button" data-fk-value="{escaped_value}" data-fk-label="{escaped_label}" class="w-full text-left px-md py-sm hover:bg-surface-container-high font-body-md text-on-surface transition-colors"><span class="block truncate"><span class="font-medium tabular-nums">{escaped_value}</span><span class="text-outline">: </span>{escaped_label}</span></button>"##
            ));
        }
        if items.is_empty() {
            html.push_str(
                r#"<p class="px-md py-sm text-outline text-body-sm italic">No results</p>"#,
            );
        }
        html.push_str("</div>");
        if total > page_size as i64 {
            let prev_page = page.saturating_sub(1).max(1);
            let next_page = page + 1;
            let encoded_search = urlencoding_simple(search);
            let prev_disabled = if page <= 1 { " disabled" } else { "" };
            let next_disabled = if !has_more { " disabled" } else { "" };
            let base = crate::branding::current().base_path;
            html.push_str(&format!(
                r##"<div class="flex items-center justify-between gap-sm border-t border-outline-variant px-sm py-xs"><button type="button" class="px-sm py-xs rounded-lg border border-outline-variant text-label-sm text-on-surface-variant hover:bg-surface-container-high disabled:opacity-40 disabled:cursor-not-allowed" hx-get="{base}/api/{table}/{field}/options?search={encoded_search}&page={prev_page}&page_size={page_size}" hx-target="closest .fk-options" hx-swap="innerHTML"{prev_disabled}>Previous</button><span class="text-label-sm text-outline tabular-nums">Page {page}</span><button type="button" class="px-sm py-xs rounded-lg border border-outline-variant text-label-sm text-on-surface-variant hover:bg-surface-container-high disabled:opacity-40 disabled:cursor-not-allowed" hx-get="{base}/api/{table}/{field}/options?search={encoded_search}&page={next_page}&page_size={page_size}" hx-target="closest .fk-options" hx-swap="innerHTML"{next_disabled}>Next</button></div>"##
            ));
        }
        return axum::response::Response::builder()
            .status(StatusCode::OK)
            .header("Content-Type", "text/html")
            .body(axum::body::Body::from(html))
            .unwrap_or_else(|_| StatusCode::OK.into_response());
    }

    Json(serde_json::json!({
        "items": items,
        "page": page,
        "has_more": has_more,
    }))
    .into_response()
}

/// `GET /admin/api/{table}/{field}/options/resolve?ids=1,2,3`
///
/// Returns labels for pre-selected ids — used on edit-form load so
/// the combobox can render the existing FK value's label before the
/// user has typed a search query. Same URL semantics as `fk_options`:
/// `{table}` is the parent's table, `{field}` is the FK column on
/// it; the handler derives the target via `col.fk_target`.
pub(crate) async fn fk_options_resolve(
    State(state): State<AdminState>,
    headers: HeaderMap,
    Path((table, field)): Path<(String, String)>,
    Query(params): Query<HashMap<String, String>>,
) -> Response {
    let path = format!(
        "{}/api/{table}/{field}/options/resolve",
        crate::branding::current().base_path
    );
    let who = match require_staff(&headers, &path).await {
        Ok(u) => u,
        Err(r) => return r,
    };
    let Some((plugin_name, model)) = find_model(&table) else {
        return AdminError::NotFound(format!("no model `{table}`")).into_response();
    };
    // WEB-7: the FK autocomplete returns rows of a model; without a
    // permission gate any staff user could enumerate a model they have no
    // `view_<model>` right to. Require View on the parent table, mirroring
    // the CRUD/list handlers (a no-permissions install still passes).
    if let Err(r) =
        crate::permcheck::require(&who, &plugin_name, &table, crate::permcheck::Action::View).await
    {
        return r;
    }
    // Same dual-lookup as `fk_options`: FK columns live in
    // `model.fields`, M2M relations in `model.m2m_relations`. Either
    // path resolves to the same related table.
    let related_table = if let Some(col) = model.fields.iter().find(|c| c.name == field) {
        col.fk_target
            .clone()
            .unwrap_or_else(|| field.trim_end_matches("_id").to_string())
    } else if let Some(rel) = model.m2m_relations.iter().find(|r| r.field_name == field) {
        rel.target_table.clone()
    } else {
        return AdminError::NotFound(format!("no field `{field}`")).into_response();
    };
    let Some((_, related_model)) = find_model(&related_table) else {
        return (StatusCode::FORBIDDEN, "related model not found").into_response();
    };

    // PK lift Pass B: `?ids=` now carries raw PK strings (could be
    // "1,2,3" for integer PKs OR "blog.publish_post,blog.edit_post"
    // for String-PK models like `permissions_permission`). Bind via
    // `filter_in_strings` which dispatches on the column's SqlType
    // and coerces accordingly — so the same parser works for either
    // shape without the caller having to know in advance.
    let ids_param = params.get("ids").cloned().unwrap_or_default();
    let ids: Vec<String> = ids_param
        .split(',')
        .filter_map(|s| {
            let s = s.trim();
            if s.is_empty() {
                None
            } else {
                Some(s.to_string())
            }
        })
        .collect();
    if ids.is_empty() {
        return Json(serde_json::json!({ "items": [] })).into_response();
    }

    let label_col_owned = related_model
        .fields
        .iter()
        .find(|c| !c.primary_key && matches!(c.ty, SqlType::Text))
        .map(|c| c.name.clone())
        .unwrap_or_else(|| "id".to_string());
    let pk_col = pk_column(&related_model);
    let pk_col_name = pk_col
        .as_ref()
        .map(|c| c.name.clone())
        .unwrap_or_else(|| "id".to_string());
    let pk_col_type = pk_col.as_ref().map(|c| c.ty);

    let select_cols = if pk_col_name == label_col_owned {
        vec![pk_col_name.clone()]
    } else {
        vec![pk_col_name.clone(), label_col_owned.clone()]
    };

    let _ = &state; // referenced only above

    match DynQuerySet::for_meta(&related_model)
        .select_cols(&select_cols)
        .filter_in_strings(&pk_col_name, &ids)
        .fetch_as_strings()
        .await
    {
        Ok(rows) => {
            // Same `value` rendering rule as `fk_options`: emit the
            // PK in whatever JSON shape matches its SqlType so the
            // frontend's hidden input round-trips the right value.
            let items: Vec<serde_json::Value> = rows
                .iter()
                .map(|r| {
                    let raw_pk = r.get(&pk_col_name).cloned().unwrap_or_default();
                    let value_json = pk_string_to_json(&raw_pk, pk_col_type);
                    let label: String = r
                        .get(&label_col_owned)
                        .cloned()
                        .unwrap_or_else(|| format!("#{raw_pk}"));
                    serde_json::json!({ "value": value_json, "label": label })
                })
                .collect();
            Json(serde_json::json!({ "items": items })).into_response()
        }
        Err(e) => AdminError::from(e).into_response(),
    }
}

/// PK lift Pass B helper — turn a stringified PK (the shape
/// `DynQuerySet::fetch_as_strings` produces) into the JSON value
/// the FK picker emits. Integer / FK / float PKs round-trip as
/// `serde_json::Value::Number`; Text / Uuid / unknown PKs land as
/// `Value::String`. The `Display` form is preserved for both —
/// HTMX consumers can read `data-fk-value` as a string verbatim,
/// and the JSON consumer's hidden input gets the right type when
/// submitted.
fn pk_string_to_json(raw: &str, pk_ty: Option<umbral::orm::SqlType>) -> serde_json::Value {
    use umbral::orm::SqlType;
    match pk_ty {
        Some(SqlType::SmallInt)
        | Some(SqlType::Integer)
        | Some(SqlType::BigInt)
        | Some(SqlType::ForeignKey) => raw
            .parse::<i64>()
            .ok()
            .map(serde_json::Value::from)
            .unwrap_or_else(|| serde_json::Value::String(raw.to_string())),
        Some(SqlType::Real) | Some(SqlType::Double) => raw
            .parse::<f64>()
            .ok()
            .map(serde_json::Value::from)
            .unwrap_or_else(|| serde_json::Value::String(raw.to_string())),
        // Text / Uuid / anything else: keep as string.
        _ => serde_json::Value::String(raw.to_string()),
    }
}