rustio_admin/admin/bulk.rs
1//! Public surface for project-defined bulk-action dispatch.
2//!
3//! The framework owns the metadata declaration ([`super::BulkAction`])
4//! and the rendering / confirmation flow; *projects* own what each
5//! action actually does. This module carries the three types that
6//! cross the public boundary between framework and project:
7//!
8//! - [`BulkActionContext`] — what the framework hands to the
9//! project's handler (actor, correlation-id, client IP). Narrow
10//! by design; `#[non_exhaustive]` keeps it SemVer-safe.
11//! - [`BulkActionResult`] — what the project hands back. Carries
12//! a succeeded count, an optional per-id failure list, and an
13//! operator-facing summary line.
14//! - [`BulkActionFailure`] — one row that failed inside an
15//! otherwise-successful batch.
16//!
17//! The dispatcher itself is [`super::ModelAdmin::execute_bulk_action`].
18//! See its docstring for the contract; see `DESIGN_CHROME.md` for the
19//! bulk-bar's visual conventions.
20//!
21//! The framework emits **one** `audit::record` row per submission
22//! after the project's handler returns. Projects don't need to call
23//! `audit::record` themselves for the dispatch envelope; any
24//! business-level audit emissions inside the action body are still
25//! the project's call.
26
27use crate::auth::Identity;
28
29// public:
30/// Per-request context the framework passes into project-side
31/// [`super::ModelAdmin::execute_bulk_action`] implementations.
32///
33/// Narrower than a generic "request context" on purpose. The
34/// framework promises three facts at the dispatch boundary:
35///
36/// - `actor` — who initiated this action. Used by projects for
37/// per-actor authorisation refinements, per-row audit
38/// emission, and "edited_by"-shaped column writes.
39/// - `correlation_id` — the per-request UUID. Projects that emit
40/// their own audit rows should reuse it so the history chain
41/// stays linked through their work.
42/// - `ip_address` — the client IP resolved from `x-forwarded-for`
43/// / `x-real-ip` headers, when present.
44///
45/// `#[non_exhaustive]` so future additions (e.g. a per-request
46/// transaction handle, a `ProgressHandle` for streaming progress)
47/// stay SemVer-safe.
48#[non_exhaustive]
49pub struct BulkActionContext<'a> {
50 /// The signed-in operator who initiated the bulk dispatch.
51 pub actor: &'a Identity,
52 /// Per-request correlation id from the R0 middleware. `None`
53 /// only on request paths that don't run the correlation
54 /// middleware (none in the framework today; reserved for
55 /// future custom mounts).
56 pub correlation_id: Option<&'a str>,
57 /// Client IP resolved from request headers. `None` when the
58 /// framework couldn't determine it (no proxy headers, no
59 /// connecting peer info available).
60 pub ip_address: Option<&'a str>,
61}
62
63impl<'a> BulkActionContext<'a> {
64 /// Construct a minimal context — actor-only. Future fields
65 /// default to `None`. Useful for project-side unit tests of
66 /// `execute_bulk_action` that don't want to fabricate a
67 /// correlation id or IP.
68 pub fn new(actor: &'a Identity) -> Self {
69 Self {
70 actor,
71 correlation_id: None,
72 ip_address: None,
73 }
74 }
75}
76
77// public:
78/// Outcome of a project-defined bulk action.
79///
80/// Two channels:
81///
82/// - The action *itself* failed (unknown action name, project
83/// code panicked into a `Result::Err`, DB connection lost).
84/// The dispatcher returns `Err(...)` from
85/// [`super::ModelAdmin::execute_bulk_action`]; the framework
86/// surfaces it as a 4xx / 5xx page.
87/// - The action ran but *some rows* failed (one of three loans
88/// was already in the target state, an FK constraint
89/// rejected one of the writes). The dispatcher returns
90/// `Ok(BulkActionResult)` with `failed` populated; the
91/// framework emits a partial-success audit row and the
92/// operator sees a per-id failure summary.
93///
94/// `#[non_exhaustive]` so future additions (e.g. a `warnings`
95/// channel, a `progress_handle` for streaming) stay SemVer-safe.
96#[non_exhaustive]
97#[derive(Debug, Clone, Default)]
98pub struct BulkActionResult {
99 /// Number of rows the project successfully applied the action
100 /// to.
101 pub succeeded: usize,
102 /// Per-row failure list. Empty for a clean run; one entry per
103 /// row the project tried and couldn't complete.
104 pub failed: Vec<BulkActionFailure>,
105 /// Operator-facing summary line. `None` lets the framework
106 /// fall back to its default rendering
107 /// (`"<label>: <succeeded> of <total>"`).
108 pub message: Option<String>,
109}
110
111impl BulkActionResult {
112 /// All `succeeded` rows applied cleanly; no failures.
113 pub fn ok(succeeded: usize) -> Self {
114 Self {
115 succeeded,
116 failed: Vec::new(),
117 message: None,
118 }
119 }
120
121 /// Mixed outcome — pair the survivor count with the per-id
122 /// failure list.
123 pub fn partial(succeeded: usize, failed: Vec<BulkActionFailure>) -> Self {
124 Self {
125 succeeded,
126 failed,
127 message: None,
128 }
129 }
130
131 /// Total rows the project attempted: `succeeded + failed.len()`.
132 pub fn total(&self) -> usize {
133 self.succeeded + self.failed.len()
134 }
135
136 /// Attach an operator-facing summary line. Builder-style for
137 /// terse construction:
138 ///
139 /// ```ignore
140 /// BulkActionResult::ok(12).with_message("Marked 12 loans overdue")
141 /// ```
142 pub fn with_message(mut self, message: impl Into<String>) -> Self {
143 self.message = Some(message.into());
144 self
145 }
146}
147
148// public:
149/// One row that failed inside an otherwise-successful bulk batch.
150///
151/// `#[non_exhaustive]` so future additions (e.g. a structured
152/// `ErrorKind` enum, a retryable hint) stay SemVer-safe.
153#[non_exhaustive]
154#[derive(Debug, Clone)]
155pub struct BulkActionFailure {
156 /// The row id the project couldn't apply the action to.
157 pub id: i64,
158 /// Operator-facing reason. Shown as-is on the bulk result
159 /// flash / page; redact sensitive details before constructing.
160 pub reason: String,
161}
162
163impl BulkActionFailure {
164 /// Construct a failure entry with a reason string.
165 pub fn new(id: i64, reason: impl Into<String>) -> Self {
166 Self {
167 id,
168 reason: reason.into(),
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn ok_constructs_clean_result() {
179 let r = BulkActionResult::ok(7);
180 assert_eq!(r.succeeded, 7);
181 assert!(r.failed.is_empty());
182 assert!(r.message.is_none());
183 assert_eq!(r.total(), 7);
184 }
185
186 #[test]
187 fn partial_carries_failures_into_total() {
188 let r = BulkActionResult::partial(
189 5,
190 vec![
191 BulkActionFailure::new(42, "already overdue"),
192 BulkActionFailure::new(51, "row not active"),
193 ],
194 );
195 assert_eq!(r.succeeded, 5);
196 assert_eq!(r.failed.len(), 2);
197 assert_eq!(r.total(), 7);
198 assert_eq!(r.failed[0].id, 42);
199 assert_eq!(r.failed[0].reason, "already overdue");
200 }
201
202 #[test]
203 fn with_message_attaches_summary() {
204 let r = BulkActionResult::ok(3).with_message("Marked 3 loans overdue");
205 assert_eq!(r.message.as_deref(), Some("Marked 3 loans overdue"));
206 }
207
208 #[test]
209 fn default_is_zero_result() {
210 let r = BulkActionResult::default();
211 assert_eq!(r.succeeded, 0);
212 assert!(r.failed.is_empty());
213 assert_eq!(r.total(), 0);
214 }
215
216 #[test]
217 fn failure_new_owns_reason() {
218 // String, &str, and String::from all flow through Into<String>.
219 let f1 = BulkActionFailure::new(1, "static");
220 let f2 = BulkActionFailure::new(2, String::from("owned"));
221 let f3 = BulkActionFailure::new(3, format!("{}-{}", "fmt", "string"));
222 assert_eq!(f1.reason, "static");
223 assert_eq!(f2.reason, "owned");
224 assert_eq!(f3.reason, "fmt-string");
225 }
226}