#![allow(dead_code)]
use std::collections::HashMap;
use serde::Serialize;
use super::audit::AdminAction;
use super::types::{Admin, AdminEntry, AdminField, EditRow, ListRow};
use crate::auth::Identity;
use crate::error::Result;
use crate::http::FormData;
use crate::orm::Db;
#[derive(Serialize)]
pub(crate) struct IdentityCtx {
pub email: String,
pub is_admin: bool,
pub is_developer: bool,
pub mfa_enabled: bool,
}
impl From<&Identity> for IdentityCtx {
fn from(i: &Identity) -> Self {
Self {
email: i.email.clone(),
is_admin: i.is_admin(),
is_developer: i.is_active && i.role.includes(crate::auth::Role::Developer),
mfa_enabled: i.mfa_enabled,
}
}
}
#[derive(Serialize)]
pub(crate) struct BaseContext {
pub identity: Option<IdentityCtx>,
pub csrf_token: String,
pub site_title: String,
pub site_header: String,
pub index_title: String,
pub footer_copyright: String,
pub app_name: String,
pub app_tagline: Option<String>,
pub show_powered_by: bool,
pub framework_version: &'static str,
pub environment_label: &'static str,
pub environment_kind: &'static str,
pub server_now: String,
pub is_demo_session: bool,
pub demo_label: Option<String>,
pub has_theme_overrides: bool,
pub accent_hex: Option<String>,
pub accent_rgb: Option<String>,
pub theme_bg: Option<String>,
pub theme_surface: Option<String>,
pub theme_text: Option<String>,
pub theme_text_muted: Option<String>,
pub theme_border: Option<String>,
pub read_only: bool,
pub unread_count: i64,
}
pub(crate) fn hex_to_rgb_triplet(hex: &str) -> String {
const FALLBACK: &str = "160 52 26"; let h = hex.trim_start_matches('#');
if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
return FALLBACK.into();
}
let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(160);
let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(52);
let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(26);
format!("{r} {g} {b}")
}
fn environment_label() -> &'static str {
static ENV_LABEL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
ENV_LABEL.get_or_init(|| {
std::env::var("RUSTIO_ENV").unwrap_or_else(|_| {
if cfg!(debug_assertions) {
"Development".into()
} else {
"Production".into()
}
})
})
}
fn environment_kind(label: &str) -> &'static str {
match label.to_ascii_lowercase().as_str() {
"production" | "prod" => "prod",
"development" | "dev" => "dev",
_ => "other",
}
}
impl BaseContext {
pub(crate) fn new(identity: Option<&Identity>, csrf_token: String, admin: &Admin) -> Self {
let b = admin.branding();
let (is_demo_session, demo_label) = match identity {
Some(i) => (i.is_demo, i.demo_label.clone()),
None => (false, None),
};
let theme = admin.active_theme();
let accent_hex = theme.accent.clone();
let accent_rgb = accent_hex.as_deref().map(hex_to_rgb_triplet);
let env_label = environment_label();
Self {
identity: identity.map(IdentityCtx::from),
csrf_token,
site_title: b.site_title.clone(),
site_header: b.site_header.clone(),
index_title: b.index_title.clone(),
footer_copyright: b.footer_copyright.clone(),
app_name: b.app_name.clone(),
app_tagline: b.app_tagline.clone(),
show_powered_by: b.show_powered_by,
framework_version: env!("CARGO_PKG_VERSION"),
environment_label: env_label,
environment_kind: environment_kind(env_label),
server_now: chrono::Utc::now().format("%Y-%m-%d %H:%M UTC").to_string(),
is_demo_session,
demo_label,
has_theme_overrides: theme.has_overrides(),
accent_hex,
accent_rgb,
theme_bg: theme.bg.clone(),
theme_surface: theme.surface.clone(),
theme_text: theme.text.clone(),
theme_text_muted: theme.text_muted.clone(),
theme_border: theme.border.clone(),
read_only: admin.is_read_only(),
unread_count: 0,
}
}
pub(crate) fn with_unread_count(mut self, n: i64) -> Self {
self.unread_count = n.max(0);
self
}
}
#[derive(Serialize)]
pub(crate) struct SidebarEntry {
pub admin_name: &'static str,
pub display_name: &'static str,
}
impl From<&AdminEntry> for SidebarEntry {
fn from(e: &AdminEntry) -> Self {
Self {
admin_name: e.admin_name,
display_name: e.display_name,
}
}
}
#[derive(Serialize)]
pub(crate) struct FlashCtx {
pub kind: &'static str,
pub message: String,
}
#[derive(Serialize)]
pub(crate) struct LoginCtx {
#[serde(flatten)]
pub base: BaseContext,
pub error: Option<String>,
pub sections: Vec<FormSection>,
pub flash: Option<FlashCtx>,
}
pub(crate) fn login_form_sections() -> Vec<FormSection> {
vec![FormSection {
title: None,
fields: vec![
FormField {
name: "email",
label: "Email".to_string(),
widget: "input",
input_type: "email",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 1,
autocomplete: Some("username"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "password",
label: "Password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 1,
autocomplete: Some("current-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}
#[derive(Serialize)]
pub(crate) struct DashboardCtx {
#[serde(flatten)]
pub base: BaseContext,
pub entries: Vec<SidebarEntry>,
pub apps: Vec<DashboardApp>,
pub recent_actions: Vec<RecentActionCtx>,
pub flash: Option<FlashCtx>,
pub greeting_name: String,
pub total_models: usize,
pub total_rows: i64,
pub recent_actions_count: usize,
pub activity_sparkline: Vec<DaySparkPoint>,
pub activity_sparkline_max: i64,
pub activity_sparkline_total: i64,
}
#[derive(Serialize)]
pub(crate) struct DaySparkPoint {
pub date_iso: String,
pub label: &'static str,
pub count: i64,
}
#[derive(Serialize)]
pub(crate) struct DashboardApp {
pub label: String,
pub models: Vec<DashboardModel>,
}
#[derive(Serialize)]
pub(crate) struct DashboardModel {
pub admin_name: &'static str,
pub display_name: &'static str,
pub field_count: usize,
pub row_estimate: i64,
pub new_this_week: Option<i64>,
pub weekly_series: Option<Vec<i64>>,
pub weekly_series_max: i64,
}
#[derive(Serialize)]
pub(crate) struct RecentActionCtx {
pub action_type: String,
pub label: &'static str,
pub pill_class: &'static str,
pub model_name: String,
pub object_id: i64,
pub user_email: String,
pub summary: String,
pub when_relative: String,
}
pub(crate) fn group_entries_by_app(
entries: &[AdminEntry],
row_estimates: &HashMap<&str, i64>,
new_this_week: &HashMap<&str, i64>,
per_model_series: &HashMap<&str, Vec<i64>>,
) -> Vec<DashboardApp> {
let mut apps: Vec<DashboardApp> = Vec::new();
for entry in entries {
if entry.core {
continue;
}
let label = app_label_for(entry.admin_name);
let app = match apps.iter_mut().find(|a| a.label == label) {
Some(a) => a,
None => {
apps.push(DashboardApp {
label: label.clone(),
models: Vec::new(),
});
apps.last_mut().unwrap()
}
};
let estimate = row_estimates.get(entry.table).copied().unwrap_or(0).max(0);
let week = new_this_week.get(entry.table).copied().map(|n| n.max(0));
let series = per_model_series.get(entry.table).cloned();
let series_max = series
.as_ref()
.and_then(|s| s.iter().copied().max())
.unwrap_or(0)
.max(0);
app.models.push(DashboardModel {
admin_name: entry.admin_name,
display_name: entry.display_name,
field_count: entry.fields.len(),
row_estimate: estimate,
new_this_week: week,
weekly_series: series,
weekly_series_max: series_max,
});
}
apps
}
pub(crate) fn app_label_for(admin_name: &str) -> String {
let prefix = admin_name.split('.').next().unwrap_or(admin_name);
capitalise(prefix)
}
fn capitalise(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn dashboard_ctx(
identity: &Identity,
admin: &Admin,
recent_actions: Vec<AdminAction>,
csrf_token: String,
row_estimates: &HashMap<&str, i64>,
new_this_week: &HashMap<&str, i64>,
per_model_series: &HashMap<&str, Vec<i64>>,
activity_sparkline: Vec<DaySparkPoint>,
) -> DashboardCtx {
let recent: Vec<RecentActionCtx> = recent_actions
.into_iter()
.map(|a| RecentActionCtx {
action_type: a.action_type.clone(),
label: action_label(&a.action_type),
pill_class: action_pill_class(&a.action_type),
model_name: a.model_name,
object_id: a.object_id,
user_email: a.user_email.unwrap_or_else(|| "—".to_string()),
summary: a.summary,
when_relative: relative_time(a.timestamp),
})
.collect();
let apps = group_entries_by_app(
admin.entries(),
row_estimates,
new_this_week,
per_model_series,
);
let total_models = apps.iter().map(|a| a.models.len()).sum();
let total_rows = apps
.iter()
.flat_map(|a| a.models.iter())
.map(|m| m.row_estimate)
.sum();
let recent_actions_count = recent.len();
let greeting_name = greeting_from_email(&identity.email);
let activity_sparkline_max = activity_sparkline
.iter()
.map(|p| p.count)
.max()
.unwrap_or(0)
.max(0);
let activity_sparkline_total = activity_sparkline.iter().map(|p| p.count).sum();
DashboardCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
apps,
recent_actions: recent,
flash: None,
greeting_name,
total_models,
total_rows,
recent_actions_count,
activity_sparkline,
activity_sparkline_max,
activity_sparkline_total,
}
}
fn greeting_from_email(email: &str) -> String {
let local = email.split('@').next().unwrap_or(email);
let cleaned: String = local
.split(['.', '_', '-'])
.filter(|s| !s.is_empty())
.map(capitalise)
.collect::<Vec<_>>()
.join(" ");
if cleaned.is_empty() {
"there".to_string()
} else {
cleaned
}
}
#[derive(Serialize)]
pub(crate) struct AccountSessionsCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<SidebarEntry>,
pub sessions: Vec<AccountSessionRowCtx>,
}
#[derive(Serialize)]
pub(crate) struct AccountSessionRowCtx {
pub session_id: i64,
pub trust_label: &'static str,
pub is_current: bool,
pub ip: String,
pub ua_summary: String,
pub created_at: String,
pub last_seen_relative: String,
pub expires_relative: String,
}
pub(crate) fn account_sessions_ctx(
identity: &Identity,
admin: &Admin,
sessions: Vec<crate::auth::Session>,
current_session_id: Option<i64>,
csrf_token: String,
) -> AccountSessionsCtx {
let rows = sessions
.into_iter()
.map(|s| AccountSessionRowCtx {
session_id: s.session_id,
trust_label: trust_label(s.trust_level),
is_current: Some(s.session_id) == current_session_id,
ip: s.ip.unwrap_or_else(|| "—".to_string()),
ua_summary: summarise_user_agent(s.user_agent.as_deref()),
created_at: s.created_at.format("%Y-%m-%d %H:%M").to_string(),
last_seen_relative: relative_time(s.last_seen),
expires_relative: relative_time(s.expires_at),
})
.collect();
AccountSessionsCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: "Active sessions",
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
sessions: rows,
}
}
const fn trust_label(t: crate::auth::SessionTrust) -> &'static str {
match t {
crate::auth::SessionTrust::Authenticated => "Signed in",
crate::auth::SessionTrust::Elevated => "Elevated",
crate::auth::SessionTrust::MfaVerified => "MFA verified",
}
}
pub(crate) fn summarise_user_agent(ua: Option<&str>) -> String {
let Some(ua) = ua else {
return "—".to_string();
};
let lc = ua.to_ascii_lowercase();
let os = if lc.contains("windows") {
"Windows"
} else if lc.contains("iphone") {
"iOS"
} else if lc.contains("ipad") {
"iPadOS"
} else if lc.contains("android") {
"Android"
} else if lc.contains("mac os x") || lc.contains("macos") {
"macOS"
} else if lc.contains("linux") {
"Linux"
} else {
"—"
};
let browser = if lc.contains("firefox") {
"Firefox"
} else if lc.contains("edg/") {
"Edge"
} else if lc.contains("opr/") || lc.contains("opera") {
"Opera"
} else if lc.contains("chrome") {
"Chrome"
} else if lc.contains("safari") {
"Safari"
} else if lc.contains("curl") {
"curl"
} else {
"—"
};
if os == "—" && browser == "—" {
ua.chars().take(40).collect()
} else {
format!("{os} · {browser}")
}
}
fn action_label(action_type: &str) -> &'static str {
match action_type {
"create" => "Created",
"update" => "Changed",
"delete" => "Deleted",
"user_created" => "User created",
"user_updated" => "User updated",
"user_deleted" => "User deleted",
"group_created" => "Group created",
"group_updated" => "Group updated",
"group_deleted" => "Group deleted",
"password_changed_self" => "Password changed",
"password_reset_self_request" => "Reset link requested",
"password_reset_self_consume" => "Reset link consumed",
"password_reset_by_other" => "Password reset by admin",
"forced_password_change_completed" => "Forced password change",
"account_locked" => "Account locked",
"account_unlocked" => "Account unlocked",
"mfa_enabled" => "MFA enabled",
"mfa_disabled" => "MFA disabled",
"mfa_reset_by_other" => "MFA reset by admin",
"mfa_code_consumed" => "Backup code used",
"backup_codes_regenerated" => "Backup codes regenerated",
"sessions_revoked_self" => "Sessions revoked (self)",
"sessions_revoked_by_other" => "Sessions revoked by admin",
"session_logout" => "Logged out",
"login_succeeded" => "Signed in",
"login_failed" => "Failed sign-in",
"emergency_recovery" => "Emergency recovery",
_ => "Action",
}
}
fn action_pill_class(action_type: &str) -> &'static str {
match action_type {
"create" | "user_created" | "group_created" | "account_unlocked" | "mfa_enabled" => {
"badge-success"
}
"delete"
| "user_deleted"
| "group_deleted"
| "account_locked"
| "mfa_disabled"
| "mfa_reset_by_other"
| "sessions_revoked_by_other"
| "login_failed" => "badge-danger",
"password_reset_by_other" | "forced_password_change_completed" | "emergency_recovery" => {
"badge-warning"
}
_ => "badge-neutral",
}
}
pub(crate) fn relative_time(ts: chrono::DateTime<chrono::Utc>) -> String {
let now = chrono::Utc::now();
let delta = now - ts;
if delta.num_seconds() < 60 {
"just now".to_string()
} else if delta.num_minutes() < 60 {
format!("{}m ago", delta.num_minutes())
} else if delta.num_hours() < 24 {
format!("{}h ago", delta.num_hours())
} else if delta.num_days() < 30 {
format!("{}d ago", delta.num_days())
} else {
ts.format("%Y-%m-%d").to_string()
}
}
#[derive(Serialize)]
pub(crate) struct ListField {
pub name: String,
pub label: String,
pub kind: &'static str,
pub sort_active: &'static str,
pub sort_link: String,
}
#[derive(Serialize)]
pub(crate) struct ListCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub fields: Vec<ListField>,
pub rows: Vec<ListRowCtx>,
pub search_query: String,
pub filters: Vec<FilterGroupCtx>,
pub active_filter_count: usize,
pub active_filter_pairs: Vec<(String, String)>,
pub active_filter_pills: Vec<ActiveFilterPillCtx>,
pub clear_all_filters_link: String,
pub csv_export_url: String,
pub sort_options: Vec<SortOptionCtx>,
pub current_sort_label: String,
pub active_sort_field: Option<String>,
pub active_sort_dir: Option<&'static str>,
pub per_page_options: Vec<PerPageOptionCtx>,
pub current_per_page_label: String,
pub active_per_page_override: Option<usize>,
pub page: usize,
pub total_pages: usize,
pub per_page: usize,
pub total_rows: usize,
pub prev_page_link: Option<String>,
pub next_page_link: Option<String>,
pub page_items: Vec<PageItem>,
pub bulk_actions_enabled: bool,
pub bulk_action_buttons: Vec<BulkActionBtnCtx>,
pub saved_filters: Vec<SavedFilterBtnCtx>,
pub current_query_string: String,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct SavedFilterBtnCtx {
pub id: i64,
pub name: String,
pub apply_url: String,
pub delete_url: String,
pub is_current: bool,
}
#[derive(Serialize)]
pub(crate) struct BulkActionBtnCtx {
pub name: &'static str,
pub label: &'static str,
pub destructive: bool,
pub form_action: String,
}
#[derive(Serialize)]
pub(crate) struct ListRowCtx {
pub id: i64,
#[serde(flatten)]
pub values: HashMap<String, serde_json::Value>,
pub links: HashMap<String, String>,
pub highlights: HashMap<String, String>,
}
#[derive(Serialize)]
pub(crate) struct FilterGroupCtx {
pub field: String,
pub label: String,
#[serde(default = "default_filter_kind")]
pub kind: &'static str,
pub options: Vec<FilterOptionCtx>,
pub current: Option<String>,
#[serde(default)]
pub all_link: String,
#[serde(default)]
pub date_from_name: String,
#[serde(default)]
pub date_from_value: String,
#[serde(default)]
pub date_to_name: String,
#[serde(default)]
pub date_to_value: String,
#[serde(default)]
pub hidden_pairs: Vec<(String, String)>,
#[serde(default)]
pub has_active_range: bool,
#[serde(default)]
pub multi_selected: Vec<String>,
#[serde(default)]
pub fk_selected_id: String,
#[serde(default)]
pub fk_selected_label: String,
#[serde(default)]
pub fk_lookup_url: String,
#[serde(default)]
pub fk_target_label: String,
}
fn default_filter_kind() -> &'static str {
"chips"
}
#[derive(Serialize)]
pub(crate) struct FilterOptionCtx {
pub value: String,
pub label: String,
pub selected: bool,
#[serde(default)]
pub link: String,
}
#[derive(Serialize)]
pub(crate) struct SortOptionCtx {
pub label: String,
pub link: String,
pub is_active: bool,
}
#[derive(Serialize)]
pub(crate) struct PerPageOptionCtx {
pub value: usize,
pub label: String,
pub link: String,
pub is_active: bool,
}
#[derive(Serialize)]
pub(crate) struct ActiveFilterPillCtx {
pub label: String,
pub value_label: String,
pub remove_link: String,
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum PageItem {
Number {
number: usize,
link: String,
is_active: bool,
},
Ellipsis,
}
fn build_page_items(
current: usize,
total: usize,
build_link: impl Fn(usize) -> String,
) -> Vec<PageItem> {
if total <= 1 {
return Vec::new();
}
let mk = |n: usize| PageItem::Number {
number: n,
link: build_link(n),
is_active: n == current,
};
if total <= 7 {
return (1..=total).map(mk).collect();
}
let mut items: Vec<PageItem> = Vec::with_capacity(9);
items.push(mk(1));
if current > 3 {
items.push(PageItem::Ellipsis);
}
let start = current.saturating_sub(1).max(2);
let end = (current + 1).min(total - 1);
for n in start..=end {
items.push(mk(n));
}
if current + 2 < total {
items.push(PageItem::Ellipsis);
}
items.push(mk(total));
items
}
fn sort_direction_label(
field_type: super::types::FieldType,
dir: super::modeladmin::SortDir,
) -> &'static str {
use super::modeladmin::SortDir;
use super::types::FieldType::*;
match (field_type, dir) {
(DateTime | OptionalDateTime, SortDir::Desc) => "newest first",
(DateTime | OptionalDateTime, SortDir::Asc) => "oldest first",
(String | OptionalString | FilePath | OptionalFilePath, SortDir::Asc) => "A → Z",
(String | OptionalString | FilePath | OptionalFilePath, SortDir::Desc) => "Z → A",
(Bool, SortDir::Asc) => "off → on",
(Bool, SortDir::Desc) => "on → off",
(_, SortDir::Asc) => "ascending",
(_, SortDir::Desc) => "descending",
}
}
fn push_html_escaped(out: &mut String, c: char) {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
fn push_html_escaped_str(out: &mut String, s: &str) {
for c in s.chars() {
push_html_escaped(out, c);
}
}
fn highlight_search_match(text: &str, term: &str) -> Option<String> {
if term.is_empty() {
return None;
}
let text_lower: String = text.chars().map(|c| c.to_ascii_lowercase()).collect();
let term_lower: String = term.chars().map(|c| c.to_ascii_lowercase()).collect();
let term_bytes = term_lower.len();
let first = text_lower.find(&term_lower)?;
let mut out = String::with_capacity(text.len() + 16);
push_html_escaped_str(&mut out, &text[..first]);
let mut cursor = first;
loop {
out.push_str("<mark>");
push_html_escaped_str(&mut out, &text[cursor..cursor + term_bytes]);
out.push_str("</mark>");
cursor += term_bytes;
match text_lower[cursor..].find(&term_lower) {
Some(rel) => {
let next = cursor + rel;
push_html_escaped_str(&mut out, &text[cursor..next]);
cursor = next;
}
None => break,
}
}
push_html_escaped_str(&mut out, &text[cursor..]);
Some(out)
}
fn build_list_url(
admin_name: &str,
q: &str,
filters: &[(String, String)],
sort: Option<(&str, super::modeladmin::SortDir)>,
page: usize,
per_page: Option<usize>,
) -> String {
use super::modeladmin::SortDir;
let mut parts: Vec<String> = Vec::new();
if !q.is_empty() {
parts.push(format!("q={}", urlencoding::encode(q)));
}
for (field, value) in filters {
parts.push(format!(
"{}={}",
urlencoding::encode(field),
urlencoding::encode(value),
));
}
if let Some((col, dir)) = sort {
parts.push(format!("sort={}", urlencoding::encode(col)));
parts.push(
match dir {
SortDir::Asc => "dir=asc",
SortDir::Desc => "dir=desc",
}
.to_string(),
);
}
if page > 1 {
parts.push(format!("page={page}"));
}
if let Some(n) = per_page {
parts.push(format!("per_page={n}"));
}
if parts.is_empty() {
format!("/admin/{admin_name}")
} else {
format!("/admin/{}?{}", admin_name, parts.join("&"))
}
}
fn build_csv_export_url(
admin_name: &str,
q: &str,
filters: &[(String, String)],
sort: Option<(&str, super::modeladmin::SortDir)>,
) -> String {
use super::modeladmin::SortDir;
let mut parts: Vec<String> = Vec::new();
if !q.is_empty() {
parts.push(format!("q={}", urlencoding::encode(q)));
}
for (field, value) in filters {
parts.push(format!(
"{}={}",
urlencoding::encode(field),
urlencoding::encode(value),
));
}
if let Some((col, dir)) = sort {
parts.push(format!("sort={}", urlencoding::encode(col)));
parts.push(
match dir {
SortDir::Asc => "dir=asc",
SortDir::Desc => "dir=desc",
}
.to_string(),
);
}
if parts.is_empty() {
format!("/admin/{admin_name}/export.csv")
} else {
format!("/admin/{}/export.csv?{}", admin_name, parts.join("&"))
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn list_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
rows: Vec<ListRow>,
search_query: String,
mut filters: Vec<FilterGroupCtx>,
page: usize,
per_page: usize,
per_page_override: Option<usize>,
total_rows: usize,
active_sort: Option<(String, super::modeladmin::SortDir)>,
csrf_token: String,
saved_filters: Vec<super::saved_filters::SavedFilter>,
current_query_string: String,
) -> ListCtx {
let total_pages = total_rows.div_ceil(per_page.max(1)).max(1);
let active_filter_pairs: Vec<(String, String)> = filters
.iter()
.flat_map(|g| {
let mut pairs: Vec<(String, String)> = Vec::new();
if let Some(v) = g.current.as_ref() {
pairs.push((g.field.clone(), v.clone()));
}
if !g.date_from_value.is_empty() {
pairs.push((g.date_from_name.clone(), g.date_from_value.clone()));
}
if !g.date_to_value.is_empty() {
pairs.push((g.date_to_name.clone(), g.date_to_value.clone()));
}
for v in &g.multi_selected {
pairs.push((g.field.clone(), v.clone()));
}
pairs
})
.collect();
let active_sort_ref: Option<(&str, super::modeladmin::SortDir)> =
active_sort.as_ref().map(|(c, d)| (c.as_str(), *d));
for group in &mut filters {
let other: Vec<(String, String)> = active_filter_pairs
.iter()
.filter(|(field, _)| {
if group.kind == "date_range" {
field != &group.date_from_name && field != &group.date_to_name
} else {
field != &group.field
}
})
.cloned()
.collect();
group.all_link = build_list_url(
entry.admin_name,
&search_query,
&other,
active_sort_ref,
1,
per_page_override,
);
if group.kind == "chips" {
for opt in &mut group.options {
let mut combined = other.clone();
combined.push((group.field.clone(), opt.value.clone()));
opt.link = build_list_url(
entry.admin_name,
&search_query,
&combined,
active_sort_ref,
1,
per_page_override,
);
}
}
if group.kind == "date_range"
|| group.kind == "multi_select"
|| group.kind == "fk_autocomplete"
{
group.hidden_pairs = other;
}
}
let clear_all_filters_link = build_list_url(
entry.admin_name,
&search_query,
&[],
active_sort_ref,
1,
per_page_override,
);
let csv_export_url = build_csv_export_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
active_sort_ref,
);
let active_filter_pills: Vec<ActiveFilterPillCtx> = filters
.iter()
.filter_map(|g| {
if g.kind == "fk_autocomplete" {
if g.fk_selected_id.is_empty() {
return None;
}
let other: Vec<(String, String)> = active_filter_pairs
.iter()
.filter(|(field, _)| field != &g.field)
.cloned()
.collect();
return Some(ActiveFilterPillCtx {
label: g.label.clone(),
value_label: if g.fk_selected_label.is_empty() {
format!("#{}", g.fk_selected_id)
} else {
g.fk_selected_label.clone()
},
remove_link: build_list_url(
entry.admin_name,
&search_query,
&other,
active_sort_ref,
1,
per_page_override,
),
});
}
if g.kind == "multi_select" {
if g.multi_selected.is_empty() {
return None;
}
let value_label = g.multi_selected.join(", ");
let other: Vec<(String, String)> = active_filter_pairs
.iter()
.filter(|(field, _)| field != &g.field)
.cloned()
.collect();
return Some(ActiveFilterPillCtx {
label: g.label.clone(),
value_label,
remove_link: build_list_url(
entry.admin_name,
&search_query,
&other,
active_sort_ref,
1,
per_page_override,
),
});
}
if g.kind == "date_range" {
if !g.has_active_range {
return None;
}
let value_label = match (g.date_from_value.as_str(), g.date_to_value.as_str()) {
("", "") => return None,
("", to) => format!("≤ {to}"),
(from, "") => format!("≥ {from}"),
(from, to) => format!("{from} → {to}"),
};
let other: Vec<(String, String)> = active_filter_pairs
.iter()
.filter(|(field, _)| field != &g.date_from_name && field != &g.date_to_name)
.cloned()
.collect();
Some(ActiveFilterPillCtx {
label: g.label.clone(),
value_label,
remove_link: build_list_url(
entry.admin_name,
&search_query,
&other,
active_sort_ref,
1,
per_page_override,
),
})
} else {
let v = g.current.as_ref()?;
let value_label = g
.options
.iter()
.find(|o| &o.value == v)
.map(|o| o.label.clone())
.unwrap_or_else(|| v.clone());
let other: Vec<(String, String)> = active_filter_pairs
.iter()
.filter(|(field, _)| field != &g.field)
.cloned()
.collect();
Some(ActiveFilterPillCtx {
label: g.label.clone(),
value_label,
remove_link: build_list_url(
entry.admin_name,
&search_query,
&other,
active_sort_ref,
1,
per_page_override,
),
})
}
})
.collect();
let visible_fields: Vec<&AdminField> = if entry.list_display.is_empty() {
entry.fields.iter().collect()
} else {
entry
.list_display
.iter()
.filter_map(|name| entry.fields.iter().find(|f| f.name == *name))
.collect()
};
let fields: Vec<ListField> = visible_fields
.iter()
.map(|f| {
let (sort_active, sort_link) = build_sort_link(
f.name,
&active_sort,
entry.admin_name,
&search_query,
&active_filter_pairs,
per_page_override,
);
ListField {
name: f.name.to_string(),
label: f.label.to_string(),
kind: f.field_type.widget(),
sort_active,
sort_link,
}
})
.collect();
use super::modeladmin::SortDir;
let mut sort_options: Vec<SortOptionCtx> = Vec::with_capacity(visible_fields.len() * 2 + 1);
sort_options.push(SortOptionCtx {
label: "Default order".to_string(),
link: build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
None,
1,
per_page_override,
),
is_active: active_sort.is_none(),
});
for f in &visible_fields {
for dir in [SortDir::Asc, SortDir::Desc] {
let dir_label = sort_direction_label(f.field_type, dir);
let is_active = matches!(
&active_sort,
Some((col, d)) if col == f.name && *d == dir
);
sort_options.push(SortOptionCtx {
label: format!("{} ({})", f.label, dir_label),
link: build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
Some((f.name, dir)),
1,
per_page_override,
),
is_active,
});
}
}
let current_sort_label = sort_options
.iter()
.find(|o| o.is_active)
.map(|o| o.label.clone())
.unwrap_or_else(|| "Default order".to_string());
let prev_page_link = (page > 1).then(|| {
build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
active_sort_ref,
page - 1,
per_page_override,
)
});
let next_page_link = (page < total_pages).then(|| {
build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
active_sort_ref,
page + 1,
per_page_override,
)
});
let page_items = build_page_items(page, total_pages, |n| {
build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
active_sort_ref,
n,
per_page_override,
)
});
let (active_sort_field, active_sort_dir) = match &active_sort {
Some((col, SortDir::Asc)) => (Some(col.clone()), Some("asc")),
Some((col, SortDir::Desc)) => (Some(col.clone()), Some("desc")),
None => (None, None),
};
let per_page_choices: [usize; 4] = [25, 50, 100, 200];
let model_default_per_page = entry.list_per_page;
let per_page_options: Vec<PerPageOptionCtx> = per_page_choices
.iter()
.map(|&n| {
let override_for_link = (n != model_default_per_page).then_some(n);
PerPageOptionCtx {
value: n,
label: format!("{n} / page"),
link: build_list_url(
entry.admin_name,
&search_query,
&active_filter_pairs,
active_sort_ref,
1,
override_for_link,
),
is_active: per_page == n,
}
})
.collect();
let current_per_page_label = format!("{per_page} / page");
let field_names: Vec<&'static str> = entry.fields.iter().map(|f| f.name).collect();
let field_types: Vec<crate::admin::FieldType> =
entry.fields.iter().map(|f| f.field_type).collect();
let search_term_trimmed = search_query.trim();
let search_term_for_highlight: Option<String> = if search_term_trimmed.is_empty() {
None
} else {
Some(search_term_trimmed.to_string())
};
let searched_columns: std::collections::HashSet<&str> =
entry.search_fields.iter().copied().collect();
ListCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: entry.display_name.to_string(),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
display_name: entry.display_name,
singular_name: entry.singular_name,
fields,
rows: rows
.into_iter()
.map(|r| {
let mut values: HashMap<String, serde_json::Value> =
HashMap::with_capacity(field_names.len().saturating_sub(1));
let mut links: HashMap<String, String> = HashMap::new();
let mut highlights: HashMap<String, String> = HashMap::new();
let cell_links = r.cell_links;
for (i, cell) in r.cells.into_iter().enumerate() {
if let Some(name) = field_names.get(i) {
if *name == "id" {
continue;
}
if let Some(term) = &search_term_for_highlight {
let is_bool =
matches!(field_types.get(i), Some(crate::admin::FieldType::Bool));
if !is_bool && searched_columns.contains(*name) {
if let Some(html) = highlight_search_match(&cell, term) {
highlights.insert((*name).to_string(), html);
}
}
}
let typed = match field_types.get(i) {
Some(crate::admin::FieldType::Bool) => {
serde_json::Value::Bool(cell == "true")
}
_ => serde_json::Value::String(cell),
};
values.insert((*name).to_string(), typed);
if let Some(Some(link)) = cell_links.get(i) {
links.insert(
(*name).to_string(),
format!("/admin/{}/{}/edit", link.admin_name, link.id),
);
}
}
}
ListRowCtx {
id: r.id,
values,
links,
highlights,
}
})
.collect(),
search_query,
active_filter_count: filters
.iter()
.filter(|g| g.current.is_some() || g.has_active_range || !g.multi_selected.is_empty())
.count(),
active_filter_pairs,
active_filter_pills,
clear_all_filters_link,
csv_export_url,
filters,
sort_options,
current_sort_label,
active_sort_field,
active_sort_dir,
per_page_options,
current_per_page_label,
active_per_page_override: per_page_override,
page,
total_pages,
per_page,
total_rows,
prev_page_link,
next_page_link,
page_items,
bulk_actions_enabled: false,
bulk_action_buttons: entry
.bulk_actions
.iter()
.map(|a| BulkActionBtnCtx {
name: a.name,
label: a.label,
destructive: a.destructive,
form_action: format!("/admin/{}/bulk/{}", entry.admin_name, a.name),
})
.collect(),
saved_filters: saved_filters
.into_iter()
.map(|sf| {
let apply_url = if sf.query_string.is_empty() {
format!("/admin/{}", entry.admin_name)
} else {
format!("/admin/{}?{}", entry.admin_name, sf.query_string)
};
let is_current = sf.query_string == current_query_string;
SavedFilterBtnCtx {
delete_url: format!(
"/admin/{}/saved_filters/{}/delete",
entry.admin_name, sf.id
),
apply_url,
name: sf.name,
is_current,
id: sf.id,
}
})
.collect(),
current_query_string,
flash: None,
}
}
fn build_sort_link(
name: &'static str,
active: &Option<(String, super::modeladmin::SortDir)>,
admin_name: &str,
q: &str,
filters: &[(String, String)],
per_page: Option<usize>,
) -> (&'static str, String) {
use super::modeladmin::SortDir;
let (marker, new_sort) = match active {
Some((col, SortDir::Asc)) if col == name => ("asc", Some((name, SortDir::Desc))),
Some((col, SortDir::Desc)) if col == name => ("desc", None),
_ => ("", Some((name, SortDir::Asc))),
};
(
marker,
build_list_url(admin_name, q, filters, new_sort, 1, per_page),
)
}
#[derive(Serialize)]
pub(crate) struct FormCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub mode: &'static str, pub object_id: Option<i64>,
pub sections: Vec<FormSection>,
pub errors: Vec<String>,
pub inlines: Vec<FormInlineCtx>,
pub has_file_field: bool,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct FormInlineCtx {
pub label: String,
pub target_display_name: String,
pub target_admin_name: String,
pub rows: Vec<FormInlineRowCtx>,
pub has_more: bool,
pub total: i64,
pub add_url: String,
pub list_url: String,
}
#[derive(Serialize)]
pub(crate) struct FormInlineRowCtx {
pub id: i64,
pub label: String,
pub edit_url: String,
pub delete_url: String,
}
#[derive(Serialize, Clone)]
pub(crate) struct SelectOption {
pub value: String,
pub label: String,
}
#[derive(Serialize)]
pub(crate) struct FormField {
pub name: &'static str,
pub label: String,
pub widget: &'static str,
pub input_type: &'static str,
pub value: String,
pub hint: Option<String>,
pub placeholder: Option<String>,
pub required: bool,
pub options: Option<Vec<SelectOption>>,
pub multiple: bool,
pub span: u8,
pub autocomplete: Option<&'static str>,
pub autofocus: bool,
pub disabled: bool,
pub maxlength: Option<u16>,
pub searchable: bool,
pub has_more: bool,
pub search_url: Option<String>,
pub errors: Vec<String>,
pub target_model: Option<String>,
pub checked: bool,
}
#[derive(Serialize)]
pub(crate) struct FormSection {
pub title: Option<&'static str>,
pub fields: Vec<FormField>,
}
fn humanise_field(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut out = String::with_capacity(s.len());
let mut first_segment = true;
for segment in s.split('_') {
if !first_segment {
out.push(' ');
}
first_segment = false;
let lower = segment.to_ascii_lowercase();
if HUMANISE_ACRONYMS.contains(&lower.as_str()) {
out.push_str(&lower.to_ascii_uppercase());
} else {
let mut chars = segment.chars();
if let Some(first) = chars.next() {
out.push(first.to_ascii_uppercase());
for c in chars {
out.push(c);
}
}
}
}
out
}
pub(crate) const HUMANISE_ACRONYMS: &[&str] = &[
"id", "ip", "url", "uri", "api", "uuid", "mfa", "csv", "sql", "html", "http", "https", "json",
"tls", "ssl", "smtp", "xml",
];
pub(crate) fn bucket_errors_by_label(
entry: &AdminEntry,
errors: Vec<String>,
) -> (Vec<String>, HashMap<String, Vec<String>>) {
let labels: Vec<(&'static str, String)> = entry
.fields
.iter()
.filter(|f| f.editable)
.map(|f| (f.name, format!("{} ", humanise_field(f.name))))
.collect();
let mut global: Vec<String> = Vec::new();
let mut per_field: HashMap<String, Vec<String>> = HashMap::new();
'outer: for err in errors {
for (name, prefix) in &labels {
if err.starts_with(prefix.as_str()) {
per_field.entry((*name).to_string()).or_default().push(err);
continue 'outer;
}
}
global.push(err);
}
(global, per_field)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn form_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
mode: &'static str,
object_id: Option<i64>,
existing: Option<&EditRow>,
errors: Vec<String>,
csrf_token: String,
relation_options: HashMap<&'static str, (Vec<SelectOption>, bool)>,
field_errors: HashMap<String, Vec<String>>,
submitted: Option<&FormData>,
inlines: Vec<FormInlineCtx>,
) -> FormCtx {
let fields = entry
.fields
.iter()
.filter(|f| f.editable)
.map(|f| {
let readonly = mode == "edit" && entry.readonly_fields.contains(&f.name);
let value = if let Some(form) = submitted.filter(|_| !readonly) {
form.get(f.name).map(str::to_string).unwrap_or_default()
} else {
existing
.and_then(|row| {
row.values
.iter()
.find(|(col, _)| col == f.name)
.map(|(_, v)| v.clone())
})
.unwrap_or_default()
};
let ui = super::filters::field_ui_metadata(f);
let (base_widget, input_type) = map_field_to_ui(f);
let widget = if base_widget == "input"
&& matches!(
f.field_type,
super::types::FieldType::String | super::types::FieldType::OptionalString
)
&& is_long_text_name(f.name)
{
"textarea"
} else {
base_widget
};
let required = !readonly
&& !f.field_type.nullable()
&& !matches!(f.field_type, super::types::FieldType::Bool);
let (options, multiple, searchable, has_more) = if let Some(values) = f.choices {
let mut opts: Vec<SelectOption> = Vec::with_capacity(values.len() + 1);
if f.field_type.nullable() {
opts.push(SelectOption {
value: String::new(),
label: "—".to_string(),
});
}
opts.extend(values.iter().map(|v| SelectOption {
value: (*v).to_string(),
label: (*v).to_string(),
}));
(Some(opts), false, false, false)
} else if let Some(rel) = &f.relation {
let (opts, has_more) = relation_options.get(f.name).cloned().unwrap_or_default();
(Some(opts), rel.multi, true, has_more)
} else {
(None, false, false, false)
};
let span: u8 = if widget == "textarea" { 2 } else { 1 };
let search_url = f
.relation
.as_ref()
.map(|rel| format!("/admin/search/{}", rel.target_model));
let target_model = f.relation.as_ref().map(|rel| rel.target_model.to_string());
let checked = matches!(value.as_str(), "on" | "true" | "1" | "yes");
let placeholder = if let Some(rel) = &f.relation {
Some(format!("Select {}…", rel.target_model))
} else {
ui.placeholder
};
FormField {
name: f.name,
label: ui.label,
widget,
input_type,
value,
hint: ui.hint,
placeholder,
required,
options,
multiple,
span,
autocomplete: None,
autofocus: false,
disabled: readonly,
maxlength: None,
searchable,
has_more,
search_url,
errors: field_errors.get(f.name).cloned().unwrap_or_default(),
target_model,
checked,
}
})
.collect::<Vec<FormField>>();
let sections = if entry.fieldsets.is_empty() {
group_fields_into_sections(fields)
} else {
group_fields_by_fieldsets(fields, entry.fieldsets)
};
FormCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: match mode {
"new" => format!("Add {}", entry.singular_name),
_ => format!("Change {}", entry.singular_name),
},
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
display_name: entry.display_name,
singular_name: entry.singular_name,
mode,
object_id,
sections,
errors,
inlines,
has_file_field: entry.fields.iter().any(|f| {
matches!(
f.field_type,
crate::admin::FieldType::FilePath | crate::admin::FieldType::OptionalFilePath
)
}),
flash: None,
}
}
pub(crate) fn apply_field_errors(
sections: &mut [FormSection],
field_errors: &HashMap<String, Vec<String>>,
) {
for section in sections.iter_mut() {
for field in section.fields.iter_mut() {
if let Some(errs) = field_errors.get(field.name) {
field.errors = errs.clone();
}
}
}
}
fn group_fields_by_fieldsets(
mut fields: Vec<FormField>,
fieldsets: &'static [super::modeladmin::Fieldset],
) -> Vec<FormSection> {
let mut sections: Vec<FormSection> = Vec::with_capacity(fieldsets.len() + 1);
for fs in fieldsets {
let mut grouped = Vec::with_capacity(fs.fields.len());
for &name in fs.fields {
if let Some(pos) = fields.iter().position(|f| f.name == name) {
grouped.push(fields.remove(pos));
}
}
if !grouped.is_empty() {
sections.push(FormSection {
title: Some(fs.title),
fields: grouped,
});
}
}
if !fields.is_empty() {
sections.push(FormSection {
title: Some("Other"),
fields,
});
}
sections
}
fn group_fields_into_sections(fields: Vec<FormField>) -> Vec<FormSection> {
let mut default_fields = Vec::new();
let mut metadata_fields = Vec::new();
let mut advanced_fields = Vec::new();
for field in fields {
match classify_field_section(field.name) {
FieldSection::Default => default_fields.push(field),
FieldSection::Metadata => metadata_fields.push(field),
FieldSection::Advanced => advanced_fields.push(field),
}
}
let mut sections: Vec<FormSection> = Vec::with_capacity(3);
if !default_fields.is_empty() {
sections.push(FormSection {
title: None,
fields: default_fields,
});
}
if !metadata_fields.is_empty() {
sections.push(FormSection {
title: Some("System"),
fields: metadata_fields,
});
}
if !advanced_fields.is_empty() {
sections.push(FormSection {
title: Some("Advanced"),
fields: advanced_fields,
});
}
sections
}
enum FieldSection {
Default,
Metadata,
Advanced,
}
fn classify_field_section(name: &str) -> FieldSection {
if name.contains("created") || name.contains("updated") || name.contains("timestamp") {
FieldSection::Metadata
} else if matches!(name, "id" | "uuid" | "slug") {
FieldSection::Advanced
} else {
FieldSection::Default
}
}
fn is_long_text_name(name: &str) -> bool {
matches!(
name,
"body" | "description" | "notes" | "content" | "summary" | "bio" | "details"
)
}
fn map_field_to_ui(field: &super::types::AdminField) -> (&'static str, &'static str) {
if field.choices.is_some() {
return ("select", "select");
}
if let Some(rel) = &field.relation {
if rel.multi {
return ("select", "select-multiple");
}
return ("select", "select");
}
use super::types::FieldType::*;
match field.field_type {
Bool => ("checkbox", "checkbox"),
I32 | I64 | OptionalI64 => ("input", "number"),
DateTime | OptionalDateTime => ("input", "datetime-local"),
FilePath | OptionalFilePath => ("file", "file"),
String | OptionalString => ("input", "text"),
}
}
pub(crate) const FK_OPTIONS_LIMIT: usize = 50;
pub(crate) async fn resolve_relation_options(
admin: &Admin,
entry: &AdminEntry,
db: &Db,
) -> Result<HashMap<&'static str, (Vec<SelectOption>, bool)>> {
let mut out: HashMap<&'static str, (Vec<SelectOption>, bool)> = HashMap::new();
for f in entry.fields.iter() {
let Some(rel) = &f.relation else {
continue;
};
let target = admin.entries().iter().find(|e| {
e.singular_name == rel.target_model
|| e.admin_name == rel.target_model
|| e.display_name == rel.target_model
});
let Some(target) = target else {
out.insert(f.name, (Vec::new(), false));
continue;
};
let page = target
.ops
.list(
db,
super::types::ListOpts {
limit: Some(FK_OPTIONS_LIMIT as i64),
..super::types::ListOpts::default()
},
)
.await?;
let display_idx = pick_display_index(target.fields, rel.display_field);
let opts: Vec<SelectOption> = page
.rows
.into_iter()
.map(|r| {
let label = display_idx
.and_then(|i| r.cells.get(i).cloned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| r.id.to_string());
SelectOption {
value: r.id.to_string(),
label,
}
})
.collect();
let has_more = page.total > FK_OPTIONS_LIMIT as i64;
out.insert(f.name, (opts, has_more));
}
Ok(out)
}
pub(crate) fn pick_display_index(
fields: &[AdminField],
display_field: Option<&str>,
) -> Option<usize> {
if let Some(preferred) = display_field {
if let Some(i) = fields.iter().position(|f| f.name == preferred) {
return Some(i);
}
}
for fallback in ["name", "title", "full_name", "email"] {
if let Some(i) = fields.iter().position(|f| f.name == fallback) {
return Some(i);
}
}
None
}
#[derive(Serialize)]
pub(crate) struct ConfirmDeleteCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub object_id: i64,
pub object_label: String,
pub cascading: Vec<CascadeItem>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct CascadeItem {
pub source_display_name: String,
pub source_admin_name: String,
pub source_field: String,
}
pub(crate) fn confirm_delete_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
object_id: i64,
object_label: String,
cascading: Vec<CascadeItem>,
csrf_token: String,
) -> ConfirmDeleteCtx {
ConfirmDeleteCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: format!("Delete {}", entry.singular_name),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
display_name: entry.display_name,
singular_name: entry.singular_name,
object_id,
object_label,
cascading,
flash: None,
}
}
#[derive(Serialize)]
pub(crate) struct BulkConfirmDeleteCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub items: Vec<BulkDeleteItem>,
pub ids_csv: String,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct BulkDeleteItem {
pub id: i64,
pub label: String,
}
#[derive(Serialize)]
pub(crate) struct BulkConfirmActionCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: &'static str,
pub display_name: &'static str,
pub singular_name: &'static str,
pub action_name: &'static str,
pub action_label: &'static str,
pub action_destructive: bool,
pub items: Vec<BulkDeleteItem>,
pub ids_csv: String,
pub flash: Option<FlashCtx>,
}
pub(crate) fn bulk_confirm_action_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
action: super::modeladmin::BulkAction,
items: Vec<BulkDeleteItem>,
csrf_token: String,
) -> BulkConfirmActionCtx {
let ids_csv = items
.iter()
.map(|i| i.id.to_string())
.collect::<Vec<_>>()
.join(",");
BulkConfirmActionCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: format!("{} — {} {}", action.label, items.len(), entry.display_name),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
display_name: entry.display_name,
singular_name: entry.singular_name,
action_name: action.name,
action_label: action.label,
action_destructive: action.destructive,
items,
ids_csv,
flash: None,
}
}
pub(crate) fn bulk_confirm_delete_ctx(
identity: &Identity,
admin: &Admin,
entry: &AdminEntry,
items: Vec<BulkDeleteItem>,
csrf_token: String,
) -> BulkConfirmDeleteCtx {
let ids_csv = items
.iter()
.map(|i| i.id.to_string())
.collect::<Vec<_>>()
.join(",");
BulkConfirmDeleteCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: format!("Delete {} {}", items.len(), entry.display_name),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
admin_name: entry.admin_name,
display_name: entry.display_name,
singular_name: entry.singular_name,
items,
ids_csv,
flash: None,
}
}
#[derive(Serialize)]
pub(crate) struct ForbiddenCtx {
#[serde(flatten)]
pub base: BaseContext,
pub entries: Vec<SidebarEntry>,
pub page_title: &'static str,
pub attempted: Option<String>,
pub required_role: Option<&'static str>,
}
#[derive(Serialize)]
pub(crate) struct ErrorCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub status: u16,
pub heading: String,
pub message: String,
pub entries: Vec<SidebarEntry>,
}
pub(crate) fn admin_error_heading(status: u16) -> &'static str {
match status {
400 => "Bad request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not found",
405 => "Method not allowed",
409 => "Conflict",
500 => "Server error",
_ => "Error",
}
}
pub(crate) fn render_admin_error_response(
admin: &Admin,
templates: &crate::templates::Templates,
identity: Option<&Identity>,
status: u16,
message: String,
) -> crate::http::Response {
let heading = admin_error_heading(status).to_string();
let sidebar_entries: Vec<SidebarEntry> = admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect();
let view = ErrorCtx {
base: BaseContext::new(identity, String::new(), admin),
page_title: format!("{status} {heading}"),
status,
heading: heading.clone(),
message,
entries: sidebar_entries,
};
let html_status =
hyper::StatusCode::from_u16(status).unwrap_or(hyper::StatusCode::INTERNAL_SERVER_ERROR);
match templates.render("admin/error.html", &view) {
Ok(body) => crate::http::Response::html(body).with_status(html_status),
Err(e) => {
log::error!("admin/error.html render failed: {e}");
crate::http::Response::text(format!("{status} {heading}: {}", view.message))
.with_status(html_status)
}
}
}
pub(crate) fn render_forbidden_body(
admin: &Admin,
templates: &crate::templates::Templates,
identity: &Identity,
csrf_token: String,
attempted: Option<String>,
required_role: Option<&'static str>,
) -> crate::error::Result<String> {
let view = ForbiddenCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
page_title: "Permission denied",
attempted,
required_role,
};
templates.render("admin/forbidden.html", &view)
}
#[derive(Serialize)]
pub(crate) struct HistoryEntryCtx {
pub timestamp_iso: String,
pub when_relative: String,
pub date_iso: String,
pub is_new_day: bool,
pub user_id: i64,
pub user_email: String,
pub action_type: String,
pub label: &'static str,
pub pill_class: &'static str,
pub model_name: String,
pub model_admin_name: String,
pub object_id: i64,
pub summary: String,
pub ip_address: String,
pub changes: Vec<HistoryChangeCtx>,
}
#[derive(Serialize)]
pub(crate) struct ApisIndexCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<SidebarEntry>,
pub apis: Vec<ApiEntryCtx>,
pub openapi_url: &'static str,
}
#[derive(Serialize)]
pub(crate) struct ApiEntryCtx {
pub admin_name: &'static str,
pub singular_name: &'static str,
pub display_name: &'static str,
pub list_path: String,
pub detail_path: String,
pub field_count: usize,
pub fields: Vec<ApiFieldCtx>,
}
#[derive(Serialize)]
pub(crate) struct ApiFieldCtx {
pub name: &'static str,
pub type_label: &'static str,
pub nullable: bool,
}
pub(crate) fn api_field_type_label(field: &AdminField) -> &'static str {
use crate::admin::types::FieldType::*;
match field.field_type {
Bool => "boolean",
I32 => "integer (i32)",
I64 | OptionalI64 => "integer (i64)",
String | OptionalString => "string",
DateTime | OptionalDateTime => "date-time",
FilePath | OptionalFilePath => "file path",
}
}
#[derive(Serialize)]
pub(crate) struct DocsIndexCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<SidebarEntry>,
pub docs: Vec<DocSummaryCtx>,
}
#[derive(Serialize)]
pub(crate) struct DocSummaryCtx {
pub slug: &'static str,
pub title: &'static str,
}
#[derive(Serialize)]
pub(crate) struct DocPageCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub doc_title: &'static str,
pub body_html: String,
}
pub(crate) fn docs_index_ctx(
identity: &Identity,
admin: &Admin,
csrf_token: String,
) -> DocsIndexCtx {
DocsIndexCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: "Framework docs",
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
docs: crate::admin::docs::EMBEDDED_DOCS
.iter()
.map(|d| DocSummaryCtx {
slug: d.slug,
title: d.title,
})
.collect(),
}
}
pub(crate) fn doc_page_ctx(
identity: &Identity,
admin: &Admin,
csrf_token: String,
doc: &crate::admin::docs::EmbeddedDoc,
) -> DocPageCtx {
DocPageCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: format!("Docs — {}", doc.title),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
doc_title: doc.title,
body_html: crate::admin::docs::render_markdown(doc.source),
}
}
#[derive(Serialize)]
pub(crate) struct CsvImportResultCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub entries: Vec<SidebarEntry>,
pub admin_name: String,
pub display_name: String,
pub total: usize,
pub inserted: usize,
pub failed: usize,
pub outcomes: Vec<CsvOutcomeCtx>,
}
#[derive(Serialize)]
pub(crate) struct CsvOutcomeCtx {
pub row_number: usize,
pub status: &'static str, pub id: Option<i64>,
pub errors: Vec<String>,
}
pub(crate) fn csv_import_result_ctx(
identity: &Identity,
admin: &Admin,
csrf_token: String,
entry: &AdminEntry,
report: crate::admin::csv_import::ImportReport,
) -> CsvImportResultCtx {
use crate::admin::csv_import::RowOutcome;
let outcomes = report
.outcomes
.into_iter()
.map(|o| match o {
RowOutcome::Inserted { row_number, id } => CsvOutcomeCtx {
row_number,
status: "inserted",
id: Some(id),
errors: Vec::new(),
},
RowOutcome::Failed { row_number, errors } => CsvOutcomeCtx {
row_number,
status: "failed",
id: None,
errors,
},
})
.collect();
CsvImportResultCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: format!("CSV import — {}", entry.display_name),
admin_name: entry.admin_name.to_string(),
display_name: entry.display_name.to_string(),
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
total: report.total,
inserted: report.inserted,
failed: report.failed,
outcomes,
}
}
#[derive(Serialize)]
pub(crate) struct NotificationsCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<SidebarEntry>,
pub notifications: Vec<NotificationRowCtx>,
pub unread_count: i64,
}
#[derive(Serialize)]
pub(crate) struct NotificationRowCtx {
pub id: i64,
pub message: String,
pub url: String,
pub unread: bool,
pub when_relative: String,
pub created_iso: String,
}
pub(crate) fn notifications_ctx(
identity: &Identity,
admin: &Admin,
csrf_token: String,
notifications: Vec<crate::admin::notifications::Notification>,
) -> NotificationsCtx {
let unread_count = notifications.iter().filter(|n| n.read_at.is_none()).count() as i64;
let rows = notifications
.into_iter()
.map(|n| NotificationRowCtx {
id: n.id,
message: n.message,
url: n.url,
unread: n.read_at.is_none(),
when_relative: relative_time(n.created_at),
created_iso: n.created_at.to_rfc3339(),
})
.collect();
NotificationsCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: "Notifications",
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
notifications: rows,
unread_count,
}
}
#[derive(Serialize)]
pub(crate) struct FeatureFlagsCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<SidebarEntry>,
pub flags: Vec<FeatureFlagCtx>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct FeatureFlagCtx {
pub key: String,
pub enabled: bool,
pub description: String,
pub created_iso: String,
pub updated_iso: String,
}
pub(crate) fn feature_flags_ctx(
identity: &Identity,
admin: &Admin,
csrf_token: String,
flags: Vec<crate::admin::feature_flags::FeatureFlag>,
flash: Option<FlashCtx>,
) -> FeatureFlagsCtx {
FeatureFlagsCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: "Feature flags",
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
flags: flags
.into_iter()
.map(|f| FeatureFlagCtx {
key: f.key,
enabled: f.enabled,
description: f.description,
created_iso: f.created_at.to_rfc3339(),
updated_iso: f.updated_at.to_rfc3339(),
})
.collect(),
flash,
}
}
#[derive(Serialize)]
pub(crate) struct HealthCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<SidebarEntry>,
pub checks: Vec<HealthCheckCtx>,
pub all_ok: bool,
pub ok_count: usize,
pub warn_count: usize,
pub error_count: usize,
}
#[derive(Serialize)]
pub(crate) struct HealthCheckCtx {
pub label: &'static str,
pub status: &'static str,
pub message: String,
}
pub(crate) fn health_ctx(
identity: &Identity,
admin: &Admin,
csrf_token: String,
checks: Vec<crate::admin::health_dashboard::HealthCheck>,
) -> HealthCtx {
use crate::admin::health_dashboard::HealthStatus;
let mut ok_count = 0;
let mut warn_count = 0;
let mut error_count = 0;
for c in &checks {
match c.status {
HealthStatus::Ok => ok_count += 1,
HealthStatus::Warn => warn_count += 1,
HealthStatus::Error => error_count += 1,
}
}
let all_ok = warn_count == 0 && error_count == 0;
let ctx_checks: Vec<HealthCheckCtx> = checks
.into_iter()
.map(|c| HealthCheckCtx {
label: c.label,
status: c.status.as_str(),
message: c.message,
})
.collect();
HealthCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: "Health",
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
checks: ctx_checks,
all_ok,
ok_count,
warn_count,
error_count,
}
}
#[derive(Serialize)]
pub(crate) struct PlaygroundCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<SidebarEntry>,
pub models: Vec<PlaygroundModelCtx>,
}
#[derive(Serialize)]
pub(crate) struct PlaygroundModelCtx {
pub admin_name: &'static str,
pub display_name: &'static str,
}
pub(crate) fn playground_ctx(
identity: &Identity,
admin: &Admin,
csrf_token: String,
) -> PlaygroundCtx {
PlaygroundCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: "API playground",
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
models: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(|e| PlaygroundModelCtx {
admin_name: e.admin_name,
display_name: e.display_name,
})
.collect(),
}
}
pub(crate) fn apis_index_ctx(
identity: &Identity,
admin: &Admin,
csrf_token: String,
) -> ApisIndexCtx {
let apis: Vec<ApiEntryCtx> = admin
.entries()
.iter()
.filter(|e| !e.core)
.map(|e| ApiEntryCtx {
admin_name: e.admin_name,
singular_name: e.singular_name,
display_name: e.display_name,
list_path: format!("/admin/{}", e.admin_name),
detail_path: format!("/admin/{}/{{id}}", e.admin_name),
field_count: e.fields.len(),
fields: e
.fields
.iter()
.map(|f| ApiFieldCtx {
name: f.name,
type_label: api_field_type_label(f),
nullable: f.field_type.nullable(),
})
.collect(),
})
.collect();
ApisIndexCtx {
base: BaseContext::new(Some(identity), csrf_token, admin),
page_title: "API surface",
entries: admin
.entries()
.iter()
.filter(|e| !e.core)
.map(SidebarEntry::from)
.collect(),
apis,
openapi_url: "/admin/apis/openapi.json",
}
}
#[derive(Serialize, Clone)]
pub(crate) struct HistoryChangeCtx {
pub field: String,
pub label: String,
pub from: String,
pub to: String,
}
#[derive(Serialize)]
pub(crate) struct ObjectHistoryCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: String,
pub admin_name: String,
pub display_name: String,
pub singular_name: String,
pub object_id: i64,
pub object_label: String,
pub entries: Vec<SidebarEntry>,
pub history_entries: Vec<HistoryEntryCtx>,
pub flash: Option<FlashCtx>,
}
#[derive(Serialize)]
pub(crate) struct LogEntriesCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub entries: Vec<SidebarEntry>,
pub history_entries: Vec<HistoryEntryCtx>,
pub flash: Option<FlashCtx>,
pub user_filter_label: Option<String>,
}
pub(crate) fn map_audit_actions(actions: Vec<AdminAction>) -> Vec<HistoryEntryCtx> {
let mut prev_date_iso: Option<String> = None;
actions
.into_iter()
.map(|a| {
let changes = extract_changes_from_metadata(a.metadata.as_ref());
let date_iso = a.timestamp.format("%Y-%m-%d").to_string();
let is_new_day = prev_date_iso.as_deref() != Some(date_iso.as_str());
prev_date_iso = Some(date_iso.clone());
HistoryEntryCtx {
timestamp_iso: a.timestamp.to_rfc3339(),
when_relative: relative_time(a.timestamp),
date_iso,
is_new_day,
user_id: a.user_id,
user_email: a.user_email.unwrap_or_else(|| "—".to_string()),
label: action_label(&a.action_type),
pill_class: action_pill_class(&a.action_type),
model_name: a.model_name.clone(),
model_admin_name: a.model_name,
action_type: a.action_type,
object_id: a.object_id,
summary: a.summary,
ip_address: a.ip_address.unwrap_or_default(),
changes,
}
})
.collect()
}
fn extract_changes_from_metadata(metadata: Option<&serde_json::Value>) -> Vec<HistoryChangeCtx> {
let Some(obj) = metadata.and_then(|m| m.as_object()) else {
return Vec::new();
};
let Some(arr) = obj.get("changes").and_then(|v| v.as_array()) else {
return Vec::new();
};
arr.iter()
.filter_map(|entry| {
let o = entry.as_object()?;
let field = o.get("field")?.as_str()?.to_string();
let label = o
.get("label")
.and_then(|v| v.as_str())
.unwrap_or(&field)
.to_string();
let from = o
.get("from")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let to = o
.get("to")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(HistoryChangeCtx {
field,
label,
from,
to,
})
})
.collect()
}
#[derive(Serialize)]
pub(crate) struct PasswordChangeCtx {
#[serde(flatten)]
pub base: BaseContext,
pub page_title: &'static str,
pub errors: Vec<String>,
pub success: bool,
pub sections: Vec<FormSection>,
}
pub(crate) fn role_select_options(editor_rank: u32) -> Vec<SelectOption> {
let all = [
(crate::auth::Role::User, "user", "User (no admin access)"),
(
crate::auth::Role::Staff,
"staff",
"Staff (admin access; per-model group permissions)",
),
(
crate::auth::Role::Supervisor,
"supervisor",
"Supervisor (view + edit; no destructive ops)",
),
(
crate::auth::Role::Administrator,
"administrator",
"Administrator (full coverage; bypasses group checks)",
),
(
crate::auth::Role::Developer,
"developer",
"Developer (highest tier)",
),
];
all.iter()
.filter(|(role, _, _)| role.rank() <= editor_rank)
.map(|(_, slug, label)| SelectOption {
value: (*slug).to_string(),
label: (*label).to_string(),
})
.collect()
}
pub(crate) fn user_new_form_sections(
email: &str,
role: &str,
editor_rank: u32,
min_length: usize,
) -> Vec<FormSection> {
let password_hint = format!(
"At least {min_length} characters. The user can change it later via Change password."
);
vec![
FormSection {
title: Some("Identity"),
fields: vec![
FormField {
name: "email",
label: "Email".to_string(),
widget: "input",
input_type: "email",
value: email.to_string(),
hint: Some("Must be unique across all users.".to_string()),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("off"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "password",
label: "Password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: Some(password_hint),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
},
FormSection {
title: Some("Role"),
fields: vec![FormField {
name: "role",
label: "Role".to_string(),
widget: "select",
input_type: "select",
value: role.to_string(),
hint: Some(
"Higher roles include all lower-role capabilities. Group memberships are assigned on the next page after save."
.to_string(),
),
placeholder: None,
required: true,
options: Some(role_select_options(editor_rank)),
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
}],
},
]
}
pub(crate) fn group_form_sections(name: &str, description: &str) -> Vec<FormSection> {
vec![FormSection {
title: Some("General"),
fields: vec![
FormField {
name: "name",
label: "Name".to_string(),
widget: "input",
input_type: "text",
value: name.to_string(),
hint: Some(
"A short identifier — letters, digits, dots and dashes only. Example: editors."
.to_string(),
),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("off"),
autofocus: true,
disabled: false,
maxlength: Some(150),
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "description",
label: "Description".to_string(),
widget: "textarea",
input_type: "text",
value: description.to_string(),
hint: Some("Optional. What this group is for.".to_string()),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}
pub(crate) fn user_edit_identity_sections(
email: &str,
role: &str,
is_active: bool,
editor_rank: u32,
) -> Vec<FormSection> {
vec![FormSection {
title: Some("Identity"),
fields: vec![
FormField {
name: "email",
label: "Email".to_string(),
widget: "input",
input_type: "email",
value: email.to_string(),
hint: Some(
"Email changes aren't exposed here — they require a full user update."
.to_string(),
),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: true,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "role",
label: "Role".to_string(),
widget: "select",
input_type: "select",
value: role.to_string(),
hint: None,
placeholder: None,
required: true,
options: Some(role_select_options(editor_rank)),
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "is_active",
label: "Active".to_string(),
widget: "checkbox",
input_type: "checkbox",
value: if is_active {
"true".to_string()
} else {
"false".to_string()
},
hint: Some("Inactive users cannot sign in or hold sessions.".to_string()),
placeholder: None,
required: false,
options: None,
multiple: false,
span: 2,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: is_active,
},
],
}]
}
pub(crate) fn password_change_form_sections(min_length: usize) -> Vec<FormSection> {
let new_password_hint = format!("At least {min_length} characters.");
vec![FormSection {
title: None,
fields: vec![
FormField {
name: "old_password",
label: "Old password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("current-password"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "new_password1",
label: "New password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: Some(new_password_hint),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "new_password2",
label: "Confirm".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}
pub(crate) fn must_change_password_form_sections(min_length: usize) -> Vec<FormSection> {
let new_password_hint = format!("At least {min_length} characters.");
vec![FormSection {
title: None,
fields: vec![
FormField {
name: "new_password1",
label: "New password".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: Some(new_password_hint),
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: true,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
FormField {
name: "new_password2",
label: "Confirm".to_string(),
widget: "input",
input_type: "password",
value: String::new(),
hint: None,
placeholder: None,
required: true,
options: None,
multiple: false,
span: 2,
autocomplete: Some("new-password"),
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
},
],
}]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn humanise_field_standalone_acronyms() {
assert_eq!(humanise_field("id"), "ID");
assert_eq!(humanise_field("ip"), "IP");
assert_eq!(humanise_field("url"), "URL");
assert_eq!(humanise_field("uuid"), "UUID");
assert_eq!(humanise_field("mfa"), "MFA");
}
#[test]
fn humanise_field_compound_acronyms() {
assert_eq!(humanise_field("email_id"), "Email ID");
assert_eq!(humanise_field("id_card"), "ID Card");
assert_eq!(humanise_field("user_ip"), "User IP");
assert_eq!(humanise_field("api_token"), "API Token");
assert_eq!(humanise_field("mfa_secret_key_id"), "MFA Secret Key ID");
assert_eq!(humanise_field("csv_export_path"), "CSV Export Path");
}
#[test]
fn humanise_field_acronym_substrings_left_alone() {
assert_eq!(humanise_field("video"), "Video");
assert_eq!(humanise_field("video_url"), "Video URL");
assert_eq!(humanise_field("hidden_field"), "Hidden Field");
assert_eq!(humanise_field("idle_seconds"), "Idle Seconds");
}
#[test]
fn humanise_field_plain_snake_case() {
assert_eq!(humanise_field("title"), "Title");
assert_eq!(humanise_field("chart_number"), "Chart Number");
assert_eq!(humanise_field("full_name"), "Full Name");
assert_eq!(
humanise_field("performed_by_technician"),
"Performed By Technician",
);
}
#[test]
fn humanise_field_edge_cases() {
assert_eq!(humanise_field(""), "");
assert_eq!(humanise_field("a"), "A");
assert_eq!(humanise_field("created_at"), "Created At");
assert_eq!(humanise_field("revoked_by"), "Revoked By");
}
#[test]
fn sort_direction_label_strings_read_alphabetical() {
use super::super::modeladmin::SortDir;
use super::super::types::FieldType::*;
assert_eq!(sort_direction_label(String, SortDir::Asc), "A → Z");
assert_eq!(sort_direction_label(String, SortDir::Desc), "Z → A");
assert_eq!(sort_direction_label(OptionalString, SortDir::Asc), "A → Z");
assert_eq!(sort_direction_label(OptionalString, SortDir::Desc), "Z → A");
}
#[test]
fn sort_direction_label_filepaths_read_alphabetical() {
use super::super::modeladmin::SortDir;
use super::super::types::FieldType::*;
assert_eq!(sort_direction_label(FilePath, SortDir::Asc), "A → Z");
assert_eq!(sort_direction_label(FilePath, SortDir::Desc), "Z → A");
assert_eq!(
sort_direction_label(OptionalFilePath, SortDir::Asc),
"A → Z"
);
assert_eq!(
sort_direction_label(OptionalFilePath, SortDir::Desc),
"Z → A"
);
}
#[test]
fn sort_direction_label_datetimes_read_chronological() {
use super::super::modeladmin::SortDir;
use super::super::types::FieldType::*;
assert_eq!(sort_direction_label(DateTime, SortDir::Asc), "oldest first");
assert_eq!(
sort_direction_label(DateTime, SortDir::Desc),
"newest first"
);
assert_eq!(
sort_direction_label(OptionalDateTime, SortDir::Asc),
"oldest first",
);
assert_eq!(
sort_direction_label(OptionalDateTime, SortDir::Desc),
"newest first",
);
}
#[test]
fn sort_direction_label_bools_read_off_on() {
use super::super::modeladmin::SortDir;
use super::super::types::FieldType::*;
assert_eq!(sort_direction_label(Bool, SortDir::Asc), "off → on");
assert_eq!(sort_direction_label(Bool, SortDir::Desc), "on → off");
}
#[test]
fn sort_direction_label_numerics_fall_back_to_generic() {
use super::super::modeladmin::SortDir;
use super::super::types::FieldType::*;
assert_eq!(sort_direction_label(I32, SortDir::Asc), "ascending");
assert_eq!(sort_direction_label(I64, SortDir::Asc), "ascending");
assert_eq!(
sort_direction_label(OptionalI64, SortDir::Desc),
"descending"
);
}
#[test]
fn action_label_covers_every_audit_event_string() {
let known_event_strings: &[&str] = &[
"create",
"update",
"delete",
"user_created",
"user_updated",
"user_deleted",
"group_created",
"group_updated",
"group_deleted",
"password_changed_self",
"password_reset_self_request",
"password_reset_self_consume",
"password_reset_by_other",
"forced_password_change_completed",
"account_locked",
"account_unlocked",
"mfa_enabled",
"mfa_disabled",
"mfa_reset_by_other",
"mfa_code_consumed",
"backup_codes_regenerated",
"sessions_revoked_self",
"sessions_revoked_by_other",
"session_logout",
"login_succeeded",
"login_failed",
"emergency_recovery",
];
let mut missing: Vec<&'static str> = Vec::new();
for &s in known_event_strings {
if action_label(s) == "Action" {
missing.push(s);
}
}
assert!(
missing.is_empty(),
"action_label falls through to the generic \"Action\" \
label for these event strings — add explicit match arms \
in `admin/render.rs::action_label` (and pick a pill class \
in `action_pill_class`): {missing:?}"
);
}
#[test]
fn action_pill_class_returns_known_classes() {
let known_strings: &[&str] = &[
"create",
"update",
"delete",
"user_created",
"user_updated",
"user_deleted",
"group_created",
"group_updated",
"group_deleted",
"password_changed_self",
"password_reset_self_request",
"password_reset_self_consume",
"password_reset_by_other",
"forced_password_change_completed",
"account_locked",
"account_unlocked",
"mfa_enabled",
"mfa_disabled",
"mfa_reset_by_other",
"mfa_code_consumed",
"backup_codes_regenerated",
"sessions_revoked_self",
"sessions_revoked_by_other",
"session_logout",
"login_succeeded",
"login_failed",
"emergency_recovery",
];
let known_classes = [
"badge-success",
"badge-neutral",
"badge-danger",
"badge-warning",
];
for &s in known_strings {
let class = action_pill_class(s);
assert!(
known_classes.contains(&class),
"action_pill_class({s:?}) returned {class:?} which is \
not one of {known_classes:?}"
);
}
}
#[test]
fn ua_summary_macos_safari() {
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
assert_eq!(summarise_user_agent(Some(ua)), "macOS · Safari");
}
#[test]
fn ua_summary_windows_chrome() {
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
assert_eq!(summarise_user_agent(Some(ua)), "Windows · Chrome");
}
#[test]
fn ua_summary_linux_firefox() {
let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0";
assert_eq!(summarise_user_agent(Some(ua)), "Linux · Firefox");
}
#[test]
fn ua_summary_android_chrome() {
let ua = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
assert_eq!(summarise_user_agent(Some(ua)), "Android · Chrome");
}
#[test]
fn ua_summary_ios_safari() {
let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
assert_eq!(summarise_user_agent(Some(ua)), "iOS · Safari");
}
#[test]
fn ua_summary_curl_falls_through_to_unknown_os() {
let ua = "curl/8.4.0";
let s = summarise_user_agent(Some(ua));
assert!(s.contains("curl"));
}
#[test]
fn ua_summary_unknown_returns_truncated() {
let ua =
"QuiteUnusualUserAgent/1.0 with extremely long descriptor that should be truncated";
let s = summarise_user_agent(Some(ua));
assert!(s.len() <= 40);
}
#[test]
fn ua_summary_none_returns_dash() {
assert_eq!(summarise_user_agent(None), "—");
}
#[test]
fn trust_label_strings() {
assert_eq!(
trust_label(crate::auth::SessionTrust::Authenticated),
"Signed in"
);
assert_eq!(trust_label(crate::auth::SessionTrust::Elevated), "Elevated");
assert_eq!(
trust_label(crate::auth::SessionTrust::MfaVerified),
"MFA verified"
);
}
#[test]
fn password_change_form_sections_renders_live_min_length() {
let sections = password_change_form_sections(10);
assert_eq!(sections.len(), 1);
let fields = §ions[0].fields;
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].name, "old_password");
assert_eq!(fields[1].name, "new_password1");
assert_eq!(fields[2].name, "new_password2");
assert_eq!(
fields[1].hint.as_deref(),
Some("At least 10 characters."),
"default-policy floor 10 must surface in the hint"
);
let sections = password_change_form_sections(16);
assert_eq!(
sections[0].fields[1].hint.as_deref(),
Some("At least 16 characters."),
);
}
#[test]
fn password_change_form_sections_only_new_password_carries_hint() {
let sections = password_change_form_sections(10);
let fields = §ions[0].fields;
assert!(fields[0].hint.is_none(), "old_password must have no hint");
assert!(fields[1].hint.is_some(), "new_password1 must have the hint");
assert!(fields[2].hint.is_none(), "new_password2 must have no hint");
}
#[test]
fn fieldsets_drive_section_order_and_titles() {
use super::super::modeladmin::Fieldset;
let fields = vec![
stub_form_field("title"),
stub_form_field("body"),
stub_form_field("created_at"),
stub_form_field("slug"),
];
let fieldsets: &'static [Fieldset] = &[
Fieldset {
title: "Content",
fields: &["body", "title"],
},
Fieldset {
title: "Metadata",
fields: &["created_at"],
},
];
let sections = group_fields_by_fieldsets(fields, fieldsets);
assert_eq!(sections.len(), 3);
assert_eq!(sections[0].title, Some("Content"));
assert_eq!(
sections[0]
.fields
.iter()
.map(|f| f.name)
.collect::<Vec<_>>(),
vec!["body", "title"],
);
assert_eq!(sections[1].title, Some("Metadata"));
assert_eq!(
sections[1]
.fields
.iter()
.map(|f| f.name)
.collect::<Vec<_>>(),
vec!["created_at"],
);
assert_eq!(sections[2].title, Some("Other"));
assert_eq!(
sections[2]
.fields
.iter()
.map(|f| f.name)
.collect::<Vec<_>>(),
vec!["slug"],
);
}
#[test]
fn fieldsets_with_unknown_field_names_skip_silently() {
use super::super::modeladmin::Fieldset;
let fields = vec![stub_form_field("title")];
let fieldsets: &'static [Fieldset] = &[
Fieldset {
title: "Real",
fields: &["title", "typo_nonexistent"],
},
Fieldset {
title: "Empty",
fields: &["also_missing"],
},
];
let sections = group_fields_by_fieldsets(fields, fieldsets);
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].title, Some("Real"));
assert_eq!(sections[0].fields.len(), 1);
assert_eq!(sections[0].fields[0].name, "title");
}
fn stub_form_field(name: &'static str) -> FormField {
FormField {
name,
label: name.to_string(),
widget: "input",
input_type: "text",
value: String::new(),
hint: None,
placeholder: None,
required: false,
options: None,
multiple: false,
span: 1,
autocomplete: None,
autofocus: false,
disabled: false,
maxlength: None,
searchable: false,
has_more: false,
search_url: None,
errors: vec![],
target_model: None,
checked: false,
}
}
#[test]
fn highlight_returns_none_when_no_match() {
assert_eq!(highlight_search_match("Anna Lindqvist", "zzz"), None);
}
#[test]
fn highlight_returns_none_for_empty_term() {
assert_eq!(highlight_search_match("anything", ""), None);
}
#[test]
fn highlight_wraps_ascii_case_insensitive() {
let html = highlight_search_match("Anna Lindqvist", "anna").unwrap();
assert_eq!(html, "<mark>Anna</mark> Lindqvist");
}
#[test]
fn highlight_wraps_every_occurrence() {
let html = highlight_search_match("abc ABC abC", "abc").unwrap();
assert_eq!(html, "<mark>abc</mark> <mark>ABC</mark> <mark>abC</mark>");
}
#[test]
fn highlight_escapes_html_around_marks() {
let html = highlight_search_match("name<script>alert('x')</script>", "name").unwrap();
assert!(html.starts_with("<mark>name</mark>"));
assert!(html.contains("<script>"));
assert!(html.contains("'x'"));
assert!(!html.contains("<script>"));
}
#[test]
fn highlight_escapes_inside_mark_when_term_contains_specials() {
let html = highlight_search_match("a<b<c", "<").unwrap();
assert_eq!(html, "a<mark><</mark>b<mark><</mark>c");
}
#[test]
fn highlight_preserves_non_ascii_bytes_around_match() {
let html = highlight_search_match("Anna Wåhlin", "ann").unwrap();
assert_eq!(html, "<mark>Ann</mark>a Wåhlin");
}
#[test]
fn highlight_handles_adjacent_matches() {
let html = highlight_search_match("aaaa", "aa").unwrap();
assert_eq!(html, "<mark>aa</mark><mark>aa</mark>");
}
#[test]
fn extract_changes_returns_empty_when_metadata_is_none() {
let r = extract_changes_from_metadata(None);
assert!(r.is_empty());
}
#[test]
fn extract_changes_returns_empty_when_changes_key_missing() {
let meta = serde_json::json!({ "actor_user_id": 42 });
let r = extract_changes_from_metadata(Some(&meta));
assert!(r.is_empty());
}
#[test]
fn extract_changes_returns_empty_when_changes_not_array() {
let meta = serde_json::json!({ "changes": "not-an-array" });
let r = extract_changes_from_metadata(Some(&meta));
assert!(r.is_empty());
}
#[test]
fn extract_changes_round_trips_full_entry() {
let meta = serde_json::json!({
"changes": [
{ "field": "title", "label": "Title", "from": "Old", "to": "New" },
{ "field": "body", "label": "Body", "from": "", "to": "Filled in" },
]
});
let r = extract_changes_from_metadata(Some(&meta));
assert_eq!(r.len(), 2);
assert_eq!(r[0].field, "title");
assert_eq!(r[0].label, "Title");
assert_eq!(r[0].from, "Old");
assert_eq!(r[0].to, "New");
assert_eq!(r[1].from, "");
assert_eq!(r[1].to, "Filled in");
}
#[test]
fn extract_changes_falls_back_to_field_when_label_missing() {
let meta = serde_json::json!({
"changes": [
{ "field": "title", "from": "a", "to": "b" }
]
});
let r = extract_changes_from_metadata(Some(&meta));
assert_eq!(r.len(), 1);
assert_eq!(r[0].label, "title");
}
#[test]
fn extract_changes_skips_malformed_entries() {
let meta = serde_json::json!({
"changes": [
{ "field": "title", "from": "a", "to": "b" },
{ "label": "missing-field", "from": "x", "to": "y" },
"not-an-object",
]
});
let r = extract_changes_from_metadata(Some(&meta));
assert_eq!(r.len(), 1);
assert_eq!(r[0].field, "title");
}
}