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 primary_col = columns
1206 .iter()
1207 .find(|c| c.name.as_str() != pk)
1208 .map(|c| c.name.clone());
1209 let rows: Vec<RowView> = rows_raw
1210 .iter()
1211 .map(|row| {
1212 let id = row.get(pk).cloned().unwrap_or_default();
1213 let cells = columns
1214 .iter()
1215 .enumerate()
1216 .map(|(col_idx, col)| {
1217 let raw = row.get(&col.name).cloned().unwrap_or_default();
1218 if col.name.as_str() == pk {
1219 if raw.is_empty() {
1221 return String::new();
1222 }
1223 return format!(
1224 r#"<span class="rio-cell-id">#{}</span>"#,
1225 html_escape(&raw)
1226 );
1227 }
1228 if let Some(fk) = fk_lookups.iter().find(|f| f.column_index == col_idx) {
1229 if raw.is_empty() {
1232 return String::new();
1233 }
1234 match fk.id_to_label.get(&raw) {
1235 Some(label) => format!(
1236 r#"<a href="/admin/{slug}/{id}">{label}</a>"#,
1237 slug = html_escape(&fk.target_admin_name),
1238 id = html_escape(&raw),
1239 label = html_escape(label),
1240 ),
1241 None => format!("#{}", html_escape(&raw)),
1242 }
1243 } else if is_status_field_name(&col.name) {
1244 if raw.is_empty() {
1248 return String::new();
1249 }
1250 let (data_value, label) = normalize_status_pill(&raw);
1251 format!(
1252 r#"<span class="{cls}">{label}</span>"#,
1253 cls = status_pill_color(&data_value),
1254 label = html_escape(&label),
1255 )
1256 } else if primary_col.as_deref() == Some(col.name.as_str()) {
1257 format!(
1259 r#"<span class="rio-cell-primary">{}</span>"#,
1260 html_escape(&raw)
1261 )
1262 } else {
1263 html_escape(&raw)
1264 }
1265 })
1266 .collect();
1267 RowView {
1268 id: id.clone(),
1269 cells,
1270 edit_url: format!("/admin/{slug}/{id}/edit"),
1271 delete_url: format!("/admin/{slug}/{id}/delete"),
1272 }
1273 })
1274 .collect();
1275
1276 let pagination = build_pagination_view(
1277 slug,
1278 query,
1279 current_page,
1280 total_pages,
1281 total,
1282 &validated_sort,
1283 &validated_dir,
1284 );
1285
1286 let model_view = ModelView {
1287 display_name: format!("{}s", model.model_name()),
1288 singular_name: model.model_name().to_string(),
1289 new_url: format!("/admin/{slug}/new"),
1290 };
1291
1292 let signed_in = identity.is_some();
1296 let permissions = ListPermissionsView {
1297 view: true,
1298 create: signed_in,
1299 edit: signed_in,
1300 delete: signed_in,
1301 };
1302
1303 let design = design_view();
1304 let user = user_view(identity);
1305
1306 let env = crate::admin::templating::env();
1307 match env.get_template("admin/list.html").and_then(|tmpl| {
1308 tmpl.render(minijinja::context! {
1309 design => design,
1310 current_user => user,
1311 sidebar_entries => sidebar,
1312 model => model_view,
1313 columns => columns,
1314 rows => rows,
1315 total => total,
1316 pagination => pagination,
1317 permissions => permissions,
1318 page_title => format!("{}s", model.model_name()),
1319 query => query.unwrap_or(""),
1320 csrf_token => csrf_token.unwrap_or(""),
1321 rustio_version => env!("CARGO_PKG_VERSION"),
1322 })
1323 }) {
1324 Ok(html) => html,
1325 Err(err) => {
1326 eprintln!("admin list template render failed: {err}");
1327 list_fallback(model, &rows_raw, &columns)
1328 }
1329 }
1330}
1331
1332fn build_pagination_view(
1333 slug: &str,
1334 query: Option<&str>,
1335 current: i64,
1336 pages: i64,
1337 total: i64,
1338 sort: &Option<String>,
1339 dir: &Option<String>,
1340) -> PaginationView {
1341 let per_page: i64 = 20;
1344 let from = if total == 0 {
1345 0
1346 } else {
1347 (current - 1) * per_page + 1
1348 };
1349 let to = (current * per_page).min(total).max(from);
1350 if pages <= 1 {
1351 return PaginationView {
1352 pages,
1353 current,
1354 per_page,
1355 total,
1356 from,
1357 to,
1358 links: Vec::new(),
1359 };
1360 }
1361 let q_param = query.unwrap_or("");
1362 let sort_param = sort.as_deref().unwrap_or("");
1363 let dir_param = dir.as_deref().unwrap_or("");
1364 let base_href = |p: i64| -> String {
1365 let mut parts = vec![format!("page={p}")];
1366 if !q_param.is_empty() {
1367 parts.push(format!("q={}", urlencode(q_param)));
1368 }
1369 if !sort_param.is_empty() {
1370 parts.push(format!("sort={sort_param}"));
1371 }
1372 if !dir_param.is_empty() {
1373 parts.push(format!("dir={dir_param}"));
1374 }
1375 format!("/admin/{slug}?{}", parts.join("&"))
1376 };
1377
1378 let mut links = Vec::with_capacity(pages as usize + 2);
1379 links.push(PageLinkView {
1380 label: "‹ Prev".into(),
1381 href: if current > 1 {
1382 base_href(current - 1)
1383 } else {
1384 "#".into()
1385 },
1386 active: false,
1387 disabled: current <= 1,
1388 });
1389 for p in 1..=pages {
1390 links.push(PageLinkView {
1391 label: p.to_string(),
1392 href: base_href(p),
1393 active: p == current,
1394 disabled: false,
1395 });
1396 }
1397 links.push(PageLinkView {
1398 label: "Next ›".into(),
1399 href: if current < pages {
1400 base_href(current + 1)
1401 } else {
1402 "#".into()
1403 },
1404 active: false,
1405 disabled: current >= pages,
1406 });
1407
1408 PaginationView {
1409 pages,
1410 current,
1411 per_page,
1412 total,
1413 from,
1414 to,
1415 links,
1416 }
1417}
1418
1419fn urlencode(s: &str) -> String {
1423 let mut out = String::with_capacity(s.len());
1424 for b in s.bytes() {
1425 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
1426 out.push(b as char);
1427 } else {
1428 out.push_str(&format!("%{b:02X}"));
1429 }
1430 }
1431 out
1432}
1433
1434fn list_fallback(
1435 model: &dyn AdminUiModel,
1436 rows: &[HashMap<String, String>],
1437 columns: &[ColumnView],
1438) -> String {
1439 let mut out = format!(
1440 "<!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>",
1441 html_escape(model.model_name()),
1442 html_escape(model.model_name()),
1443 );
1444 for c in columns {
1445 out.push_str(&format!("<th>{}</th>", html_escape(&c.label)));
1446 }
1447 out.push_str("</tr>");
1448 for row in rows {
1449 out.push_str("<tr>");
1450 for c in columns {
1451 let v = row.get(&c.name).cloned().unwrap_or_default();
1452 out.push_str(&format!("<td>{}</td>", html_escape(&v)));
1453 }
1454 out.push_str("</tr>");
1455 }
1456 out.push_str("</table></body></html>");
1457 out
1458}
1459
1460#[derive(serde::Serialize)]
1461struct ProfileView {
1462 email: String,
1463 user_id: i64,
1464 role: String,
1465 is_active: bool,
1466}
1467
1468pub async fn profile_render(
1472 db: &Db,
1473 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1474 legacy_entries: &[crate::admin::AdminEntry],
1475 identity: Option<&crate::auth::Identity>,
1476 user: Option<&crate::auth::User>,
1477 csrf_token: Option<&str>,
1478) -> String {
1479 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1480 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1481
1482 let profile = match user {
1483 Some(u) => ProfileView {
1484 email: u.email.clone(),
1485 user_id: u.id,
1486 role: u.role.clone(),
1487 is_active: u.is_active,
1488 },
1489 None => ProfileView {
1490 email: "unknown".into(),
1491 user_id: 0,
1492 role: "?".into(),
1493 is_active: false,
1494 },
1495 };
1496
1497 let design = design_view();
1498 let user_v = user_view(identity);
1499
1500 let env = crate::admin::templating::env();
1501 match env.get_template("admin/profile.html").and_then(|tmpl| {
1502 tmpl.render(minijinja::context! {
1503 design => design,
1504 current_user => user_v,
1505 sidebar_entries => sidebar,
1506 profile => profile,
1507 page_title => "Your account",
1508 csrf_token => csrf_token.unwrap_or(""),
1509 rustio_version => env!("CARGO_PKG_VERSION"),
1510 })
1511 }) {
1512 Ok(html) => html,
1513 Err(err) => {
1514 eprintln!("admin profile template render failed: {err}");
1515 format!(
1516 "<!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>",
1517 html_escape(&profile.email),
1518 )
1519 }
1520 }
1521}
1522
1523#[derive(serde::Serialize)]
1524struct ActionRowView {
1525 timestamp: String,
1526 user_email: Option<String>,
1527 action_type: String,
1528 model_name: String,
1529 object_id: i64,
1530 object_url: Option<String>,
1531 summary: String,
1532}
1533
1534#[derive(serde::Serialize)]
1535struct OptionView {
1536 value: String,
1537 label: String,
1538 selected: bool,
1539}
1540
1541#[allow(clippy::too_many_arguments)]
1543pub async fn actions_render(
1544 db: &Db,
1545 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1546 legacy_entries: &[crate::admin::AdminEntry],
1547 identity: Option<&crate::auth::Identity>,
1548 csrf_token: Option<&str>,
1549 actions: &[crate::admin::audit::AdminAction],
1550 model_filter: Option<&str>,
1551 action_filter: Option<&str>,
1552) -> String {
1553 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1554 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1555 let design = design_view();
1556 let user_v = user_view(identity);
1557
1558 let model_options: Vec<OptionView> = legacy_entries
1559 .iter()
1560 .filter(|e| !e.core)
1561 .map(|e| OptionView {
1562 value: e.admin_name.to_string(),
1563 label: e.display_name.to_string(),
1564 selected: model_filter == Some(e.admin_name),
1565 })
1566 .collect();
1567
1568 let action_options: Vec<OptionView> = [
1569 ("", "All actions"),
1570 ("create", "Created"),
1571 ("update", "Updated"),
1572 ("delete", "Deleted"),
1573 ]
1574 .into_iter()
1575 .map(|(v, l)| OptionView {
1576 value: v.to_string(),
1577 label: l.to_string(),
1578 selected: match v {
1579 "" => action_filter.is_none(),
1580 other => action_filter == Some(other),
1581 },
1582 })
1583 .collect();
1584
1585 let action_rows: Vec<ActionRowView> = actions
1586 .iter()
1587 .map(|a| {
1588 let object_url = legacy_entries
1589 .iter()
1590 .find(|e| e.singular_name == a.model_name || e.display_name == a.model_name)
1591 .map(|e| format!("/admin/{}/{}/edit", e.admin_name, a.object_id));
1592 ActionRowView {
1593 timestamp: a.timestamp.format("%Y-%m-%d %H:%M UTC").to_string(),
1594 user_email: a.user_email.clone(),
1595 action_type: a.action_type.clone(),
1596 model_name: a.model_name.clone(),
1597 object_id: a.object_id,
1598 object_url,
1599 summary: a.summary.clone(),
1600 }
1601 })
1602 .collect();
1603
1604 let count_label = if actions.len() == 1 {
1605 "1 action".to_string()
1606 } else {
1607 format!("{} actions", actions.len())
1608 };
1609 let filters_active = model_filter.is_some() || action_filter.is_some();
1610
1611 let env = crate::admin::templating::env();
1612 match env.get_template("admin/actions.html").and_then(|tmpl| {
1613 tmpl.render(minijinja::context! {
1614 design => design,
1615 current_user => user_v,
1616 sidebar_entries => sidebar,
1617 page_title => "Recent actions",
1618 csrf_token => csrf_token.unwrap_or(""),
1619 rustio_version => env!("CARGO_PKG_VERSION"),
1620 actions => action_rows,
1621 model_options => model_options,
1622 action_options => action_options,
1623 filters_active => filters_active,
1624 count_label => count_label,
1625 })
1626 }) {
1627 Ok(html) => html,
1628 Err(err) => {
1629 eprintln!("admin actions template render failed: {err}");
1630 "<!doctype html><html><body><h1>Recent actions</h1><p>Template failed.</p></body></html>".into()
1631 }
1632 }
1633}
1634
1635#[derive(serde::Serialize)]
1636pub struct SuggestionReviewView {
1637 pub model: String,
1638 pub field: String,
1639 pub industry: String,
1640 pub confidence_label: String,
1641 pub confidence_class: String,
1642 pub apply_url: String,
1643 pub can_apply: bool,
1644 pub step_descriptions: Vec<String>,
1645 pub schema_diff_html: String,
1646 pub explanation: String,
1647 pub risk_label: String,
1648 pub risk_class: String,
1649 pub adds_fields: u32,
1650 pub destructive: bool,
1651 pub validation_ok: bool,
1652 pub validation_message: Option<String>,
1653 pub warnings: Vec<String>,
1654 pub error: Option<String>,
1655}
1656
1657#[derive(serde::Serialize)]
1658pub struct AppliedFileView {
1659 pub kind: String,
1660 pub path: String,
1661}
1662
1663#[derive(serde::Serialize)]
1664pub struct SuggestionAppliedView {
1665 pub change_lines: Vec<String>,
1666 pub files: Vec<AppliedFileView>,
1667}
1668
1669pub async fn suggestion_review_render(
1673 db: &Db,
1674 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1675 legacy_entries: &[crate::admin::AdminEntry],
1676 identity: Option<&crate::auth::Identity>,
1677 csrf_token: Option<&str>,
1678 view: SuggestionReviewView,
1679) -> String {
1680 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1681 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1682 let design = design_view();
1683 let user_v = user_view(identity);
1684 let env = crate::admin::templating::env();
1685 match env
1686 .get_template("admin/suggestion_review.html")
1687 .and_then(|tmpl| {
1688 tmpl.render(minijinja::context! {
1689 design => design,
1690 current_user => user_v,
1691 sidebar_entries => sidebar,
1692 page_title => format!("Review: add {} to {}", view.field, view.model),
1693 csrf_token => csrf_token.unwrap_or(""),
1694 rustio_version => env!("CARGO_PKG_VERSION"),
1695 view => view,
1696 })
1697 }) {
1698 Ok(html) => html,
1699 Err(err) => {
1700 eprintln!("admin suggestion_review template render failed: {err}");
1701 "<!doctype html><html><body><h1>Review suggestion</h1><p>Template failed.</p></body></html>".into()
1702 }
1703 }
1704}
1705
1706pub async fn suggestion_applied_render(
1709 db: &Db,
1710 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1711 legacy_entries: &[crate::admin::AdminEntry],
1712 identity: Option<&crate::auth::Identity>,
1713 csrf_token: Option<&str>,
1714 applied: SuggestionAppliedView,
1715) -> String {
1716 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1717 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1718 let design = design_view();
1719 let user_v = user_view(identity);
1720 let env = crate::admin::templating::env();
1721 match env
1722 .get_template("admin/suggestion_applied.html")
1723 .and_then(|tmpl| {
1724 tmpl.render(minijinja::context! {
1725 design => design,
1726 current_user => user_v,
1727 sidebar_entries => sidebar,
1728 page_title => "Changes applied",
1729 csrf_token => csrf_token.unwrap_or(""),
1730 rustio_version => env!("CARGO_PKG_VERSION"),
1731 applied => applied,
1732 })
1733 }) {
1734 Ok(html) => html,
1735 Err(err) => {
1736 eprintln!("admin suggestion_applied template render failed: {err}");
1737 "<!doctype html><html><body><h1>Changes applied</h1><p>Template failed.</p></body></html>".into()
1738 }
1739 }
1740}
1741
1742pub async fn password_change_render(
1745 db: &Db,
1746 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1747 legacy_entries: &[crate::admin::AdminEntry],
1748 identity: Option<&crate::auth::Identity>,
1749 csrf_token: Option<&str>,
1750 error: Option<&str>,
1751) -> String {
1752 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1753 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1754 let design = design_view();
1755 let user_v = user_view(identity);
1756 let env = crate::admin::templating::env();
1757 match env
1758 .get_template("admin/password_change.html")
1759 .and_then(|tmpl| {
1760 tmpl.render(minijinja::context! {
1761 design => design,
1762 current_user => user_v,
1763 sidebar_entries => sidebar,
1764 page_title => "Change password",
1765 csrf_token => csrf_token.unwrap_or(""),
1766 error => error,
1767 rustio_version => env!("CARGO_PKG_VERSION"),
1768 })
1769 }) {
1770 Ok(html) => html,
1771 Err(err) => {
1772 eprintln!("admin password_change template render failed: {err}");
1773 "<!doctype html><html><body><h1>Change password</h1><p>Template failed.</p></body></html>".into()
1774 }
1775 }
1776}
1777
1778pub async fn password_change_done_render(
1780 db: &Db,
1781 registry: &crate::admin::admin_form_bridge::AdminRegistry,
1782 legacy_entries: &[crate::admin::AdminEntry],
1783 identity: Option<&crate::auth::Identity>,
1784 csrf_token: Option<&str>,
1785) -> String {
1786 let dashboard_entries = collect_dashboard_entries(db, registry).await;
1787 let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1788 let design = design_view();
1789 let user_v = user_view(identity);
1790 let env = crate::admin::templating::env();
1791 match env
1792 .get_template("admin/password_change_done.html")
1793 .and_then(|tmpl| {
1794 tmpl.render(minijinja::context! {
1795 design => design,
1796 current_user => user_v,
1797 sidebar_entries => sidebar,
1798 page_title => "Password changed",
1799 csrf_token => csrf_token.unwrap_or(""),
1800 rustio_version => env!("CARGO_PKG_VERSION"),
1801 })
1802 }) {
1803 Ok(html) => html,
1804 Err(err) => {
1805 eprintln!("admin password_change_done template render failed: {err}");
1806 "<!doctype html><html><body><h1>Password changed</h1><p><a href=\"/admin\">Back</a></p></body></html>".into()
1807 }
1808 }
1809}
1810
1811fn is_status_field_name(name: &str) -> bool {
1822 let n = name.to_lowercase();
1823 n == "status"
1824 || n == "state"
1825 || n == "active"
1826 || n == "published"
1827 || n.ends_with("_status")
1828 || n.ends_with("_state")
1829 || n.starts_with("is_")
1830 || n.starts_with("has_")
1831}
1832
1833fn status_pill_color(data_value: &str) -> &'static str {
1853 match data_value.trim() {
1854 "active" | "approved" | "published" | "live" | "completed" | "complete" | "done"
1855 | "finished" | "resolved" | "paid" => "rio-pill rio-pill-emerald",
1856 "referred" | "pending" | "todo" | "queued" | "open" | "new" | "scheduled" | "draft"
1857 | "sent" | "in progress" | "in review" | "review" | "overdue" | "on leave" => {
1858 "rio-pill rio-pill-amber"
1859 }
1860 _ => "rio-pill rio-pill-slate",
1861 }
1862}
1863
1864fn normalize_status_pill(raw: &str) -> (String, String) {
1865 let lc = raw.trim().to_lowercase();
1866 match lc.as_str() {
1867 "1" | "true" | "yes" | "on" => ("active".to_string(), "Active".to_string()),
1868 "0" | "false" | "no" | "off" => ("inactive".to_string(), "Inactive".to_string()),
1869 _ => (lc.clone(), humanize_status_label(raw)),
1870 }
1871}
1872
1873fn humanize_status_label(raw: &str) -> String {
1879 let spaced = raw.trim().replace('_', " ").to_lowercase();
1880 let mut chars = spaced.chars();
1881 match chars.next() {
1882 None => String::new(),
1883 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1884 }
1885}
1886
1887fn humanize_field_label(raw: &str) -> String {
1906 if raw == "id" {
1907 return "ID".to_string();
1908 }
1909 if !raw.contains('_') && raw.chars().next().is_some_and(|c| c.is_uppercase()) {
1910 return raw.to_string();
1911 }
1912 let stripped = raw.strip_suffix("_id").unwrap_or(raw);
1913 let spaced = stripped.replace('_', " ").to_lowercase();
1914 let mut chars = spaced.chars();
1915 match chars.next() {
1916 None => String::new(),
1917 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1918 }
1919}
1920
1921fn dashboard_fallback(entries: &[DashboardEntry]) -> String {
1922 let mut out = String::from(
1923 "<!doctype html><html><head><meta charset=\"utf-8\"><title>Dashboard</title></head><body style=\"font-family:system-ui\"><h1>Dashboard</h1><ul>",
1924 );
1925 for e in entries {
1926 out.push_str(&format!(
1927 "<li><a href=\"/admin/{}\">{}</a> ({})</li>",
1928 html_escape(e.slug),
1929 html_escape(e.model_name),
1930 e.count
1931 ));
1932 }
1933 out.push_str("</ul></body></html>");
1934 out
1935}
1936
1937#[cfg(test)]
1938mod tests {
1939 use super::*;
1940 use crate::admin::{AdminEntry, AdminField, FieldType};
1941
1942 fn entry(
1946 admin: &'static str,
1947 singular: &'static str,
1948 table: &'static str,
1949 core: bool,
1950 ) -> AdminEntry {
1951 const NO_FIELDS: &[AdminField] = &[AdminField {
1952 name: "id",
1953 ty: FieldType::I64,
1954 editable: false,
1955 nullable: false,
1956 relation: None,
1957 }];
1958 AdminEntry {
1959 admin_name: admin,
1960 display_name: singular,
1961 singular_name: singular,
1962 table,
1963 fields: NO_FIELDS,
1964 core,
1965 }
1966 }
1967
1968 #[tokio::test]
1969 async fn legacy_dashboard_walk_returns_one_entry_per_non_core_model() {
1970 let db = Db::memory().await.unwrap();
1971 sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
1972 .execute(db.pool())
1973 .await
1974 .unwrap();
1975 sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY)")
1976 .execute(db.pool())
1977 .await
1978 .unwrap();
1979 sqlx::query("INSERT INTO projects DEFAULT VALUES")
1980 .execute(db.pool())
1981 .await
1982 .unwrap();
1983 sqlx::query("INSERT INTO projects DEFAULT VALUES")
1984 .execute(db.pool())
1985 .await
1986 .unwrap();
1987 sqlx::query("INSERT INTO tasks DEFAULT VALUES")
1988 .execute(db.pool())
1989 .await
1990 .unwrap();
1991
1992 let legacy = [
1993 entry("projects", "Project", "projects", false),
1994 entry("tasks", "Task", "tasks", false),
1995 ];
1996 let known = std::collections::HashSet::new();
1997
1998 let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
1999
2000 assert_eq!(got.len(), 2);
2001 assert_eq!(got[0].slug, "projects");
2002 assert_eq!(got[0].count, 2);
2003 assert_eq!(got[1].slug, "tasks");
2004 assert_eq!(got[1].count, 1);
2005 }
2006
2007 #[tokio::test]
2008 async fn legacy_dashboard_walk_skips_core_entries() {
2009 let db = Db::memory().await.unwrap();
2010 let legacy = [
2012 entry("rustio_users", "User", "rustio_users", true),
2013 entry("projects", "Project", "projects", false),
2014 ];
2015 sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
2016 .execute(db.pool())
2017 .await
2018 .unwrap();
2019
2020 let known = std::collections::HashSet::new();
2021 let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2022
2023 assert_eq!(got.len(), 1, "core entry should be skipped");
2024 assert_eq!(got[0].slug, "projects");
2025 }
2026
2027 #[tokio::test]
2028 async fn legacy_dashboard_walk_dedupes_against_already_listed_slugs() {
2029 let db = Db::memory().await.unwrap();
2030 sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
2031 .execute(db.pool())
2032 .await
2033 .unwrap();
2034 sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY)")
2035 .execute(db.pool())
2036 .await
2037 .unwrap();
2038
2039 let mut known = std::collections::HashSet::new();
2041 known.insert("projects");
2042
2043 let legacy = [
2044 entry("projects", "Project", "projects", false),
2045 entry("tasks", "Task", "tasks", false),
2046 ];
2047
2048 let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2049
2050 assert_eq!(got.len(), 1, "already-listed slug should be skipped");
2051 assert_eq!(got[0].slug, "tasks");
2052 }
2053
2054 #[tokio::test]
2055 async fn legacy_dashboard_walk_falls_back_to_zero_when_table_missing() {
2056 let db = Db::memory().await.unwrap();
2057 let legacy = [entry("ghosts", "Ghost", "ghosts", false)];
2060 let known = std::collections::HashSet::new();
2061
2062 let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2063
2064 assert_eq!(got.len(), 1);
2065 assert_eq!(got[0].count, 0);
2066 }
2067
2068 #[test]
2069 fn status_field_name_matches_known_patterns() {
2070 assert!(is_status_field_name("status"));
2072 assert!(is_status_field_name("state"));
2073 assert!(is_status_field_name("active"));
2074 assert!(is_status_field_name("published"));
2075 assert!(is_status_field_name("Status"));
2077 assert!(is_status_field_name("STATE"));
2078 assert!(is_status_field_name("task_status"));
2080 assert!(is_status_field_name("order_state"));
2081 assert!(is_status_field_name("is_active"));
2083 assert!(is_status_field_name("is_published"));
2084 assert!(is_status_field_name("has_paid"));
2085 }
2086
2087 #[test]
2088 fn status_field_name_rejects_non_status_columns() {
2089 assert!(!is_status_field_name("title"));
2091 assert!(!is_status_field_name("description"));
2092 assert!(!is_status_field_name("name"));
2093 assert!(!is_status_field_name("priority"));
2095 assert!(!is_status_field_name("count"));
2096 assert!(!is_status_field_name("created_at"));
2098 assert!(!is_status_field_name("due_at"));
2099 assert!(!is_status_field_name("project_id"));
2101 assert!(!is_status_field_name("user_id"));
2102 assert!(!is_status_field_name("statustown"));
2104 assert!(!is_status_field_name("estatus_id"));
2105 }
2106
2107 #[test]
2108 fn normalize_status_pill_maps_boolean_encodings() {
2109 for raw in ["1", "true", "TRUE", " True ", "yes", "on"] {
2111 let (data, label) = normalize_status_pill(raw);
2112 assert_eq!(
2113 data, "active",
2114 "truthy raw {raw:?} should map to data=active"
2115 );
2116 assert_eq!(label, "Active", "truthy raw {raw:?} should label as Active");
2117 }
2118 for raw in ["0", "false", "FALSE", "no", "off"] {
2120 let (data, label) = normalize_status_pill(raw);
2121 assert_eq!(
2122 data, "inactive",
2123 "falsy raw {raw:?} should map to data=inactive"
2124 );
2125 assert_eq!(
2126 label, "Inactive",
2127 "falsy raw {raw:?} should label as Inactive"
2128 );
2129 }
2130 }
2131
2132 #[test]
2133 fn normalize_status_pill_humanizes_string_statuses() {
2134 let (data, label) = normalize_status_pill("In_Progress");
2138 assert_eq!(data, "in_progress");
2139 assert_eq!(label, "In progress");
2140
2141 let (data, label) = normalize_status_pill("DONE");
2142 assert_eq!(data, "done");
2143 assert_eq!(label, "Done");
2144
2145 let (data, label) = normalize_status_pill("todo");
2146 assert_eq!(data, "todo");
2147 assert_eq!(label, "Todo");
2148
2149 let (data, label) = normalize_status_pill("review");
2150 assert_eq!(data, "review");
2151 assert_eq!(label, "Review");
2152
2153 let (data, label) = normalize_status_pill("custom_state");
2155 assert_eq!(data, "custom_state");
2156 assert_eq!(label, "Custom state");
2157 }
2158
2159 #[test]
2160 fn humanize_status_label_handles_edges() {
2161 assert_eq!(humanize_status_label(""), "");
2162 assert_eq!(humanize_status_label("a"), "A");
2163 assert_eq!(humanize_status_label(" trim "), "Trim");
2164 assert_eq!(
2165 humanize_status_label("multi_word_status"),
2166 "Multi word status"
2167 );
2168 }
2169
2170 #[test]
2171 fn humanize_field_label_cases() {
2172 assert_eq!(humanize_field_label(""), "");
2173 assert_eq!(humanize_field_label("id"), "ID");
2174 assert_eq!(humanize_field_label("title"), "Title");
2175 assert_eq!(humanize_field_label("project_id"), "Project");
2176 assert_eq!(humanize_field_label("user_id"), "User");
2177 assert_eq!(humanize_field_label("due_at"), "Due at");
2178 assert_eq!(humanize_field_label("created_at"), "Created at");
2179 assert_eq!(humanize_field_label("first_name"), "First name");
2180 assert_eq!(humanize_field_label("Username"), "Username");
2183 assert_eq!(humanize_field_label("User ID"), "User ID");
2184 assert_eq!(
2186 humanize_field_label(&humanize_field_label("due_at")),
2187 "Due at"
2188 );
2189 }
2190}