use std::collections::HashMap;
use umbral::migrate::{Column, ModelMeta};
use umbral::orm::{DynQuerySet, SqlType};
use crate::AdminError;
use crate::config::AdminConfig;
fn apply_active_filters<'a>(
mut qs: DynQuerySet<'a>,
model: &ModelMeta,
active_filters: &[(String, String)],
) -> DynQuerySet<'a> {
for (field, value) in active_filters {
let parts: Vec<String> = if value.contains(',') {
value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else {
vec![value.clone()]
};
if model.m2m_relations.iter().any(|r| &r.field_name == field) {
qs = qs.filter_m2m_contains_any(field, &parts);
} else if parts.len() > 1 {
qs = qs.filter_in_strings(field, &parts);
} else {
qs = qs.filter_eq_string(field, value);
}
}
qs
}
pub(crate) async fn count_rows_filtered(
model: &ModelMeta,
search_term: Option<&str>,
cfg: Option<&AdminConfig>,
active_filters: &[(String, String)],
trash: bool,
) -> Result<usize, AdminError> {
let mut qs = DynQuerySet::for_meta(model);
if trash {
qs = qs.only_deleted();
}
if let Some(term) = search_term {
let restrict: &[String] = cfg.map(|c| c.search_fields.as_slice()).unwrap_or(&[]);
qs = qs.search(restrict, term);
}
qs = apply_active_filters(qs, model, active_filters);
let count = qs.count().await?;
Ok(count as usize)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn fetch_rows_paged(
model: &ModelMeta,
display_cols: &[String],
order_clause: &str,
search_term: Option<&str>,
cfg: Option<&AdminConfig>,
active_filters: &[(String, String)],
limit: usize,
offset: usize,
trash: bool,
) -> Result<Vec<HashMap<String, String>>, AdminError> {
let mut qs = DynQuerySet::for_meta(model).select_cols(display_cols);
if trash {
qs = qs.only_deleted();
}
if let Some(term) = search_term {
let restrict: &[String] = cfg.map(|c| c.search_fields.as_slice()).unwrap_or(&[]);
qs = qs.search(restrict, term);
}
qs = apply_active_filters(qs, model, active_filters);
for (col, desc) in parse_order_clause(order_clause) {
qs = qs.order_by_col(&col, desc);
}
qs = qs.limit(limit as u64).offset(offset as u64);
let mut rows = qs.fetch_as_strings().await?;
apply_max_length_truncation(model, &mut rows);
Ok(rows)
}
fn apply_max_length_truncation(model: &ModelMeta, rows: &mut [HashMap<String, String>]) {
for row in rows.iter_mut() {
for col in &model.fields {
if col.max_length == 0 {
continue;
}
let limit = col.max_length as usize;
if let Some(val) = row.get_mut(&col.name) {
if val.chars().count() > limit {
let truncated: String = val.chars().take(limit).collect();
*val = format!("{truncated}…");
}
}
}
}
}
fn parse_order_clause(clause: &str) -> Vec<(String, bool)> {
if clause.trim().is_empty() {
return Vec::new();
}
clause
.split(',')
.filter_map(|part| {
let trimmed = part.trim();
if trimmed.is_empty() {
return None;
}
let (col_part, dir_part) = trimmed.rsplit_once(' ')?;
let col = col_part.trim().trim_matches('"');
if col.is_empty() {
return None;
}
let descending = dir_part.trim().eq_ignore_ascii_case("DESC");
Some((col.to_string(), descending))
})
.collect()
}
pub(crate) async fn fetch_rows_filtered(
model: &ModelMeta,
where_pk: Option<(&str, &str)>,
display_cols: &[String],
) -> Result<Vec<HashMap<String, String>>, AdminError> {
let mut qs = DynQuerySet::for_meta(model).select_cols(display_cols);
if let Some((col, val)) = where_pk {
qs = qs.filter_eq_string(col, val).limit(1);
} else {
qs = qs.limit(200);
}
Ok(qs.fetch_as_strings().await?)
}
pub(crate) async fn insert_row_in_tx(
tx: &mut umbral::db::Transaction,
model: &ModelMeta,
form: &HashMap<String, String>,
cfg: Option<&AdminConfig>,
) -> Result<String, AdminError> {
let form_owned: HashMap<String, String>;
let form = if let Some(pw_col) = cfg.and_then(|c| c.password_field.as_deref()) {
if let Some(plaintext) = form.get(pw_col).filter(|v| !v.is_empty()) {
let confirm_key = format!("{pw_col}_confirm");
let confirm = form.get(&confirm_key).map(|s| s.as_str()).unwrap_or("");
if plaintext != confirm {
return Err(AdminError::BadInput("Passwords do not match.".to_string()));
}
let hash = umbral_auth::hash_password_async(plaintext)
.await
.map_err(|e| AdminError::BadInput(format!("password hashing failed: {e}")))?;
let mut owned = form.clone();
owned.insert(pw_col.to_string(), hash);
form_owned = owned;
&form_owned
} else {
form
}
} else {
form
};
let skip = readonly_set(model, cfg);
let new_int_pk = DynQuerySet::for_meta(model)
.insert_form_in_tx(tx, form, &skip)
.await?;
let pk_col = model.fields.iter().find(|c| c.primary_key);
Ok(match pk_col {
Some(c) if !matches!(c.ty, SqlType::SmallInt | SqlType::Integer | SqlType::BigInt) => {
form.get(&c.name).cloned().unwrap_or_default()
}
_ => new_int_pk.to_string(),
})
}
pub(crate) async fn update_row_in_tx(
tx: &mut umbral::db::Transaction,
model: &ModelMeta,
pk: &Column,
pk_value: &str,
form: &HashMap<String, String>,
cfg: Option<&AdminConfig>,
) -> Result<(), AdminError> {
let skip = readonly_set(model, cfg);
DynQuerySet::for_meta(model)
.filter_eq_string(&pk.name, pk_value)
.update_form_in_tx(tx, form, &skip)
.await?;
Ok(())
}
fn readonly_set(model: &ModelMeta, cfg: Option<&AdminConfig>) -> Vec<String> {
let all_col_names: Vec<&str> = model.fields.iter().map(|c| c.name.as_str()).collect();
let mut set: Vec<String> = if let Some(c) = cfg {
c.effective_readonly_fields(&all_col_names)
.into_iter()
.map(|s| s.to_string())
.collect()
} else {
all_col_names
.iter()
.filter(|n| crate::config::is_sensitive_column(n))
.map(|s| s.to_string())
.collect()
};
for col in &model.fields {
if (col.noform || col.noedit) && !set.iter().any(|s| s == &col.name) {
set.push(col.name.clone());
}
}
set
}
#[cfg(test)]
mod readonly_set_tests {
use super::*;
fn col(name: &str, noform: bool, noedit: bool) -> Column {
Column {
name: name.into(),
ty: SqlType::Text,
primary_key: false,
nullable: false,
fk_target: None,
noform,
db_constraint: true,
noedit,
is_string_repr: false,
max_length: 0,
choices: vec![],
choice_labels: vec![],
default: String::new(),
is_multichoice: false,
unique: false,
on_delete: umbral::orm::FkAction::NoAction,
on_update: umbral::orm::FkAction::NoAction,
index: false,
auto_now_add: false,
auto_now: false,
help: String::new(),
example: String::new(),
widget: None,
supported_backends: Vec::new(),
min: None,
max: None,
text_format: None,
slug_from: None,
}
}
fn meta(fields: Vec<Column>) -> ModelMeta {
ModelMeta {
name: "M".into(),
table: "m".into(),
fields,
display: "M".into(),
icon: "database".into(),
database: None,
singleton: false,
unique_together: Vec::new(),
indexes: Vec::new(),
ordering: Vec::new(),
m2m_relations: Vec::new(),
soft_delete: false,
app_label: "app".into(),
}
}
#[test]
fn noform_and_noedit_columns_are_always_readonly() {
let m = meta(vec![
col("title", false, false), col("locked", true, false), col("username", false, true), ]);
let skip = readonly_set(&m, None);
assert!(
skip.contains(&"locked".to_string()),
"noform must be skipped: {skip:?}"
);
assert!(
skip.contains(&"username".to_string()),
"noedit must be skipped: {skip:?}"
);
assert!(
!skip.contains(&"title".to_string()),
"editable col must be writable: {skip:?}"
);
}
}