1use std::collections::HashMap;
11
12use crate::admin::admin_form_bridge::{
13 resolve_filter_type, AdminDataType, AdminUiField, AdminUiModel, FilterType,
14};
15use crate::admin::persistence;
16use crate::admin::ui::html_escape;
17use crate::orm::Db;
18
19struct DashboardEntry {
25 slug: &'static str,
26 model_name: &'static str,
27 count: i64,
28}
29
30async fn collect_dashboard_entries(
34 db: &Db,
35 registry: &crate::admin::admin_form_bridge::AdminRegistry,
36) -> Vec<DashboardEntry> {
37 use sqlx::Row;
38 let mut slugs: Vec<&'static str> = registry.slugs().copied().collect();
39 slugs.sort();
40 let mut out = Vec::with_capacity(slugs.len());
41 for slug in slugs {
42 let Some(model) = registry.get(slug) else {
43 continue;
44 };
45 if let Some(sql) = model.ensure_table_sql() {
46 let _ = persistence::ensure_table(db, sql).await;
47 }
48 let table = model.table_name();
49 let count: i64 = {
50 let sql = format!(
51 "SELECT COUNT(*) AS c FROM \"{}\"",
52 table.replace('"', "\"\"")
53 );
54 match sqlx::query(&sql).fetch_one(db.pool()).await {
55 Ok(row) => row.try_get::<i64, _>("c").unwrap_or(0),
56 Err(_) => 0,
57 }
58 };
59 out.push(DashboardEntry {
60 slug: model.slug(),
61 model_name: model.model_name(),
62 count,
63 });
64 }
65 out
66}
67
68async fn collect_legacy_dashboard_entries(
80 db: &Db,
81 legacy_entries: &[crate::admin::AdminEntry],
82 already_listed: &std::collections::HashSet<&str>,
83) -> Vec<DashboardEntry> {
84 use sqlx::Row;
85 let mut out = Vec::new();
86 for entry in legacy_entries {
87 if entry.core || already_listed.contains(entry.admin_name) {
88 continue;
89 }
90 let count: i64 = {
91 let sql = format!(
92 "SELECT COUNT(*) AS c FROM \"{}\"",
93 entry.table.replace('"', "\"\""),
94 );
95 match sqlx::query(&sql).fetch_one(db.pool()).await {
96 Ok(row) => row.try_get::<i64, _>("c").unwrap_or(0),
97 Err(_) => 0,
98 }
99 };
100 out.push(DashboardEntry {
101 slug: entry.admin_name,
102 model_name: entry.singular_name,
105 count,
106 });
107 }
108 out.sort_by_key(|e| e.slug);
111 out
112}
113
114#[allow(clippy::too_many_arguments)]
131async fn fetch_users_table_state(
132 db: &Db,
133 model: &dyn AdminUiModel,
134 query: Option<&str>,
135 filters: &HashMap<String, String>,
136 page: i64,
137 sort: Option<&str>,
138 dir: Option<&str>,
139) -> (
140 Vec<HashMap<String, String>>,
141 i64,
142 i64,
143 i64,
144 Option<String>,
145 Option<String>,
146) {
147 const PAGE_SIZE: i64 = 20;
148 let table = model.table_name();
149 let searchable: Vec<&str> = model.searchable_fields();
150 let (eq_filters, like_filters) = classify_filters(model, filters);
151 let (validated_sort, validated_dir) = validate_sort_state(model, sort, dir);
152
153 let total = persistence::count_filtered_records(
154 db,
155 table,
156 &eq_filters,
157 &like_filters,
158 query,
159 &searchable,
160 )
161 .await
162 .unwrap_or(0);
163
164 let total_pages = if total > 0 {
165 ((total as u64).div_ceil(PAGE_SIZE as u64) as i64).max(1)
166 } else {
167 1
168 };
169 let current_page = page.clamp(1, total_pages);
170 let offset = (current_page - 1) * PAGE_SIZE;
171
172 let rows = persistence::filter_records(
173 db,
174 table,
175 &eq_filters,
176 &like_filters,
177 query,
178 &searchable,
179 validated_sort.as_deref(),
180 validated_dir.as_deref(),
181 PAGE_SIZE,
182 offset,
183 )
184 .await
185 .unwrap_or_default();
186
187 (
188 rows,
189 total,
190 current_page,
191 total_pages,
192 validated_sort,
193 validated_dir,
194 )
195}
196
197fn validate_sort_state(
205 model: &dyn AdminUiModel,
206 sort: Option<&str>,
207 dir: Option<&str>,
208) -> (Option<String>, Option<String>) {
209 let valid_sort = sort.filter(|s| model.fields().iter().any(|f| f.name == *s && f.sortable));
210 match valid_sort {
211 Some(s) => {
212 let d = if matches!(dir, Some("desc")) {
213 "desc"
214 } else {
215 "asc"
216 };
217 (Some(s.to_string()), Some(d.to_string()))
218 }
219 None => (None, None),
220 }
221}
222
223fn classify_filters(
231 model: &dyn AdminUiModel,
232 raw: &HashMap<String, String>,
233) -> (HashMap<String, String>, HashMap<String, String>) {
234 let fields = model.fields();
235 let mut eq = HashMap::new();
236 let mut like = HashMap::new();
237 for (k, v) in raw {
238 let Some(field) = fields.iter().find(|f| f.name == k.as_str()) else {
239 continue;
240 };
241 if !field.filterable && !field.advanced_filter {
245 continue;
246 }
247 match resolve_filter_type(field) {
248 FilterType::Boolean | FilterType::Select => {
249 eq.insert(k.clone(), v.clone());
250 }
251 FilterType::Exact => {
252 like.insert(k.clone(), v.clone());
253 }
254 }
255 }
256 (eq, like)
257}
258
259pub struct UserAdmin;
267
268pub fn new_user_admin() -> Box<dyn AdminUiModel> {
271 Box::new(UserAdmin)
272}
273
274impl AdminUiModel for UserAdmin {
275 fn slug(&self) -> &'static str {
276 "users"
277 }
278 fn model_name(&self) -> &'static str {
279 "User"
280 }
281 fn table_name(&self) -> &'static str {
282 "admin_new_demo_users"
283 }
284 fn primary_key(&self) -> &'static str {
285 "id"
286 }
287 fn searchable_fields(&self) -> Vec<&'static str> {
288 vec!["username", "email", "doctor_id"]
289 }
290 fn primary_status_field(&self) -> Option<&'static str> {
291 Some("is_active")
292 }
293 fn ensure_table_sql(&self) -> Option<&'static str> {
294 Some(
295 "CREATE TABLE IF NOT EXISTS admin_new_demo_users (
296 id INTEGER PRIMARY KEY AUTOINCREMENT,
297 username TEXT NOT NULL,
298 email TEXT NOT NULL,
299 is_active TEXT NOT NULL DEFAULT 'false',
300 doctor_id TEXT,
301 salary_amount TEXT
302 )",
303 )
304 }
305
306 fn fields(&self) -> Vec<AdminUiField> {
307 vec![
308 AdminUiField {
309 name: "username",
310 label: "Username",
311 data_type: AdminDataType::String,
312 required: true,
313 readonly: false,
314 is_relation: false,
315 options: vec![],
316 filterable: true,
317 advanced_filter: false,
318 sortable: true,
319 visible_in_table: true,
320 },
321 AdminUiField {
322 name: "email",
323 label: "Email",
324 data_type: AdminDataType::Email,
325 required: true,
326 readonly: false,
327 is_relation: false,
328 options: vec![],
329 filterable: false,
330 advanced_filter: true,
331 sortable: true,
332 visible_in_table: true,
333 },
334 AdminUiField {
335 name: "is_active",
336 label: "Active",
337 data_type: AdminDataType::Boolean,
338 required: false,
339 readonly: false,
340 is_relation: false,
341 options: vec![],
342 filterable: true,
343 advanced_filter: false,
344 sortable: true,
345 visible_in_table: true,
346 },
347 AdminUiField {
348 name: "doctor_id",
349 label: "Doctor",
350 data_type: AdminDataType::Integer,
351 required: true,
352 readonly: false,
353 is_relation: true,
354 options: vec![
355 ("1".into(), "Dr. Erik".into()),
356 ("2".into(), "Dr. Sara".into()),
357 ],
358 filterable: true,
359 advanced_filter: false,
360 sortable: true,
361 visible_in_table: true,
362 },
363 AdminUiField {
364 name: "salary_amount",
365 label: "Salary",
366 data_type: AdminDataType::Float,
367 required: false,
368 readonly: false,
369 is_relation: false,
370 options: vec![],
371 filterable: false,
372 advanced_filter: true,
373 sortable: true,
374 visible_in_table: true,
375 },
376 ]
377 }
378}
379
380#[derive(serde::Serialize)]
390struct DesignView<'a> {
391 project_name: &'a str,
392 logo_initial: &'a str,
393 primary_color: &'a str,
394 accent_color: &'a str,
395}
396
397#[derive(serde::Serialize)]
398struct UserView {
399 email: String,
400 display_name: String,
401}
402
403#[derive(serde::Serialize)]
404struct SidebarEntryView {
405 label: String,
406 href: String,
407 active: bool,
408 visible: bool,
409 count: i64,
414}
415
416#[derive(serde::Serialize)]
417struct DashboardCardView {
418 label: String,
419 value: i64,
420}
421
422fn design_view() -> DesignView<'static> {
423 let d = crate::admin::design::Design::global();
424 DesignView {
427 project_name: d.project_name.as_str(),
428 logo_initial: d.logo_initial.as_str(),
429 primary_color: d.primary_color.as_str(),
430 accent_color: d.accent_color.as_str(),
431 }
432}
433
434fn user_view(identity: Option<&crate::auth::Identity>) -> Option<UserView> {
435 identity.map(|id| UserView {
436 email: id.email.clone(),
437 display_name: id.email.clone(),
438 })
439}
440
441fn sidebar_from_entries(
442 entries: &[DashboardEntry],
443 active_slug: Option<&str>,
444) -> Vec<SidebarEntryView> {
445 entries
446 .iter()
447 .map(|e| SidebarEntryView {
448 label: format!("{}s", e.model_name),
449 href: format!("/admin/{}", e.slug),
450 active: active_slug == Some(e.slug),
451 visible: true,
452 count: e.count,
453 })
454 .collect()
455}
456
457fn sidebar_merged(
464 dashboard_entries: &[DashboardEntry],
465 legacy_entries: &[crate::admin::AdminEntry],
466 active_slug: Option<&str>,
467) -> Vec<SidebarEntryView> {
468 let mut merged = sidebar_from_entries(dashboard_entries, active_slug);
469 let known: std::collections::HashSet<&str> = dashboard_entries.iter().map(|e| e.slug).collect();
470 for entry in legacy_entries {
471 if entry.core || known.contains(entry.admin_name) {
472 continue;
473 }
474 merged.push(SidebarEntryView {
475 label: entry.display_name.to_string(),
476 href: format!("/admin/{}", entry.admin_name),
477 active: active_slug == Some(entry.admin_name),
478 visible: true,
479 count: -1,
480 });
481 }
482 merged
483}
484
485pub struct LegacyEntryModel {
492 entry: crate::admin::AdminEntry,
493}
494
495impl LegacyEntryModel {
496 pub fn new(entry: &crate::admin::AdminEntry) -> Self {
501 Self {
502 entry: entry.clone(),
503 }
504 }
505
506 pub fn source_entry(&self) -> &crate::admin::AdminEntry {
510 &self.entry
511 }
512}
513
514async fn fk_options(
528 db: &Db,
529 relation: crate::admin::AdminRelation,
530 legacy_entries: &[crate::admin::AdminEntry],
531) -> Vec<(String, String)> {
532 use sqlx::Row as _;
533
534 let Some(target_entry) = legacy_entries
535 .iter()
536 .find(|e| e.singular_name == relation.model)
537 else {
538 return Vec::new();
539 };
540 let display_col = relation
541 .display_field
542 .or_else(|| {
543 target_entry
544 .fields
545 .iter()
546 .filter(|f| f.name != "id" && matches!(f.ty, crate::admin::FieldType::String))
547 .map(|f| f.name)
548 .next()
549 })
550 .unwrap_or("id");
551
552 let sql = format!(
553 r#"SELECT "id", "{display}" FROM "{table}" ORDER BY "{display}" LIMIT 500"#,
554 display = display_col.replace('"', "\"\""),
555 table = target_entry.table.replace('"', "\"\""),
556 );
557 let Ok(rows) = sqlx::query(&sql).fetch_all(db.pool()).await else {
558 return Vec::new();
559 };
560 rows.into_iter()
561 .filter_map(|row| {
562 let id: Option<i64> = row.try_get(0).ok();
563 let display: Option<String> = row
567 .try_get::<Option<String>, _>(1)
568 .ok()
569 .flatten()
570 .or_else(|| {
571 row.try_get::<Option<i64>, _>(1)
572 .ok()
573 .flatten()
574 .map(|n| n.to_string())
575 });
576 match (id, display) {
577 (Some(i), Some(d)) => Some((i.to_string(), d)),
578 (Some(i), None) => Some((i.to_string(), format!("#{i}"))),
579 _ => None,
580 }
581 })
582 .collect()
583}
584
585async fn fk_lookup_batch(
590 db: &Db,
591 target_entry: &crate::admin::AdminEntry,
592 display_field: Option<&'static str>,
593 ids: &[String],
594) -> std::collections::HashMap<String, String> {
595 use sqlx::Row as _;
596
597 let mut out = std::collections::HashMap::new();
598 if ids.is_empty() {
599 return out;
600 }
601 let display_col = display_field
602 .or_else(|| {
603 target_entry
604 .fields
605 .iter()
606 .filter(|f| f.name != "id" && matches!(f.ty, crate::admin::FieldType::String))
607 .map(|f| f.name)
608 .next()
609 })
610 .unwrap_or("id");
611
612 let placeholders = vec!["?"; ids.len()].join(",");
613 let sql = format!(
614 r#"SELECT "id", "{display}" FROM "{table}" WHERE "id" IN ({placeholders})"#,
615 display = display_col.replace('"', "\"\""),
616 table = target_entry.table.replace('"', "\"\""),
617 );
618 let mut q = sqlx::query(&sql);
619 for id in ids {
620 q = q.bind(id);
621 }
622 let Ok(rows) = q.fetch_all(db.pool()).await else {
623 return out;
624 };
625 for row in rows {
626 let Ok(id) = row.try_get::<i64, _>(0) else {
627 continue;
628 };
629 let label: Option<String> =
630 row.try_get::<Option<String>, _>(1)
631 .ok()
632 .flatten()
633 .or_else(|| {
634 row.try_get::<Option<i64>, _>(1)
635 .ok()
636 .flatten()
637 .map(|n| n.to_string())
638 });
639 if let Some(l) = label {
640 out.insert(id.to_string(), l);
641 }
642 }
643 out
644}
645
646struct FkColumnInfo {
648 column_index: usize,
649 target_admin_name: String,
650 id_to_label: std::collections::HashMap<String, String>,
651}
652
653async fn build_fk_lookups(
658 db: &Db,
659 source_entry: Option<&crate::admin::AdminEntry>,
660 columns: &[ColumnView],
661 rows_raw: &[HashMap<String, String>],
662 legacy_entries: &[crate::admin::AdminEntry],
663) -> Vec<FkColumnInfo> {
664 let mut out = Vec::new();
665 let Some(source) = source_entry else {
666 return out;
667 };
668 for (idx, col) in columns.iter().enumerate() {
669 let Some(source_field) = source.fields.iter().find(|f| f.name == col.name) else {
670 continue;
671 };
672 let Some(relation) = source_field.relation else {
673 continue;
674 };
675 let Some(target_entry) = legacy_entries
676 .iter()
677 .find(|e| e.singular_name == relation.model)
678 else {
679 continue;
680 };
681 let ids: Vec<String> = {
682 let mut seen = std::collections::HashSet::new();
683 let mut v = Vec::new();
684 for row in rows_raw {
685 if let Some(id) = row.get(&col.name) {
686 if !id.is_empty() && seen.insert(id.clone()) {
687 v.push(id.clone());
688 }
689 }
690 }
691 v
692 };
693 if ids.is_empty() {
694 continue;
695 }
696 let id_to_label = fk_lookup_batch(db, target_entry, relation.display_field, &ids).await;
697 out.push(FkColumnInfo {
698 column_index: idx,
699 target_admin_name: target_entry.admin_name.to_string(),
700 id_to_label,
701 });
702 }
703 out
704}
705
706pub async fn enrich_fields_for_form(
711 db: &Db,
712 model: &dyn AdminUiModel,
713 legacy_source: Option<&crate::admin::AdminEntry>,
714 legacy_entries: &[crate::admin::AdminEntry],
715) -> Vec<AdminUiField> {
716 let mut fields = model.fields();
717 let Some(source) = legacy_source else {
718 return fields;
719 };
720 for field in fields.iter_mut() {
721 let Some(source_field) = source.fields.iter().find(|f| f.name == field.name) else {
722 continue;
723 };
724 let Some(relation) = source_field.relation else {
725 continue;
726 };
727 field.is_relation = true;
728 field.options = fk_options(db, relation, legacy_entries).await;
729 }
730 fields
731}
732
733fn admin_field_to_ui_field(field: &crate::admin::AdminField) -> AdminUiField {
734 use crate::admin::FieldType;
735 let data_type = match field.ty {
739 FieldType::String => AdminDataType::String,
740 FieldType::I32 | FieldType::I64 => AdminDataType::Integer,
741 FieldType::Bool => AdminDataType::Boolean,
742 FieldType::DateTime => AdminDataType::DateTime,
743 };
744 AdminUiField {
745 name: field.name,
746 label: field.name,
750 data_type,
751 required: !field.nullable,
752 readonly: !field.editable,
753 is_relation: field.relation.is_some(),
754 options: Vec::new(),
755 filterable: false,
756 advanced_filter: false,
757 sortable: matches!(
758 data_type,
759 AdminDataType::Integer
760 | AdminDataType::Float
761 | AdminDataType::DateTime
762 | AdminDataType::String
763 | AdminDataType::Email
764 ),
765 visible_in_table: true,
766 }
767}
768
769#[derive(serde::Serialize)]
778struct FormFieldView {
779 id: String,
780 name: String,
781 label: String,
782 required: bool,
783 readonly: bool,
784 control: String,
785 help: Option<String>,
786 error: Option<String>,
787}
788
789#[derive(serde::Serialize)]
790struct FormView {
791 title: String,
792 action: String,
793 cancel_url: String,
794 submit_label: String,
795 error: Option<String>,
796 fields: Vec<FormFieldView>,
797}
798
799fn render_field_control(field: &AdminUiField, value: &str) -> String {
800 let id = format!("field_{}", field.name);
801 let name = field.name;
802 let val = html_escape(value);
803 let readonly = if field.readonly { " readonly" } else { "" };
804 let required = if field.required && !field.readonly {
805 " required"
806 } else {
807 ""
808 };
809
810 if field.is_relation && !field.options.is_empty() {
814 let mut options = String::from(r#"<option value="">— choose —</option>"#);
815 for (ov, ol) in &field.options {
816 let sel = if ov == value { " selected" } else { "" };
817 options.push_str(&format!(
818 r#"<option value="{v}"{sel}>{l}</option>"#,
819 v = html_escape(ov),
820 l = html_escape(ol),
821 ));
822 }
823 return format!(
824 r#"<select class="rio-form__input" id="{id}" name="{name}"{readonly}{required}>{options}</select>"#,
825 );
826 }
827 if field.is_relation {
828 return format!(
833 r#"<input type="number" step="1" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required} placeholder="id">"#,
834 );
835 }
836
837 match field.data_type {
838 AdminDataType::Text => format!(
839 r#"<textarea class="rio-form__input rio-form__input--textarea" id="{id}" name="{name}"{readonly}{required} rows="4">{val}</textarea>"#,
840 ),
841 AdminDataType::Email => format!(
842 r#"<input type="email" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required} autocomplete="off">"#,
843 ),
844 AdminDataType::Integer => format!(
845 r#"<input type="number" step="1" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required}>"#,
846 ),
847 AdminDataType::Float => format!(
848 r#"<input type="number" step="any" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required}>"#,
849 ),
850 AdminDataType::Boolean => {
851 let checked = if value == "1" || value.eq_ignore_ascii_case("true") {
852 " checked"
853 } else {
854 ""
855 };
856 format!(
860 r#"<input type="hidden" name="{name}" value="0"><input type="checkbox" class="rio-form__check" id="{id}" name="{name}" value="1"{checked}{readonly}>"#,
861 )
862 }
863 AdminDataType::DateTime => format!(
864 r#"<input type="datetime-local" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required}>"#,
865 ),
866 AdminDataType::String => format!(
867 r#"<input type="text" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required}>"#,
868 ),
869 }
870}
871
872#[allow(clippy::too_many_arguments)]
882pub async fn form_render(
883 db: &Db,
884 registry: &crate::admin::admin_form_bridge::AdminRegistry,
885 legacy_entries: &[crate::admin::AdminEntry],
886 model: &dyn AdminUiModel,
887 legacy_source: Option<&crate::admin::AdminEntry>,
888 editing_id: Option<&str>,
889 identity: Option<&crate::auth::Identity>,
890 csrf_token: Option<&str>,
891 form_error: Option<&str>,
892) -> String {
893 if let Some(sql) = model.ensure_table_sql() {
894 let _ = persistence::ensure_table(db, sql).await;
895 }
896
897 let dashboard_entries = collect_dashboard_entries(db, registry).await;
898 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, Some(model.slug()));
899
900 let is_edit = editing_id.is_some();
901 let prefill = if let Some(id) = editing_id {
902 persistence::get_record_by_id(db, model.table_name(), id)
903 .await
904 .unwrap_or_default()
905 } else {
906 HashMap::new()
907 };
908
909 let pk = model.primary_key();
910 let slug = model.slug();
911 let enriched = enrich_fields_for_form(db, model, legacy_source, legacy_entries).await;
912 let fields: Vec<FormFieldView> = enriched
913 .into_iter()
914 .filter(|f| {
915 if !is_edit && f.name == pk {
919 return false;
920 }
921 true
922 })
923 .map(|mut f| {
924 if f.name == pk {
925 f.readonly = true;
926 }
927 let raw_value = prefill.get(f.name).cloned().unwrap_or_default();
928 let control = render_field_control(&f, &raw_value);
929 FormFieldView {
930 id: format!("field_{}", f.name),
931 name: f.name.to_string(),
932 label: humanize_field_label(f.label),
933 required: f.required && !f.readonly,
934 readonly: f.readonly,
935 control,
936 help: None,
937 error: None,
938 }
939 })
940 .collect();
941
942 let (title, action, submit_label) = match editing_id {
943 Some(id) => (
944 format!("Edit {}", model.model_name()),
945 format!("/admin/{slug}/{id}/edit"),
946 "Save changes".to_string(),
947 ),
948 None => (
949 format!("New {}", model.model_name()),
950 format!("/admin/{slug}/new"),
951 format!("Create {}", model.model_name()),
952 ),
953 };
954
955 let form = FormView {
956 title,
957 action,
958 cancel_url: format!("/admin/{slug}"),
959 submit_label,
960 error: form_error.map(str::to_string),
961 fields,
962 };
963
964 let design = design_view();
965 let user = user_view(identity);
966
967 let env = crate::admin::templating::env();
968 match env.get_template("admin/form.html").and_then(|tmpl| {
969 tmpl.render(minijinja::context! {
970 design => design,
971 current_user => user,
972 sidebar_entries => sidebar,
973 form => form,
974 page_title => format!(
975 "{} · {}s",
976 if is_edit { "Edit" } else { "New" },
977 model.model_name()
978 ),
979 csrf_token => csrf_token.unwrap_or(""),
980 rustio_version => env!("CARGO_PKG_VERSION"),
981 })
982 }) {
983 Ok(html) => html,
984 Err(err) => {
985 eprintln!("admin form template render failed: {err}");
986 form_fallback(model, editing_id)
987 }
988 }
989}
990
991fn form_fallback(model: &dyn AdminUiModel, editing_id: Option<&str>) -> String {
992 let kind = if editing_id.is_some() { "Edit" } else { "New" };
993 format!(
994 "<!doctype html><html><head><meta charset=\"utf-8\"><title>{kind} {mn}</title></head><body style=\"font-family:system-ui\"><h1>{kind} {mn}</h1><p>The form template failed to render. Check the server log.</p><p><a href=\"/admin/{slug}\">Back to list</a></p></body></html>",
995 mn = html_escape(model.model_name()),
996 slug = html_escape(model.slug()),
997 )
998}
999
1000impl AdminUiModel for LegacyEntryModel {
1001 fn slug(&self) -> &'static str {
1002 self.entry.admin_name
1003 }
1004 fn model_name(&self) -> &'static str {
1005 self.entry.singular_name
1006 }
1007 fn table_name(&self) -> &'static str {
1008 self.entry.table
1009 }
1010 fn primary_key(&self) -> &'static str {
1011 self.entry
1014 .fields
1015 .iter()
1016 .find(|f| !f.editable && f.name == "id")
1017 .map(|f| f.name)
1018 .unwrap_or("id")
1019 }
1020 fn fields(&self) -> Vec<AdminUiField> {
1021 self.entry
1022 .fields
1023 .iter()
1024 .map(admin_field_to_ui_field)
1025 .collect()
1026 }
1027 fn searchable_fields(&self) -> Vec<&'static str> {
1028 self.entry
1029 .fields
1030 .iter()
1031 .filter(|f| matches!(f.ty, crate::admin::FieldType::String))
1032 .map(|f| f.name)
1033 .collect()
1034 }
1035 fn primary_status_field(&self) -> Option<&'static str> {
1036 None
1037 }
1038 fn ensure_table_sql(&self) -> Option<&'static str> {
1039 None
1040 }
1041}
1042
1043pub async fn dashboard_render(
1052 db: &Db,
1053 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1054 legacy_entries: &[crate::admin::AdminEntry],
1055 identity: Option<&crate::auth::Identity>,
1056 csrf_token: Option<&str>,
1057) -> String {
1058 let new_entries = collect_dashboard_entries(db, registry).await;
1067 let known: std::collections::HashSet<&str> = new_entries.iter().map(|e| e.slug).collect();
1068 let legacy_dash = collect_legacy_dashboard_entries(db, legacy_entries, &known).await;
1069 let all_entries: Vec<&DashboardEntry> = new_entries.iter().chain(legacy_dash.iter()).collect();
1070
1071 let sidebar = sidebar_merged(&new_entries, legacy_entries, None);
1072 let cards: Vec<DashboardCardView> = all_entries
1073 .iter()
1074 .map(|e| DashboardCardView {
1075 label: format!("{}s", e.model_name),
1076 value: e.count,
1077 })
1078 .collect();
1079 let design = design_view();
1080 let user = user_view(identity);
1081
1082 let env = crate::admin::templating::env();
1083 match env.get_template("admin/dashboard.html").and_then(|tmpl| {
1084 tmpl.render(minijinja::context! {
1085 design => design,
1086 current_user => user,
1087 sidebar_entries => sidebar,
1088 dashboard_cards => cards,
1089 page_title => "Dashboard",
1090 csrf_token => csrf_token.unwrap_or(""),
1091 rustio_version => env!("CARGO_PKG_VERSION"),
1092 })
1093 }) {
1094 Ok(html) => html,
1095 Err(err) => {
1096 eprintln!("admin dashboard template render failed: {err}");
1097 let combined: Vec<DashboardEntry> =
1100 new_entries.into_iter().chain(legacy_dash).collect();
1101 dashboard_fallback(&combined)
1102 }
1103 }
1104}
1105
1106#[derive(serde::Serialize)]
1107struct ModelView {
1108 display_name: String,
1109 singular_name: String,
1110 new_url: String,
1111}
1112
1113#[derive(serde::Serialize)]
1114struct ColumnView {
1115 name: String,
1116 label: String,
1117 sortable: bool,
1118}
1119
1120#[derive(serde::Serialize)]
1121struct RowView {
1122 id: String,
1123 cells: Vec<String>,
1124 edit_url: String,
1125 delete_url: String,
1126}
1127
1128#[derive(serde::Serialize)]
1129struct PageLinkView {
1130 label: String,
1131 href: String,
1132 active: bool,
1133 disabled: bool,
1134}
1135
1136#[derive(serde::Serialize)]
1137struct PaginationView {
1138 pages: i64,
1139 current: i64,
1140 per_page: i64,
1141 total: i64,
1142 from: i64,
1143 to: i64,
1144 links: Vec<PageLinkView>,
1145}
1146
1147#[derive(serde::Serialize)]
1148struct ListPermissionsView {
1149 view: bool,
1150 create: bool,
1151 edit: bool,
1152 delete: bool,
1153}
1154
1155#[allow(clippy::too_many_arguments)]
1160pub async fn list_render(
1161 db: &Db,
1162 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1163 legacy_entries: &[crate::admin::AdminEntry],
1164 model: &dyn AdminUiModel,
1165 legacy_source: Option<&crate::admin::AdminEntry>,
1166 query: Option<&str>,
1167 page: i64,
1168 filters: &HashMap<String, String>,
1169 sort: Option<&str>,
1170 dir: Option<&str>,
1171 identity: Option<&crate::auth::Identity>,
1172 csrf_token: Option<&str>,
1173) -> String {
1174 if let Some(sql) = model.ensure_table_sql() {
1175 let _ = persistence::ensure_table(db, sql).await;
1176 }
1177
1178 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1179 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, Some(model.slug()));
1180
1181 let (rows_raw, total, current_page, total_pages, validated_sort, validated_dir) =
1182 fetch_users_table_state(db, model, query, filters, page, sort, dir).await;
1183
1184 let fields = model.fields();
1185 let columns: Vec<ColumnView> = fields
1186 .iter()
1187 .filter(|f| f.visible_in_table)
1188 .map(|f| ColumnView {
1189 name: f.name.to_string(),
1190 label: humanize_field_label(f.label),
1191 sortable: f.sortable,
1192 })
1193 .collect();
1194
1195 let fk_lookups = build_fk_lookups(db, legacy_source, &columns, &rows_raw, legacy_entries).await;
1201
1202 let pk = model.primary_key();
1203 let slug = model.slug();
1204 let rows: Vec<RowView> = rows_raw
1205 .iter()
1206 .map(|row| {
1207 let id = row.get(pk).cloned().unwrap_or_default();
1208 let cells = columns
1209 .iter()
1210 .enumerate()
1211 .map(|(col_idx, col)| {
1212 let raw = row.get(&col.name).cloned().unwrap_or_default();
1213 if let Some(fk) = fk_lookups.iter().find(|f| f.column_index == col_idx) {
1214 if raw.is_empty() {
1217 return String::new();
1218 }
1219 match fk.id_to_label.get(&raw) {
1220 Some(label) => format!(
1221 r#"<a href="/admin/{slug}/{id}">{label}</a>"#,
1222 slug = html_escape(&fk.target_admin_name),
1223 id = html_escape(&raw),
1224 label = html_escape(label),
1225 ),
1226 None => format!("#{}", html_escape(&raw)),
1227 }
1228 } else if is_status_field_name(&col.name) {
1229 if raw.is_empty() {
1240 return String::new();
1241 }
1242 let (data_value, label) = normalize_status_pill(&raw);
1243 format!(
1244 r#"<span class="badge-status" data-status="{value}">{label}</span>"#,
1245 value = html_escape(&data_value),
1246 label = html_escape(&label),
1247 )
1248 } else {
1249 html_escape(&raw)
1250 }
1251 })
1252 .collect();
1253 RowView {
1254 id: id.clone(),
1255 cells,
1256 edit_url: format!("/admin/{slug}/{id}/edit"),
1257 delete_url: format!("/admin/{slug}/{id}/delete"),
1258 }
1259 })
1260 .collect();
1261
1262 let pagination = build_pagination_view(
1263 slug,
1264 query,
1265 current_page,
1266 total_pages,
1267 total,
1268 &validated_sort,
1269 &validated_dir,
1270 );
1271
1272 let model_view = ModelView {
1273 display_name: format!("{}s", model.model_name()),
1274 singular_name: model.model_name().to_string(),
1275 new_url: format!("/admin/{slug}/new"),
1276 };
1277
1278 let signed_in = identity.is_some();
1282 let permissions = ListPermissionsView {
1283 view: true,
1284 create: signed_in,
1285 edit: signed_in,
1286 delete: signed_in,
1287 };
1288
1289 let design = design_view();
1290 let user = user_view(identity);
1291
1292 let env = crate::admin::templating::env();
1293 match env.get_template("admin/list.html").and_then(|tmpl| {
1294 tmpl.render(minijinja::context! {
1295 design => design,
1296 current_user => user,
1297 sidebar_entries => sidebar,
1298 model => model_view,
1299 columns => columns,
1300 rows => rows,
1301 total => total,
1302 pagination => pagination,
1303 permissions => permissions,
1304 page_title => format!("{}s", model.model_name()),
1305 query => query.unwrap_or(""),
1306 csrf_token => csrf_token.unwrap_or(""),
1307 rustio_version => env!("CARGO_PKG_VERSION"),
1308 })
1309 }) {
1310 Ok(html) => html,
1311 Err(err) => {
1312 eprintln!("admin list template render failed: {err}");
1313 list_fallback(model, &rows_raw, &columns)
1314 }
1315 }
1316}
1317
1318fn build_pagination_view(
1319 slug: &str,
1320 query: Option<&str>,
1321 current: i64,
1322 pages: i64,
1323 total: i64,
1324 sort: &Option<String>,
1325 dir: &Option<String>,
1326) -> PaginationView {
1327 let per_page: i64 = 20;
1330 let from = if total == 0 {
1331 0
1332 } else {
1333 (current - 1) * per_page + 1
1334 };
1335 let to = (current * per_page).min(total).max(from);
1336 if pages <= 1 {
1337 return PaginationView {
1338 pages,
1339 current,
1340 per_page,
1341 total,
1342 from,
1343 to,
1344 links: Vec::new(),
1345 };
1346 }
1347 let q_param = query.unwrap_or("");
1348 let sort_param = sort.as_deref().unwrap_or("");
1349 let dir_param = dir.as_deref().unwrap_or("");
1350 let base_href = |p: i64| -> String {
1351 let mut parts = vec![format!("page={p}")];
1352 if !q_param.is_empty() {
1353 parts.push(format!("q={}", urlencode(q_param)));
1354 }
1355 if !sort_param.is_empty() {
1356 parts.push(format!("sort={sort_param}"));
1357 }
1358 if !dir_param.is_empty() {
1359 parts.push(format!("dir={dir_param}"));
1360 }
1361 format!("/admin/{slug}?{}", parts.join("&"))
1362 };
1363
1364 let mut links = Vec::with_capacity(pages as usize + 2);
1365 links.push(PageLinkView {
1366 label: "‹ Prev".into(),
1367 href: if current > 1 {
1368 base_href(current - 1)
1369 } else {
1370 "#".into()
1371 },
1372 active: false,
1373 disabled: current <= 1,
1374 });
1375 for p in 1..=pages {
1376 links.push(PageLinkView {
1377 label: p.to_string(),
1378 href: base_href(p),
1379 active: p == current,
1380 disabled: false,
1381 });
1382 }
1383 links.push(PageLinkView {
1384 label: "Next ›".into(),
1385 href: if current < pages {
1386 base_href(current + 1)
1387 } else {
1388 "#".into()
1389 },
1390 active: false,
1391 disabled: current >= pages,
1392 });
1393
1394 PaginationView {
1395 pages,
1396 current,
1397 per_page,
1398 total,
1399 from,
1400 to,
1401 links,
1402 }
1403}
1404
1405fn urlencode(s: &str) -> String {
1409 let mut out = String::with_capacity(s.len());
1410 for b in s.bytes() {
1411 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
1412 out.push(b as char);
1413 } else {
1414 out.push_str(&format!("%{b:02X}"));
1415 }
1416 }
1417 out
1418}
1419
1420fn list_fallback(
1421 model: &dyn AdminUiModel,
1422 rows: &[HashMap<String, String>],
1423 columns: &[ColumnView],
1424) -> String {
1425 let mut out = format!(
1426 "<!doctype html><html><head><meta charset=\"utf-8\"><title>{} - list</title></head><body style=\"font-family:system-ui\"><h1>{}s</h1><table border=\"1\" cellpadding=\"6\"><tr>",
1427 html_escape(model.model_name()),
1428 html_escape(model.model_name()),
1429 );
1430 for c in columns {
1431 out.push_str(&format!("<th>{}</th>", html_escape(&c.label)));
1432 }
1433 out.push_str("</tr>");
1434 for row in rows {
1435 out.push_str("<tr>");
1436 for c in columns {
1437 let v = row.get(&c.name).cloned().unwrap_or_default();
1438 out.push_str(&format!("<td>{}</td>", html_escape(&v)));
1439 }
1440 out.push_str("</tr>");
1441 }
1442 out.push_str("</table></body></html>");
1443 out
1444}
1445
1446#[derive(serde::Serialize)]
1447struct ProfileView {
1448 email: String,
1449 user_id: i64,
1450 role: String,
1451 is_active: bool,
1452}
1453
1454pub async fn profile_render(
1458 db: &Db,
1459 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1460 legacy_entries: &[crate::admin::AdminEntry],
1461 identity: Option<&crate::auth::Identity>,
1462 user: Option<&crate::auth::User>,
1463 csrf_token: Option<&str>,
1464) -> String {
1465 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1466 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1467
1468 let profile = match user {
1469 Some(u) => ProfileView {
1470 email: u.email.clone(),
1471 user_id: u.id,
1472 role: u.role.clone(),
1473 is_active: u.is_active,
1474 },
1475 None => ProfileView {
1476 email: "unknown".into(),
1477 user_id: 0,
1478 role: "?".into(),
1479 is_active: false,
1480 },
1481 };
1482
1483 let design = design_view();
1484 let user_v = user_view(identity);
1485
1486 let env = crate::admin::templating::env();
1487 match env.get_template("admin/profile.html").and_then(|tmpl| {
1488 tmpl.render(minijinja::context! {
1489 design => design,
1490 current_user => user_v,
1491 sidebar_entries => sidebar,
1492 profile => profile,
1493 page_title => "Your account",
1494 csrf_token => csrf_token.unwrap_or(""),
1495 rustio_version => env!("CARGO_PKG_VERSION"),
1496 })
1497 }) {
1498 Ok(html) => html,
1499 Err(err) => {
1500 eprintln!("admin profile template render failed: {err}");
1501 format!(
1502 "<!doctype html><html><head><meta charset=\"utf-8\"><title>Your account</title></head><body><h1>Your account</h1><p>Email: {}</p><p><a href=\"/admin\">Back</a></p></body></html>",
1503 html_escape(&profile.email),
1504 )
1505 }
1506 }
1507}
1508
1509#[derive(serde::Serialize)]
1510struct ActionRowView {
1511 timestamp: String,
1512 user_email: Option<String>,
1513 action_type: String,
1514 model_name: String,
1515 object_id: i64,
1516 object_url: Option<String>,
1517 summary: String,
1518}
1519
1520#[derive(serde::Serialize)]
1521struct OptionView {
1522 value: String,
1523 label: String,
1524 selected: bool,
1525}
1526
1527#[allow(clippy::too_many_arguments)]
1529pub async fn actions_render(
1530 db: &Db,
1531 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1532 legacy_entries: &[crate::admin::AdminEntry],
1533 identity: Option<&crate::auth::Identity>,
1534 csrf_token: Option<&str>,
1535 actions: &[crate::admin::audit::AdminAction],
1536 model_filter: Option<&str>,
1537 action_filter: Option<&str>,
1538) -> String {
1539 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1540 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1541 let design = design_view();
1542 let user_v = user_view(identity);
1543
1544 let model_options: Vec<OptionView> = legacy_entries
1545 .iter()
1546 .filter(|e| !e.core)
1547 .map(|e| OptionView {
1548 value: e.admin_name.to_string(),
1549 label: e.display_name.to_string(),
1550 selected: model_filter == Some(e.admin_name),
1551 })
1552 .collect();
1553
1554 let action_options: Vec<OptionView> = [
1555 ("", "All actions"),
1556 ("create", "Created"),
1557 ("update", "Updated"),
1558 ("delete", "Deleted"),
1559 ]
1560 .into_iter()
1561 .map(|(v, l)| OptionView {
1562 value: v.to_string(),
1563 label: l.to_string(),
1564 selected: match v {
1565 "" => action_filter.is_none(),
1566 other => action_filter == Some(other),
1567 },
1568 })
1569 .collect();
1570
1571 let action_rows: Vec<ActionRowView> = actions
1572 .iter()
1573 .map(|a| {
1574 let object_url = legacy_entries
1575 .iter()
1576 .find(|e| e.singular_name == a.model_name || e.display_name == a.model_name)
1577 .map(|e| format!("/admin/{}/{}/edit", e.admin_name, a.object_id));
1578 ActionRowView {
1579 timestamp: a.timestamp.format("%Y-%m-%d %H:%M UTC").to_string(),
1580 user_email: a.user_email.clone(),
1581 action_type: a.action_type.clone(),
1582 model_name: a.model_name.clone(),
1583 object_id: a.object_id,
1584 object_url,
1585 summary: a.summary.clone(),
1586 }
1587 })
1588 .collect();
1589
1590 let count_label = if actions.len() == 1 {
1591 "1 action".to_string()
1592 } else {
1593 format!("{} actions", actions.len())
1594 };
1595 let filters_active = model_filter.is_some() || action_filter.is_some();
1596
1597 let env = crate::admin::templating::env();
1598 match env.get_template("admin/actions.html").and_then(|tmpl| {
1599 tmpl.render(minijinja::context! {
1600 design => design,
1601 current_user => user_v,
1602 sidebar_entries => sidebar,
1603 page_title => "Recent actions",
1604 csrf_token => csrf_token.unwrap_or(""),
1605 rustio_version => env!("CARGO_PKG_VERSION"),
1606 actions => action_rows,
1607 model_options => model_options,
1608 action_options => action_options,
1609 filters_active => filters_active,
1610 count_label => count_label,
1611 })
1612 }) {
1613 Ok(html) => html,
1614 Err(err) => {
1615 eprintln!("admin actions template render failed: {err}");
1616 "<!doctype html><html><body><h1>Recent actions</h1><p>Template failed.</p></body></html>".into()
1617 }
1618 }
1619}
1620
1621#[derive(serde::Serialize)]
1622pub struct SuggestionReviewView {
1623 pub model: String,
1624 pub field: String,
1625 pub industry: String,
1626 pub confidence_label: String,
1627 pub confidence_class: String,
1628 pub apply_url: String,
1629 pub can_apply: bool,
1630 pub step_descriptions: Vec<String>,
1631 pub schema_diff_html: String,
1632 pub explanation: String,
1633 pub risk_label: String,
1634 pub risk_class: String,
1635 pub adds_fields: u32,
1636 pub destructive: bool,
1637 pub validation_ok: bool,
1638 pub validation_message: Option<String>,
1639 pub warnings: Vec<String>,
1640 pub error: Option<String>,
1641}
1642
1643#[derive(serde::Serialize)]
1644pub struct AppliedFileView {
1645 pub kind: String,
1646 pub path: String,
1647}
1648
1649#[derive(serde::Serialize)]
1650pub struct SuggestionAppliedView {
1651 pub change_lines: Vec<String>,
1652 pub files: Vec<AppliedFileView>,
1653}
1654
1655pub async fn suggestion_review_render(
1659 db: &Db,
1660 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1661 legacy_entries: &[crate::admin::AdminEntry],
1662 identity: Option<&crate::auth::Identity>,
1663 csrf_token: Option<&str>,
1664 view: SuggestionReviewView,
1665) -> String {
1666 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1667 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1668 let design = design_view();
1669 let user_v = user_view(identity);
1670 let env = crate::admin::templating::env();
1671 match env
1672 .get_template("admin/suggestion_review.html")
1673 .and_then(|tmpl| {
1674 tmpl.render(minijinja::context! {
1675 design => design,
1676 current_user => user_v,
1677 sidebar_entries => sidebar,
1678 page_title => format!("Review: add {} to {}", view.field, view.model),
1679 csrf_token => csrf_token.unwrap_or(""),
1680 rustio_version => env!("CARGO_PKG_VERSION"),
1681 view => view,
1682 })
1683 }) {
1684 Ok(html) => html,
1685 Err(err) => {
1686 eprintln!("admin suggestion_review template render failed: {err}");
1687 "<!doctype html><html><body><h1>Review suggestion</h1><p>Template failed.</p></body></html>".into()
1688 }
1689 }
1690}
1691
1692pub async fn suggestion_applied_render(
1695 db: &Db,
1696 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1697 legacy_entries: &[crate::admin::AdminEntry],
1698 identity: Option<&crate::auth::Identity>,
1699 csrf_token: Option<&str>,
1700 applied: SuggestionAppliedView,
1701) -> String {
1702 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1703 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1704 let design = design_view();
1705 let user_v = user_view(identity);
1706 let env = crate::admin::templating::env();
1707 match env
1708 .get_template("admin/suggestion_applied.html")
1709 .and_then(|tmpl| {
1710 tmpl.render(minijinja::context! {
1711 design => design,
1712 current_user => user_v,
1713 sidebar_entries => sidebar,
1714 page_title => "Changes applied",
1715 csrf_token => csrf_token.unwrap_or(""),
1716 rustio_version => env!("CARGO_PKG_VERSION"),
1717 applied => applied,
1718 })
1719 }) {
1720 Ok(html) => html,
1721 Err(err) => {
1722 eprintln!("admin suggestion_applied template render failed: {err}");
1723 "<!doctype html><html><body><h1>Changes applied</h1><p>Template failed.</p></body></html>".into()
1724 }
1725 }
1726}
1727
1728pub async fn password_change_render(
1731 db: &Db,
1732 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1733 legacy_entries: &[crate::admin::AdminEntry],
1734 identity: Option<&crate::auth::Identity>,
1735 csrf_token: Option<&str>,
1736 error: Option<&str>,
1737) -> String {
1738 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1739 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1740 let design = design_view();
1741 let user_v = user_view(identity);
1742 let env = crate::admin::templating::env();
1743 match env
1744 .get_template("admin/password_change.html")
1745 .and_then(|tmpl| {
1746 tmpl.render(minijinja::context! {
1747 design => design,
1748 current_user => user_v,
1749 sidebar_entries => sidebar,
1750 page_title => "Change password",
1751 csrf_token => csrf_token.unwrap_or(""),
1752 error => error,
1753 rustio_version => env!("CARGO_PKG_VERSION"),
1754 })
1755 }) {
1756 Ok(html) => html,
1757 Err(err) => {
1758 eprintln!("admin password_change template render failed: {err}");
1759 "<!doctype html><html><body><h1>Change password</h1><p>Template failed.</p></body></html>".into()
1760 }
1761 }
1762}
1763
1764pub async fn password_change_done_render(
1766 db: &Db,
1767 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1768 legacy_entries: &[crate::admin::AdminEntry],
1769 identity: Option<&crate::auth::Identity>,
1770 csrf_token: Option<&str>,
1771) -> String {
1772 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1773 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1774 let design = design_view();
1775 let user_v = user_view(identity);
1776 let env = crate::admin::templating::env();
1777 match env
1778 .get_template("admin/password_change_done.html")
1779 .and_then(|tmpl| {
1780 tmpl.render(minijinja::context! {
1781 design => design,
1782 current_user => user_v,
1783 sidebar_entries => sidebar,
1784 page_title => "Password changed",
1785 csrf_token => csrf_token.unwrap_or(""),
1786 rustio_version => env!("CARGO_PKG_VERSION"),
1787 })
1788 }) {
1789 Ok(html) => html,
1790 Err(err) => {
1791 eprintln!("admin password_change_done template render failed: {err}");
1792 "<!doctype html><html><body><h1>Password changed</h1><p><a href=\"/admin\">Back</a></p></body></html>".into()
1793 }
1794 }
1795}
1796
1797fn is_status_field_name(name: &str) -> bool {
1808 let n = name.to_lowercase();
1809 n == "status"
1810 || n == "state"
1811 || n == "active"
1812 || n == "published"
1813 || n.ends_with("_status")
1814 || n.ends_with("_state")
1815 || n.starts_with("is_")
1816 || n.starts_with("has_")
1817}
1818
1819fn normalize_status_pill(raw: &str) -> (String, String) {
1836 let lc = raw.trim().to_lowercase();
1837 match lc.as_str() {
1838 "1" | "true" | "yes" | "on" => ("active".to_string(), "Active".to_string()),
1839 "0" | "false" | "no" | "off" => ("inactive".to_string(), "Inactive".to_string()),
1840 _ => (lc.clone(), humanize_status_label(raw)),
1841 }
1842}
1843
1844fn humanize_status_label(raw: &str) -> String {
1850 let spaced = raw.trim().replace('_', " ").to_lowercase();
1851 let mut chars = spaced.chars();
1852 match chars.next() {
1853 None => String::new(),
1854 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1855 }
1856}
1857
1858fn humanize_field_label(raw: &str) -> String {
1877 if raw == "id" {
1878 return "ID".to_string();
1879 }
1880 if !raw.contains('_') && raw.chars().next().is_some_and(|c| c.is_uppercase()) {
1881 return raw.to_string();
1882 }
1883 let stripped = raw.strip_suffix("_id").unwrap_or(raw);
1884 let spaced = stripped.replace('_', " ").to_lowercase();
1885 let mut chars = spaced.chars();
1886 match chars.next() {
1887 None => String::new(),
1888 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1889 }
1890}
1891
1892fn dashboard_fallback(entries: &[DashboardEntry]) -> String {
1893 let mut out = String::from(
1894 "<!doctype html><html><head><meta charset=\"utf-8\"><title>Dashboard</title></head><body style=\"font-family:system-ui\"><h1>Dashboard</h1><ul>",
1895 );
1896 for e in entries {
1897 out.push_str(&format!(
1898 "<li><a href=\"/admin/{}\">{}</a> ({})</li>",
1899 html_escape(e.slug),
1900 html_escape(e.model_name),
1901 e.count
1902 ));
1903 }
1904 out.push_str("</ul></body></html>");
1905 out
1906}
1907
1908#[cfg(test)]
1909mod tests {
1910 use super::*;
1911 use crate::admin::{AdminEntry, AdminField, FieldType};
1912
1913 fn entry(
1917 admin: &'static str,
1918 singular: &'static str,
1919 table: &'static str,
1920 core: bool,
1921 ) -> AdminEntry {
1922 const NO_FIELDS: &[AdminField] = &[AdminField {
1923 name: "id",
1924 ty: FieldType::I64,
1925 editable: false,
1926 nullable: false,
1927 relation: None,
1928 }];
1929 AdminEntry {
1930 admin_name: admin,
1931 display_name: singular,
1932 singular_name: singular,
1933 table,
1934 fields: NO_FIELDS,
1935 core,
1936 }
1937 }
1938
1939 #[tokio::test]
1940 async fn legacy_dashboard_walk_returns_one_entry_per_non_core_model() {
1941 let db = Db::memory().await.unwrap();
1942 sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
1943 .execute(db.pool())
1944 .await
1945 .unwrap();
1946 sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY)")
1947 .execute(db.pool())
1948 .await
1949 .unwrap();
1950 sqlx::query("INSERT INTO projects DEFAULT VALUES")
1951 .execute(db.pool())
1952 .await
1953 .unwrap();
1954 sqlx::query("INSERT INTO projects DEFAULT VALUES")
1955 .execute(db.pool())
1956 .await
1957 .unwrap();
1958 sqlx::query("INSERT INTO tasks DEFAULT VALUES")
1959 .execute(db.pool())
1960 .await
1961 .unwrap();
1962
1963 let legacy = [
1964 entry("projects", "Project", "projects", false),
1965 entry("tasks", "Task", "tasks", false),
1966 ];
1967 let known = std::collections::HashSet::new();
1968
1969 let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
1970
1971 assert_eq!(got.len(), 2);
1972 assert_eq!(got[0].slug, "projects");
1973 assert_eq!(got[0].count, 2);
1974 assert_eq!(got[1].slug, "tasks");
1975 assert_eq!(got[1].count, 1);
1976 }
1977
1978 #[tokio::test]
1979 async fn legacy_dashboard_walk_skips_core_entries() {
1980 let db = Db::memory().await.unwrap();
1981 let legacy = [
1983 entry("rustio_users", "User", "rustio_users", true),
1984 entry("projects", "Project", "projects", false),
1985 ];
1986 sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
1987 .execute(db.pool())
1988 .await
1989 .unwrap();
1990
1991 let known = std::collections::HashSet::new();
1992 let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
1993
1994 assert_eq!(got.len(), 1, "core entry should be skipped");
1995 assert_eq!(got[0].slug, "projects");
1996 }
1997
1998 #[tokio::test]
1999 async fn legacy_dashboard_walk_dedupes_against_already_listed_slugs() {
2000 let db = Db::memory().await.unwrap();
2001 sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
2002 .execute(db.pool())
2003 .await
2004 .unwrap();
2005 sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY)")
2006 .execute(db.pool())
2007 .await
2008 .unwrap();
2009
2010 let mut known = std::collections::HashSet::new();
2012 known.insert("projects");
2013
2014 let legacy = [
2015 entry("projects", "Project", "projects", false),
2016 entry("tasks", "Task", "tasks", false),
2017 ];
2018
2019 let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2020
2021 assert_eq!(got.len(), 1, "already-listed slug should be skipped");
2022 assert_eq!(got[0].slug, "tasks");
2023 }
2024
2025 #[tokio::test]
2026 async fn legacy_dashboard_walk_falls_back_to_zero_when_table_missing() {
2027 let db = Db::memory().await.unwrap();
2028 let legacy = [entry("ghosts", "Ghost", "ghosts", false)];
2031 let known = std::collections::HashSet::new();
2032
2033 let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2034
2035 assert_eq!(got.len(), 1);
2036 assert_eq!(got[0].count, 0);
2037 }
2038
2039 #[test]
2040 fn status_field_name_matches_known_patterns() {
2041 assert!(is_status_field_name("status"));
2043 assert!(is_status_field_name("state"));
2044 assert!(is_status_field_name("active"));
2045 assert!(is_status_field_name("published"));
2046 assert!(is_status_field_name("Status"));
2048 assert!(is_status_field_name("STATE"));
2049 assert!(is_status_field_name("task_status"));
2051 assert!(is_status_field_name("order_state"));
2052 assert!(is_status_field_name("is_active"));
2054 assert!(is_status_field_name("is_published"));
2055 assert!(is_status_field_name("has_paid"));
2056 }
2057
2058 #[test]
2059 fn status_field_name_rejects_non_status_columns() {
2060 assert!(!is_status_field_name("title"));
2062 assert!(!is_status_field_name("description"));
2063 assert!(!is_status_field_name("name"));
2064 assert!(!is_status_field_name("priority"));
2066 assert!(!is_status_field_name("count"));
2067 assert!(!is_status_field_name("created_at"));
2069 assert!(!is_status_field_name("due_at"));
2070 assert!(!is_status_field_name("project_id"));
2072 assert!(!is_status_field_name("user_id"));
2073 assert!(!is_status_field_name("statustown"));
2075 assert!(!is_status_field_name("estatus_id"));
2076 }
2077
2078 #[test]
2079 fn normalize_status_pill_maps_boolean_encodings() {
2080 for raw in ["1", "true", "TRUE", " True ", "yes", "on"] {
2082 let (data, label) = normalize_status_pill(raw);
2083 assert_eq!(
2084 data, "active",
2085 "truthy raw {raw:?} should map to data=active"
2086 );
2087 assert_eq!(label, "Active", "truthy raw {raw:?} should label as Active");
2088 }
2089 for raw in ["0", "false", "FALSE", "no", "off"] {
2091 let (data, label) = normalize_status_pill(raw);
2092 assert_eq!(
2093 data, "inactive",
2094 "falsy raw {raw:?} should map to data=inactive"
2095 );
2096 assert_eq!(
2097 label, "Inactive",
2098 "falsy raw {raw:?} should label as Inactive"
2099 );
2100 }
2101 }
2102
2103 #[test]
2104 fn normalize_status_pill_humanizes_string_statuses() {
2105 let (data, label) = normalize_status_pill("In_Progress");
2109 assert_eq!(data, "in_progress");
2110 assert_eq!(label, "In progress");
2111
2112 let (data, label) = normalize_status_pill("DONE");
2113 assert_eq!(data, "done");
2114 assert_eq!(label, "Done");
2115
2116 let (data, label) = normalize_status_pill("todo");
2117 assert_eq!(data, "todo");
2118 assert_eq!(label, "Todo");
2119
2120 let (data, label) = normalize_status_pill("review");
2121 assert_eq!(data, "review");
2122 assert_eq!(label, "Review");
2123
2124 let (data, label) = normalize_status_pill("custom_state");
2126 assert_eq!(data, "custom_state");
2127 assert_eq!(label, "Custom state");
2128 }
2129
2130 #[test]
2131 fn humanize_status_label_handles_edges() {
2132 assert_eq!(humanize_status_label(""), "");
2133 assert_eq!(humanize_status_label("a"), "A");
2134 assert_eq!(humanize_status_label(" trim "), "Trim");
2135 assert_eq!(
2136 humanize_status_label("multi_word_status"),
2137 "Multi word status"
2138 );
2139 }
2140
2141 #[test]
2142 fn humanize_field_label_cases() {
2143 assert_eq!(humanize_field_label(""), "");
2144 assert_eq!(humanize_field_label("id"), "ID");
2145 assert_eq!(humanize_field_label("title"), "Title");
2146 assert_eq!(humanize_field_label("project_id"), "Project");
2147 assert_eq!(humanize_field_label("user_id"), "User");
2148 assert_eq!(humanize_field_label("due_at"), "Due at");
2149 assert_eq!(humanize_field_label("created_at"), "Created at");
2150 assert_eq!(humanize_field_label("first_name"), "First name");
2151 assert_eq!(humanize_field_label("Username"), "Username");
2154 assert_eq!(humanize_field_label("User ID"), "User ID");
2155 assert_eq!(
2157 humanize_field_label(&humanize_field_label("due_at")),
2158 "Due at"
2159 );
2160 }
2161}