1use std::future::Future;
4use std::pin::Pin;
5
6use chrono::{DateTime, Utc};
7use serde::Deserialize;
8use serde_json::Value;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum AdminFieldKind {
15 Text,
17 TextArea,
19 Integer,
21 Float,
23 Boolean,
25 Date,
27 DateTime,
29 Select(Vec<SelectOption>),
31 Hidden,
33 Password,
35 Json,
37}
38
39impl AdminFieldKind {
40 #[must_use]
56 pub const fn blank_submission_is_null(&self) -> bool {
57 matches!(
58 self,
59 Self::Integer
60 | Self::Float
61 | Self::Date
62 | Self::DateTime
63 | Self::Text
64 | Self::TextArea
65 | Self::Select(_)
66 | Self::Json
67 )
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SelectOption {
74 pub value: String,
75 pub label: String,
76}
77
78#[derive(Debug, Clone)]
80#[allow(clippy::struct_excessive_bools)] #[non_exhaustive]
82pub struct AdminField {
83 pub name: &'static str,
85 pub label: String,
87 pub kind: AdminFieldKind,
89 pub list_display: bool,
91 pub searchable: bool,
93 pub filterable: bool,
95 pub required: bool,
97 pub editable: bool,
99 pub create_only: bool,
102 pub sortable: bool,
104 pub encrypted: bool,
113 pub encrypted_visible: bool,
117}
118
119impl AdminField {
120 #[must_use]
128 pub fn new(name: &'static str, kind: AdminFieldKind) -> Self {
129 let editable = !matches!(kind, AdminFieldKind::Hidden);
130 Self {
131 name,
132 label: humanize_field_name(name),
133 kind,
134 list_display: true,
135 searchable: false,
136 filterable: false,
137 required: true,
138 editable,
139 create_only: false,
140 sortable: true,
141 encrypted: false,
142 encrypted_visible: false,
143 }
144 }
145
146 #[must_use]
149 pub const fn encrypted(mut self) -> Self {
150 self.encrypted = true;
151 self
152 }
153
154 #[must_use]
158 pub const fn encrypted_visible(mut self) -> Self {
159 self.encrypted = true;
160 self.encrypted_visible = true;
161 self
162 }
163
164 #[must_use]
166 pub fn label(mut self, label: impl Into<String>) -> Self {
167 self.label = label.into();
168 self
169 }
170
171 #[must_use]
173 pub const fn searchable(mut self) -> Self {
174 self.searchable = true;
175 self
176 }
177
178 #[must_use]
180 pub const fn filterable(mut self) -> Self {
181 self.filterable = true;
182 self
183 }
184
185 #[must_use]
187 pub const fn optional(mut self) -> Self {
188 self.required = false;
189 self
190 }
191
192 #[must_use]
194 pub const fn readonly(mut self) -> Self {
195 self.editable = false;
196 self
197 }
198
199 #[must_use]
203 pub const fn create_only(mut self) -> Self {
204 self.create_only = true;
205 self
206 }
207
208 #[must_use]
210 pub const fn hide_from_list(mut self) -> Self {
211 self.list_display = false;
212 self
213 }
214}
215
216pub struct AdminAction {
220 pub name: &'static str,
222 pub label: String,
224 pub style: ActionStyle,
226 pub confirm: bool,
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum ActionStyle {
233 Default,
235 Primary,
237 Danger,
239}
240
241#[derive(Debug, Clone)]
248pub struct AdminHistoryEntry {
249 pub id: i64,
251 pub actor: String,
253 pub op: String,
255 pub request_id: Option<String>,
257 pub changes: Vec<Value>,
259 pub recorded_at: DateTime<Utc>,
261}
262
263#[derive(Debug, Clone)]
265pub struct AdminHistoryPage {
266 pub entries: Vec<AdminHistoryEntry>,
267 pub total: u64,
268 pub page: u64,
269 pub per_page: u64,
270}
271
272impl AdminHistoryPage {
273 #[must_use]
275 pub const fn total_pages(&self) -> u64 {
276 if self.per_page == 0 {
277 return 0;
278 }
279 self.total.div_ceil(self.per_page)
280 }
281
282 #[must_use]
284 pub const fn has_next_page(&self) -> bool {
285 self.page < self.total_pages()
286 }
287}
288
289pub type AdminFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, AdminError>> + Send + 'a>>;
293
294#[derive(Debug, thiserror::Error)]
296pub enum AdminError {
297 #[error("Record not found")]
298 NotFound,
299
300 #[error("Validation failed: {0}")]
301 Validation(String),
302
303 #[error("Database error: {0}")]
304 Database(String),
305
306 #[error("{0}")]
307 Other(String),
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
314pub enum CsvImportMode {
315 #[default]
317 Insert,
318 DryRun,
320}
321
322impl CsvImportMode {
323 #[must_use]
329 pub fn from_form_value(s: &str) -> Option<Self> {
330 match s {
331 "insert" | "Insert" => Some(Self::Insert),
332 "dry_run" | "DryRun" | "dry-run" => Some(Self::DryRun),
333 _ => None,
334 }
335 }
336}
337
338#[derive(Debug)]
340pub enum AdminImportRowResult {
341 Inserted,
343 Updated,
345 Skipped,
347 RowError(String),
349 FieldError { column: String, message: String },
351}
352
353#[derive(Debug, Default, Clone)]
355pub struct AdminImportReport {
356 pub inserted: u64,
357 pub updated: u64,
358 pub skipped: u64,
359 pub errors: Vec<AdminImportError>,
360}
361
362#[derive(Debug, Clone)]
364pub struct AdminImportError {
365 pub line: u64,
367 pub column: Option<String>,
369 pub message: String,
371}
372
373pub trait AdminModel: Send + Sync + 'static {
385 fn slug(&self) -> &'static str;
388
389 fn display_name(&self) -> &'static str;
391
392 fn display_name_plural(&self) -> &'static str;
394
395 fn fields(&self) -> Vec<AdminField>;
397
398 fn actions(&self) -> Vec<AdminAction> {
404 let mut acts = vec![AdminAction {
405 name: "delete",
406 label: "Delete selected".to_owned(),
407 style: ActionStyle::Danger,
408 confirm: true,
409 }];
410 if self.supports_soft_delete() {
411 acts.push(AdminAction {
412 name: "restore",
413 label: "Restore selected".to_owned(),
414 style: ActionStyle::Default,
415 confirm: false,
416 });
417 acts.push(AdminAction {
418 name: "purge",
419 label: "Purge selected".to_owned(),
420 style: ActionStyle::Danger,
421 confirm: true,
422 });
423 }
424 acts
425 }
426
427 fn list(
431 &self,
432 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
433 params: ListParams,
434 ) -> AdminFuture<'_, ListResult>;
435
436 fn get(
438 &self,
439 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
440 id: i64,
441 ) -> AdminFuture<'_, Option<Value>>;
442
443 fn create(
445 &self,
446 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
447 data: Value,
448 ) -> AdminFuture<'_, Value>;
449
450 fn update(
452 &self,
453 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
454 id: i64,
455 data: Value,
456 ) -> AdminFuture<'_, Value>;
457
458 fn delete(
460 &self,
461 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
462 id: i64,
463 ) -> AdminFuture<'_, ()>;
464
465 fn supports_soft_delete(&self) -> bool {
469 false
470 }
471
472 fn restore<'a>(
477 &'a self,
478 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
479 _id: i64,
480 ) -> AdminFuture<'a, ()> {
481 Box::pin(async move {
482 Err(AdminError::Other(
483 "this model does not support soft delete; \
484 override supports_soft_delete() to return true and implement restore()"
485 .to_owned(),
486 ))
487 })
488 }
489
490 fn purge<'a>(
495 &'a self,
496 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
497 _id: i64,
498 ) -> AdminFuture<'a, ()> {
499 Box::pin(async move {
500 Err(AdminError::Other(
501 "this model does not support soft delete; \
502 override supports_soft_delete() to return true and implement purge()"
503 .to_owned(),
504 ))
505 })
506 }
507
508 fn list_deleted<'a>(
513 &'a self,
514 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
515 _params: ListParams,
516 ) -> AdminFuture<'a, ListResult> {
517 Box::pin(async move {
518 Err(AdminError::Other(
519 "this model does not support soft delete; \
520 override supports_soft_delete() to return true and implement list_deleted()"
521 .to_owned(),
522 ))
523 })
524 }
525
526 fn execute_action(
528 &self,
529 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
530 action: &str,
531 ids: Vec<i64>,
532 ) -> AdminFuture<'_, u64> {
533 if action == "delete" {
540 let pool = pool.clone();
545 return Box::pin(async move {
546 let mut count: u64 = 0;
547 for id in ids {
548 self.delete(&pool, id).await?;
549 count += 1;
550 }
551 Ok(count)
552 });
553 }
554 dispatch_restore_purge_or_unhandled(self, pool, action, ids)
555 }
556
557 fn record_display(&self, record: &Value) -> String {
562 record_id(record).map_or_else(
563 || format!("{} <no id>", self.display_name()),
564 |id| format!("{} #{id}", self.display_name()),
565 )
566 }
567
568 fn per_page(&self) -> u64 {
570 25
571 }
572
573 fn count(
577 &self,
578 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
579 ) -> AdminFuture<'_, u64> {
580 let params = ListParams {
581 page: 1,
582 per_page: 0,
583 ..Default::default()
584 };
585 let fut = self.list(pool, params);
586 Box::pin(async move { fut.await.map(|r| r.total) })
587 }
588
589 fn supports_csv_export(&self) -> bool {
597 false
598 }
599
600 fn csv_export_columns(&self) -> Vec<&'static str> {
614 self.fields()
615 .into_iter()
616 .filter(|f| {
617 !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden)
622 && !f.encrypted
623 && !::autumn_web::confidential::is_confidential_column_name(f.name)
624 })
625 .map(|f| f.name)
626 .collect()
627 }
628
629 fn csv_export_row(&self, columns: &[&str], record: &Value) -> Vec<String> {
639 columns
640 .iter()
641 .map(|col| {
642 record
643 .get(*col)
644 .map(|v| match v {
645 Value::String(s) => escape_csv_formula(s),
646 Value::Null => String::new(),
647 other => other.to_string(),
648 })
649 .unwrap_or_default()
650 })
651 .collect()
652 }
653
654 fn supports_csv_import(&self) -> bool {
662 false
663 }
664
665 fn import_csv_row<'a>(
676 &'a self,
677 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
678 _line: u64,
679 _row: std::collections::HashMap<String, String>,
680 _mode: CsvImportMode,
681 ) -> AdminFuture<'a, AdminImportRowResult> {
682 Box::pin(async move { Ok(AdminImportRowResult::Skipped) })
683 }
684
685 fn has_history(&self) -> bool {
690 false
691 }
692
693 fn get_history<'a>(
698 &'a self,
699 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
700 _record_id: i64,
701 _page: u64,
702 _per_page: u64,
703 ) -> AdminFuture<'a, AdminHistoryPage> {
704 Box::pin(async move {
705 Err(AdminError::Other(
706 "this model does not have version history enabled; \
707 use #[repository(Model, versioned = true)] to opt in"
708 .to_owned(),
709 ))
710 })
711 }
712}
713
714#[allow(
726 clippy::missing_const_for_fn,
727 clippy::unnecessary_wraps,
728 reason = "the Postgres arm is a trivial Ok(()); the SQLite arm formats an error"
729)]
730pub fn require_postgres(model: &str) -> Result<(), AdminError> {
731 ::autumn_web::backend_select! {
732 pg => {{
733 let _ = model;
734 Ok(())
735 }},
736 sqlite => {{
737 Err(AdminError::Other(format!(
738 "{model} needs the Postgres backend: it reads a Postgres-only table \
739 with Postgres-only SQL. This app runs on SQLite. Register your own \
740 AdminModel instead — see the autumn-admin-plugin README, \
741 \"Database Backends\" (issue #2108)."
742 )))
743 }},
744 }
745}
746
747pub fn dispatch_restore_purge_or_unhandled<'a>(
756 model: &'a (impl AdminModel + ?Sized),
757 pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
758 action: &str,
759 ids: Vec<i64>,
760) -> AdminFuture<'a, u64> {
761 let action = action.to_owned();
762 let pool = pool.clone();
763 Box::pin(async move {
764 match action.as_str() {
765 "restore" => {
766 let mut count: u64 = 0;
767 for id in ids {
768 model.restore(&pool, id).await?;
769 count += 1;
770 }
771 Ok(count)
772 }
773 "purge" => {
774 let mut count: u64 = 0;
775 for id in ids {
776 model.purge(&pool, id).await?;
777 count += 1;
778 }
779 Ok(count)
780 }
781 other => Err(AdminError::Other(format!(
782 "unhandled bulk action '{other}'; \
783 override AdminModel::execute_action to support it"
784 ))),
785 }
786 })
787}
788
789impl From<autumn_web::version_history::VersionEntry> for AdminHistoryEntry {
792 fn from(e: autumn_web::version_history::VersionEntry) -> Self {
793 Self {
794 id: e.id,
795 actor: e.actor,
796 op: e.op.to_string(),
797 request_id: e.request_id,
798 changes: e
799 .changes
800 .into_iter()
801 .map(|c| serde_json::to_value(&c).unwrap_or(serde_json::Value::Null))
802 .collect(),
803 recorded_at: e.recorded_at,
804 }
805 }
806}
807
808impl From<autumn_web::version_history::VersionPage> for AdminHistoryPage {
809 fn from(vp: autumn_web::version_history::VersionPage) -> Self {
829 Self {
830 entries: vp
831 .entries
832 .into_iter()
833 .map(AdminHistoryEntry::from)
834 .collect(),
835 total: vp.total,
836 page: vp.page,
837 per_page: vp.per_page,
838 }
839 }
840}
841
842#[must_use]
849pub fn record_id(record: &Value) -> Option<i64> {
850 record.get("id").and_then(Value::as_i64)
851}
852
853#[derive(Debug, Clone, Default)]
857pub struct ListParams {
858 pub page: u64,
860 pub per_page: u64,
862 pub search: Option<String>,
864 pub sort_by: Option<String>,
866 pub sort_dir: SortDirection,
868 pub filters: Vec<(String, String)>,
870}
871
872impl ListParams {
873 #[must_use]
878 pub fn sql_offset_limit(&self) -> (i64, i64) {
879 if self.per_page == 0 {
880 return (0, i64::MAX);
881 }
882 let offset = self.page.saturating_sub(1) * self.per_page;
883 (
884 i64::try_from(offset).unwrap_or(0),
885 i64::try_from(self.per_page).unwrap_or(i64::MAX),
886 )
887 }
888}
889
890#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
892#[serde(rename_all = "lowercase")]
893pub enum SortDirection {
894 #[default]
895 Asc,
896 Desc,
897}
898
899impl SortDirection {
900 #[must_use]
902 pub const fn as_str(self) -> &'static str {
903 match self {
904 Self::Asc => "asc",
905 Self::Desc => "desc",
906 }
907 }
908
909 #[must_use]
911 pub const fn flipped(self) -> Self {
912 match self {
913 Self::Asc => Self::Desc,
914 Self::Desc => Self::Asc,
915 }
916 }
917}
918
919#[derive(Debug, Clone)]
921pub struct ListResult {
922 pub records: Vec<Value>,
924 pub total: u64,
926 pub page: u64,
928 pub per_page: u64,
930}
931
932impl ListResult {
933 #[must_use]
935 pub const fn total_pages(&self) -> u64 {
936 if self.per_page == 0 {
937 return 0;
938 }
939 self.total.div_ceil(self.per_page)
940 }
941}
942
943fn escape_csv_formula(s: &str) -> String {
949 match s.bytes().next() {
950 Some(b'=' | b'+' | b'-' | b'@' | b'\t' | b'\r') => {
951 let mut out = String::with_capacity(s.len() + 1);
952 out.push('\'');
953 out.push_str(s);
954 out
955 }
956 _ => s.to_owned(),
957 }
958}
959
960fn humanize_field_name(name: &str) -> String {
964 let mut s = String::with_capacity(name.len());
965 for (i, word) in name.split('_').enumerate() {
966 if i > 0 {
967 s.push(' ');
968 }
969 let mut chars = word.chars();
970 if let Some(c) = chars.next() {
971 s.extend(c.to_uppercase());
972 s.extend(chars);
973 }
974 }
975 s
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981 use std::sync::Mutex;
982
983 struct DeletingModel {
987 deleted: Mutex<Vec<i64>>,
988 fail_on: Option<i64>,
989 }
990
991 impl AdminModel for DeletingModel {
992 fn slug(&self) -> &'static str {
993 "tracked"
994 }
995 fn display_name(&self) -> &'static str {
996 "Tracked"
997 }
998 fn display_name_plural(&self) -> &'static str {
999 "Tracked"
1000 }
1001 fn fields(&self) -> Vec<AdminField> {
1002 vec![]
1003 }
1004 fn list(
1005 &self,
1006 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1007 ::autumn_web::RuntimeConnection,
1008 >,
1009 _params: ListParams,
1010 ) -> AdminFuture<'_, ListResult> {
1011 Box::pin(async {
1012 Ok(ListResult {
1013 records: vec![],
1014 total: 0,
1015 page: 1,
1016 per_page: 25,
1017 })
1018 })
1019 }
1020 fn get(
1021 &self,
1022 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1023 ::autumn_web::RuntimeConnection,
1024 >,
1025 _id: i64,
1026 ) -> AdminFuture<'_, Option<Value>> {
1027 Box::pin(async { Ok(None) })
1028 }
1029 fn create(
1030 &self,
1031 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1032 ::autumn_web::RuntimeConnection,
1033 >,
1034 data: Value,
1035 ) -> AdminFuture<'_, Value> {
1036 Box::pin(async move { Ok(data) })
1037 }
1038 fn update(
1039 &self,
1040 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1041 ::autumn_web::RuntimeConnection,
1042 >,
1043 _id: i64,
1044 data: Value,
1045 ) -> AdminFuture<'_, Value> {
1046 Box::pin(async move { Ok(data) })
1047 }
1048 fn delete(
1049 &self,
1050 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1051 ::autumn_web::RuntimeConnection,
1052 >,
1053 id: i64,
1054 ) -> AdminFuture<'_, ()> {
1055 let deleted = &self.deleted;
1056 let fail_on = self.fail_on;
1057 Box::pin(async move {
1058 if Some(id) == fail_on {
1059 return Err(AdminError::Database("simulated failure".into()));
1060 }
1061 deleted.lock().unwrap().push(id);
1062 Ok(())
1063 })
1064 }
1065 }
1066
1067 #[derive(Default)]
1071 struct SoftDeleteModel {
1072 restored: Mutex<Vec<i64>>,
1073 purged: Mutex<Vec<i64>>,
1074 }
1075
1076 impl AdminModel for SoftDeleteModel {
1077 fn slug(&self) -> &'static str {
1078 "soft"
1079 }
1080 fn display_name(&self) -> &'static str {
1081 "Soft"
1082 }
1083 fn display_name_plural(&self) -> &'static str {
1084 "Softs"
1085 }
1086 fn fields(&self) -> Vec<AdminField> {
1087 vec![]
1088 }
1089 fn list(
1090 &self,
1091 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1092 ::autumn_web::RuntimeConnection,
1093 >,
1094 _params: ListParams,
1095 ) -> AdminFuture<'_, ListResult> {
1096 Box::pin(async {
1097 Ok(ListResult {
1098 records: vec![],
1099 total: 0,
1100 page: 1,
1101 per_page: 25,
1102 })
1103 })
1104 }
1105 fn get(
1106 &self,
1107 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1108 ::autumn_web::RuntimeConnection,
1109 >,
1110 _id: i64,
1111 ) -> AdminFuture<'_, Option<Value>> {
1112 Box::pin(async { Ok(None) })
1113 }
1114 fn create(
1115 &self,
1116 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1117 ::autumn_web::RuntimeConnection,
1118 >,
1119 data: Value,
1120 ) -> AdminFuture<'_, Value> {
1121 Box::pin(async move { Ok(data) })
1122 }
1123 fn update(
1124 &self,
1125 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1126 ::autumn_web::RuntimeConnection,
1127 >,
1128 _id: i64,
1129 data: Value,
1130 ) -> AdminFuture<'_, Value> {
1131 Box::pin(async move { Ok(data) })
1132 }
1133 fn delete(
1134 &self,
1135 _pool: &diesel_async::pooled_connection::deadpool::Pool<
1136 ::autumn_web::RuntimeConnection,
1137 >,
1138 _id: i64,
1139 ) -> AdminFuture<'_, ()> {
1140 Box::pin(async { Ok(()) })
1141 }
1142 fn supports_soft_delete(&self) -> bool {
1143 true
1144 }
1145 fn restore<'a>(
1146 &'a self,
1147 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1148 ::autumn_web::RuntimeConnection,
1149 >,
1150 id: i64,
1151 ) -> AdminFuture<'a, ()> {
1152 Box::pin(async move {
1153 self.restored.lock().unwrap().push(id);
1154 Ok(())
1155 })
1156 }
1157 fn purge<'a>(
1158 &'a self,
1159 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1160 ::autumn_web::RuntimeConnection,
1161 >,
1162 id: i64,
1163 ) -> AdminFuture<'a, ()> {
1164 Box::pin(async move {
1165 self.purged.lock().unwrap().push(id);
1166 Ok(())
1167 })
1168 }
1169 fn list_deleted<'a>(
1170 &'a self,
1171 _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1172 ::autumn_web::RuntimeConnection,
1173 >,
1174 _params: ListParams,
1175 ) -> AdminFuture<'a, ListResult> {
1176 Box::pin(async {
1177 Ok(ListResult {
1178 records: vec![],
1179 total: 0,
1180 page: 1,
1181 per_page: 25,
1182 })
1183 })
1184 }
1185 }
1186
1187 fn dummy_pool()
1190 -> diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection> {
1191 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
1192 use diesel_async::pooled_connection::deadpool::Pool;
1193 let mgr = AsyncDieselConnectionManager::<::autumn_web::RuntimeConnection>::new(
1194 "postgresql://test",
1195 );
1196 Pool::builder(mgr).build().expect("build pool")
1197 }
1198
1199 #[tokio::test]
1200 async fn default_execute_action_delete_invokes_delete_for_each_id() {
1201 let model = DeletingModel {
1202 deleted: Mutex::new(vec![]),
1203 fail_on: None,
1204 };
1205 let pool = dummy_pool();
1206 let count = model
1207 .execute_action(&pool, "delete", vec![10, 20, 30])
1208 .await
1209 .expect("default delete should succeed");
1210 assert_eq!(count, 3);
1211 assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
1212 }
1213
1214 #[tokio::test]
1215 async fn default_execute_action_delete_aborts_on_first_failure() {
1216 let model = DeletingModel {
1217 deleted: Mutex::new(vec![]),
1218 fail_on: Some(20),
1219 };
1220 let pool = dummy_pool();
1221 let err = model
1222 .execute_action(&pool, "delete", vec![10, 20, 30])
1223 .await
1224 .expect_err("delete should propagate failure");
1225 assert!(matches!(err, AdminError::Database(_)));
1226 assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
1228 }
1229
1230 #[tokio::test]
1231 async fn default_execute_action_rejects_unknown_action() {
1232 let model = DeletingModel {
1233 deleted: Mutex::new(vec![]),
1234 fail_on: None,
1235 };
1236 let pool = dummy_pool();
1237 let err = model
1238 .execute_action(&pool, "promote", vec![1])
1239 .await
1240 .expect_err("unknown actions must error, not silently no-op");
1241 assert!(
1242 matches!(err, AdminError::Other(msg) if msg.contains("promote")),
1243 "error should name the unhandled action"
1244 );
1245 assert!(model.deleted.lock().unwrap().is_empty());
1246 }
1247
1248 #[test]
1249 fn humanize_converts_snake_case() {
1250 assert_eq!(humanize_field_name("created_at"), "Created At");
1251 assert_eq!(humanize_field_name("user_id"), "User Id");
1252 assert_eq!(humanize_field_name("name"), "Name");
1253 assert_eq!(humanize_field_name(""), "");
1254 }
1255
1256 #[test]
1257 fn list_result_total_pages() {
1258 let result = ListResult {
1259 records: vec![],
1260 total: 25,
1261 page: 1,
1262 per_page: 10,
1263 };
1264 assert_eq!(result.total_pages(), 3);
1265 }
1266
1267 #[test]
1268 fn list_result_total_pages_exact() {
1269 let result = ListResult {
1270 records: vec![],
1271 total: 20,
1272 page: 1,
1273 per_page: 10,
1274 };
1275 assert_eq!(result.total_pages(), 2);
1276 }
1277
1278 #[test]
1279 fn list_result_total_pages_zero_per_page() {
1280 let result = ListResult {
1281 records: vec![],
1282 total: 20,
1283 page: 1,
1284 per_page: 0,
1285 };
1286 assert_eq!(result.total_pages(), 0);
1287 }
1288
1289 #[test]
1290 fn admin_field_builder() {
1291 let field = AdminField::new("email", AdminFieldKind::Text)
1292 .label("Email Address")
1293 .searchable()
1294 .filterable()
1295 .optional();
1296
1297 assert_eq!(field.name, "email");
1298 assert_eq!(field.label, "Email Address");
1299 assert!(field.searchable);
1300 assert!(field.filterable);
1301 assert!(!field.required);
1302 assert!(field.editable);
1303 }
1304
1305 #[test]
1306 fn record_id_extracts_numeric_id() {
1307 assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
1308 }
1309
1310 #[test]
1311 fn record_id_returns_none_for_missing_or_non_numeric() {
1312 assert_eq!(record_id(&serde_json::json!({})), None);
1313 assert_eq!(record_id(&serde_json::json!({"id": null})), None);
1314 assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
1315 assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
1317 }
1318
1319 #[test]
1320 fn hidden_fields_default_to_not_editable() {
1321 let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
1325 assert!(
1326 !hidden.editable,
1327 "Hidden fields must default to editable=false"
1328 );
1329
1330 let text = AdminField::new("name", AdminFieldKind::Text);
1332 assert!(text.editable);
1333 }
1334
1335 #[test]
1338 fn admin_model_supports_soft_delete_defaults_to_false() {
1339 let model = DeletingModel {
1340 deleted: Mutex::new(vec![]),
1341 fail_on: None,
1342 };
1343 assert!(
1344 !model.supports_soft_delete(),
1345 "AdminModel::supports_soft_delete() must default to false"
1346 );
1347 }
1348
1349 #[tokio::test]
1350 async fn admin_model_restore_returns_error_when_soft_delete_not_supported() {
1351 let model = DeletingModel {
1352 deleted: Mutex::new(vec![]),
1353 fail_on: None,
1354 };
1355 let pool = dummy_pool();
1356 let err = model
1357 .restore(&pool, 1)
1358 .await
1359 .expect_err("restore must error when supports_soft_delete() is false");
1360 assert!(
1361 matches!(err, AdminError::Other(_)),
1362 "restore on non-soft-delete model must return AdminError::Other: {err:?}"
1363 );
1364 }
1365
1366 #[tokio::test]
1367 async fn admin_model_purge_returns_error_when_soft_delete_not_supported() {
1368 let model = DeletingModel {
1369 deleted: Mutex::new(vec![]),
1370 fail_on: None,
1371 };
1372 let pool = dummy_pool();
1373 let err = model
1374 .purge(&pool, 1)
1375 .await
1376 .expect_err("purge must error when supports_soft_delete() is false");
1377 assert!(
1378 matches!(err, AdminError::Other(_)),
1379 "purge on non-soft-delete model must return AdminError::Other: {err:?}"
1380 );
1381 }
1382
1383 #[tokio::test]
1384 async fn admin_model_list_deleted_returns_error_when_soft_delete_not_supported() {
1385 let model = DeletingModel {
1386 deleted: Mutex::new(vec![]),
1387 fail_on: None,
1388 };
1389 let pool = dummy_pool();
1390 let params = ListParams {
1391 page: 1,
1392 per_page: 25,
1393 ..Default::default()
1394 };
1395 let err = model
1396 .list_deleted(&pool, params)
1397 .await
1398 .expect_err("list_deleted must error when supports_soft_delete() is false");
1399 assert!(
1400 matches!(err, AdminError::Other(_)),
1401 "list_deleted on non-soft-delete model must return AdminError::Other: {err:?}"
1402 );
1403 }
1404
1405 #[test]
1406 fn default_actions_returns_only_delete_when_soft_delete_not_supported() {
1407 let model = DeletingModel {
1408 deleted: Mutex::new(vec![]),
1409 fail_on: None,
1410 };
1411 let acts = model.actions();
1412 assert_eq!(
1413 acts.len(),
1414 1,
1415 "default model must advertise exactly one action"
1416 );
1417 assert_eq!(acts[0].name, "delete");
1418 }
1419
1420 #[test]
1421 fn actions_includes_restore_and_purge_when_soft_delete_supported() {
1422 let model = SoftDeleteModel::default();
1423 let acts = model.actions();
1424 let names: Vec<&str> = acts.iter().map(|a| a.name).collect();
1425 assert!(
1426 names.contains(&"restore"),
1427 "soft-delete model must advertise restore action; got: {names:?}"
1428 );
1429 assert!(
1430 names.contains(&"purge"),
1431 "soft-delete model must advertise purge action; got: {names:?}"
1432 );
1433 }
1434
1435 #[tokio::test]
1436 async fn execute_action_restore_dispatches_to_restore_method() {
1437 let model = SoftDeleteModel::default();
1438 let pool = dummy_pool();
1439 let count = model
1440 .execute_action(&pool, "restore", vec![10, 20])
1441 .await
1442 .expect("restore action should succeed on soft-delete model");
1443 assert_eq!(
1444 count, 2,
1445 "restore action must return count of restored records"
1446 );
1447 assert_eq!(*model.restored.lock().unwrap(), vec![10, 20]);
1448 }
1449
1450 #[tokio::test]
1451 async fn execute_action_purge_dispatches_to_purge_method() {
1452 let model = SoftDeleteModel::default();
1453 let pool = dummy_pool();
1454 let count = model
1455 .execute_action(&pool, "purge", vec![5])
1456 .await
1457 .expect("purge action should succeed on soft-delete model");
1458 assert_eq!(count, 1, "purge action must return count of purged records");
1459 assert_eq!(*model.purged.lock().unwrap(), vec![5]);
1460 }
1461
1462 #[test]
1465 fn admin_model_has_history_defaults_to_false() {
1466 let model = DeletingModel {
1467 deleted: Mutex::new(vec![]),
1468 fail_on: None,
1469 };
1470 assert!(
1471 !model.has_history(),
1472 "AdminModel::has_history() must default to false"
1473 );
1474 }
1475
1476 #[tokio::test]
1477 async fn admin_model_get_history_returns_error_when_not_opted_in() {
1478 let model = DeletingModel {
1479 deleted: Mutex::new(vec![]),
1480 fail_on: None,
1481 };
1482 let pool = dummy_pool();
1483 let err = model
1484 .get_history(&pool, 42, 1, 25)
1485 .await
1486 .expect_err("get_history must error when has_history() is false");
1487 assert!(
1488 matches!(err, AdminError::Other(_)),
1489 "get_history on non-versioned model must return AdminError::Other: {err:?}"
1490 );
1491 }
1492
1493 #[test]
1494 fn admin_history_page_total_pages() {
1495 let page = AdminHistoryPage {
1496 entries: vec![],
1497 total: 51,
1498 page: 1,
1499 per_page: 25,
1500 };
1501 assert_eq!(page.total_pages(), 3);
1502 }
1503
1504 #[test]
1505 fn admin_history_page_has_next_page() {
1506 let page = AdminHistoryPage {
1507 entries: vec![],
1508 total: 50,
1509 page: 1,
1510 per_page: 25,
1511 };
1512 assert!(page.has_next_page());
1513 }
1514
1515 #[test]
1516 fn admin_history_page_no_next_on_last() {
1517 let page = AdminHistoryPage {
1518 entries: vec![],
1519 total: 50,
1520 page: 2,
1521 per_page: 25,
1522 };
1523 assert!(!page.has_next_page());
1524 }
1525
1526 #[test]
1527 fn admin_history_page_zero_per_page() {
1528 let page = AdminHistoryPage {
1529 entries: vec![],
1530 total: 10,
1531 page: 1,
1532 per_page: 0,
1533 };
1534 assert_eq!(page.total_pages(), 0);
1535 }
1536
1537 #[test]
1540 fn sort_direction_as_str_returns_correct_values() {
1541 assert_eq!(SortDirection::Asc.as_str(), "asc");
1542 assert_eq!(SortDirection::Desc.as_str(), "desc");
1543 }
1544
1545 #[test]
1546 fn sort_direction_flipped_returns_opposite() {
1547 assert_eq!(SortDirection::Asc.flipped(), SortDirection::Desc);
1548 assert_eq!(SortDirection::Desc.flipped(), SortDirection::Asc);
1549 }
1550
1551 #[test]
1552 fn admin_field_readonly_sets_editable_false() {
1553 let field = AdminField::new("created_at", AdminFieldKind::DateTime).readonly();
1554 assert!(!field.editable, "readonly() must set editable = false");
1555 }
1556
1557 #[test]
1558 fn admin_field_hide_from_list_sets_list_display_false() {
1559 let field = AdminField::new("internal_token", AdminFieldKind::Text).hide_from_list();
1560 assert!(
1561 !field.list_display,
1562 "hide_from_list() must set list_display = false"
1563 );
1564 }
1565
1566 #[test]
1567 fn admin_model_record_display_includes_display_name_and_id() {
1568 let model = DeletingModel {
1569 deleted: Mutex::new(vec![]),
1570 fail_on: None,
1571 };
1572 let record = serde_json::json!({"id": 7, "name": "foo"});
1573 assert_eq!(model.record_display(&record), "Tracked #7");
1574 }
1575
1576 #[test]
1577 fn admin_model_record_display_placeholder_when_no_id() {
1578 let model = DeletingModel {
1579 deleted: Mutex::new(vec![]),
1580 fail_on: None,
1581 };
1582 let record = serde_json::json!({"name": "bar"});
1583 assert_eq!(model.record_display(&record), "Tracked <no id>");
1584 }
1585
1586 #[test]
1587 fn admin_model_per_page_default_is_25() {
1588 let model = DeletingModel {
1589 deleted: Mutex::new(vec![]),
1590 fail_on: None,
1591 };
1592 assert_eq!(model.per_page(), 25);
1593 }
1594
1595 #[test]
1596 fn version_page_converts_to_admin_history_page() {
1597 use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp, VersionPage};
1598 use chrono::Utc;
1599
1600 let entry = VersionEntry {
1601 id: 1,
1602 table_name: "posts".to_owned(),
1603 record_id: 42,
1604 op: VersionOp::Update,
1605 actor: "admin".to_owned(),
1606 request_id: Some("req-1".to_owned()),
1607 changes: vec![ColumnChange::new(
1608 "title",
1609 Some(serde_json::json!("old")),
1610 Some(serde_json::json!("new")),
1611 )],
1612 recorded_at: Utc::now(),
1613 };
1614 let vp = VersionPage {
1615 entries: vec![entry],
1616 total: 1,
1617 page: 1,
1618 per_page: 25,
1619 };
1620
1621 let ap = AdminHistoryPage::from(vp);
1622 assert_eq!(ap.total, 1);
1623 assert_eq!(ap.page, 1);
1624 assert_eq!(ap.per_page, 25);
1625 assert_eq!(ap.entries.len(), 1);
1626 let e = &ap.entries[0];
1627 assert_eq!(e.id, 1);
1628 assert_eq!(e.actor, "admin");
1629 assert_eq!(e.op, "update");
1630 assert_eq!(e.request_id.as_deref(), Some("req-1"));
1631 assert_eq!(e.changes.len(), 1);
1632 }
1633
1634 #[test]
1635 fn version_entry_converts_to_admin_history_entry() {
1636 use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp};
1637 use chrono::Utc;
1638
1639 let entry = VersionEntry {
1640 id: 7,
1641 table_name: "users".to_owned(),
1642 record_id: 3,
1643 op: VersionOp::Delete,
1644 actor: "system".to_owned(),
1645 request_id: None,
1646 changes: vec![ColumnChange::sensitive("password_digest")],
1647 recorded_at: Utc::now(),
1648 };
1649
1650 let admin_entry = AdminHistoryEntry::from(entry);
1651 assert_eq!(admin_entry.id, 7);
1652 assert_eq!(admin_entry.actor, "system");
1653 assert_eq!(admin_entry.op, "delete");
1654 assert!(admin_entry.request_id.is_none());
1655 assert_eq!(admin_entry.changes.len(), 1);
1656 }
1657
1658 #[test]
1661 fn csv_import_mode_from_form_value_recognises_insert() {
1662 assert_eq!(
1663 CsvImportMode::from_form_value("insert"),
1664 Some(CsvImportMode::Insert)
1665 );
1666 assert_eq!(
1667 CsvImportMode::from_form_value("Insert"),
1668 Some(CsvImportMode::Insert)
1669 );
1670 }
1671
1672 #[test]
1673 fn csv_import_mode_from_form_value_recognises_dry_run() {
1674 assert_eq!(
1675 CsvImportMode::from_form_value("dry_run"),
1676 Some(CsvImportMode::DryRun)
1677 );
1678 assert_eq!(
1679 CsvImportMode::from_form_value("DryRun"),
1680 Some(CsvImportMode::DryRun)
1681 );
1682 assert_eq!(
1683 CsvImportMode::from_form_value("dry-run"),
1684 Some(CsvImportMode::DryRun)
1685 );
1686 }
1687
1688 #[test]
1689 fn csv_import_mode_from_form_value_rejects_unknown() {
1690 assert_eq!(CsvImportMode::from_form_value("upsert"), None);
1691 assert_eq!(CsvImportMode::from_form_value(""), None);
1692 assert_eq!(CsvImportMode::from_form_value("INSERT"), None);
1693 assert_eq!(CsvImportMode::from_form_value("DRY_RUN"), None);
1694 }
1695
1696 #[test]
1697 fn csv_import_mode_default_is_insert() {
1698 assert_eq!(CsvImportMode::default(), CsvImportMode::Insert);
1699 }
1700
1701 #[test]
1704 fn escape_csv_formula_prefixes_equals_sign() {
1705 assert_eq!(escape_csv_formula("=SUM(A1)"), "'=SUM(A1)");
1706 }
1707
1708 #[test]
1709 fn escape_csv_formula_prefixes_plus_and_minus_and_at() {
1710 assert_eq!(escape_csv_formula("+cmd"), "'+cmd");
1711 assert_eq!(escape_csv_formula("-1+1"), "'-1+1");
1712 assert_eq!(escape_csv_formula("@A1"), "'@A1");
1713 }
1714
1715 #[test]
1716 fn escape_csv_formula_prefixes_tab_and_cr() {
1717 assert_eq!(escape_csv_formula("\thello"), "'\thello");
1718 assert_eq!(escape_csv_formula("\rhello"), "'\rhello");
1719 }
1720
1721 #[test]
1722 fn escape_csv_formula_leaves_normal_strings_unchanged() {
1723 assert_eq!(escape_csv_formula("hello world"), "hello world");
1724 assert_eq!(escape_csv_formula("123"), "123");
1725 assert_eq!(escape_csv_formula(""), "");
1726 assert_eq!(escape_csv_formula("normal,value"), "normal,value");
1727 }
1728
1729 #[test]
1732 fn admin_model_supports_csv_export_defaults_to_false() {
1733 let model = DeletingModel {
1734 deleted: Mutex::new(vec![]),
1735 fail_on: None,
1736 };
1737 assert!(
1738 !model.supports_csv_export(),
1739 "supports_csv_export must default to false to require explicit opt-in"
1740 );
1741 }
1742
1743 #[test]
1744 fn admin_model_supports_csv_import_defaults_to_false() {
1745 let model = DeletingModel {
1746 deleted: Mutex::new(vec![]),
1747 fail_on: None,
1748 };
1749 assert!(
1750 !model.supports_csv_import(),
1751 "supports_csv_import must default to false"
1752 );
1753 }
1754
1755 #[test]
1756 fn csv_export_row_extracts_columns_and_escapes_formulas() {
1757 let model = DeletingModel {
1758 deleted: Mutex::new(vec![]),
1759 fail_on: None,
1760 };
1761 let record = serde_json::json!({
1762 "id": 1,
1763 "name": "Alice",
1764 "formula": "=EVIL()",
1765 "amount": 42.5,
1766 "active": true,
1767 "notes": null,
1768 });
1769 let columns = &[
1770 "id", "name", "formula", "amount", "active", "notes", "missing",
1771 ];
1772 let row = model.csv_export_row(columns, &record);
1773 assert_eq!(row[0], "1");
1774 assert_eq!(row[1], "Alice");
1775 assert_eq!(row[2], "'=EVIL()", "formula-leading value must be escaped");
1776 assert_eq!(row[3], "42.5");
1777 assert_eq!(row[4], "true");
1778 assert_eq!(row[5], "", "null becomes empty string");
1779 assert_eq!(row[6], "", "missing column becomes empty string");
1780 }
1781
1782 fn list_params(page: u64, per_page: u64) -> ListParams {
1783 ListParams {
1784 page,
1785 per_page,
1786 search: None,
1787 sort_by: None,
1788 sort_dir: SortDirection::default(),
1789 filters: Vec::new(),
1790 }
1791 }
1792
1793 #[test]
1794 fn sql_offset_limit_treats_per_page_zero_as_unlimited() {
1795 assert_eq!(list_params(1, 0).sql_offset_limit(), (0, i64::MAX));
1796 assert_eq!(list_params(3, 0).sql_offset_limit(), (0, i64::MAX));
1798 }
1799
1800 #[test]
1801 fn sql_offset_limit_paginates_from_page_one() {
1802 assert_eq!(list_params(1, 25).sql_offset_limit(), (0, 25));
1803 assert_eq!(list_params(2, 25).sql_offset_limit(), (25, 25));
1804 assert_eq!(list_params(3, 10).sql_offset_limit(), (20, 10));
1805 }
1806
1807 #[test]
1808 fn sql_offset_limit_treats_page_zero_like_page_one() {
1809 assert_eq!(list_params(0, 25).sql_offset_limit(), (0, 25));
1812 }
1813}