1use super::metrics::StuckThresholds;
4use super::state::{CreditRegistrationErrorCode, CreditRegistrationState, ResubmissionFacts};
5use crate::prelude::*;
6use utoipa::ToSchema;
7
8#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
13#[serde(rename_all = "snake_case")]
14#[schema(as = CreditRegistrationAttentionReason)]
16pub enum AttentionReason {
17 StuckInState,
19 PermanentError,
20 RetryWindowExpired,
21 Misregistered,
22 TooManyAttempts,
23 OutcomeUncertain,
25}
26
27impl AttentionReason {
28 pub const ALL: [Self; 6] = [
29 Self::StuckInState,
30 Self::PermanentError,
31 Self::RetryWindowExpired,
32 Self::Misregistered,
33 Self::TooManyAttempts,
34 Self::OutcomeUncertain,
35 ];
36
37 fn as_str(self) -> &'static str {
40 match self {
41 Self::StuckInState => "stuck_in_state",
42 Self::PermanentError => "permanent_error",
43 Self::RetryWindowExpired => "retry_window_expired",
44 Self::Misregistered => "misregistered",
45 Self::TooManyAttempts => "too_many_attempts",
46 Self::OutcomeUncertain => "outcome_uncertain",
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum AttentionSort {
55 #[default]
56 TimeInState,
57 NextAttempt,
58 Course,
59}
60
61impl AttentionSort {
62 fn as_str(self) -> &'static str {
64 match self {
65 Self::TimeInState => "time_in_state",
66 Self::NextAttempt => "next_attempt",
67 Self::Course => "course",
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
77pub struct AttentionRegistration {
78 pub id: Uuid,
79 pub user_id: Uuid,
80 pub first_name: Option<String>,
81 pub last_name: Option<String>,
82 pub email: Option<String>,
83 pub course_id: Uuid,
84 pub course_name: String,
85 pub course_module_id: Uuid,
86 pub course_module_name: Option<String>,
87 pub state: CreditRegistrationState,
88 pub state_entered_at: DateTime<Utc>,
89 pub error_code: Option<CreditRegistrationErrorCode>,
90 pub attempt_count: i32,
91 pub needs_admin_attention: bool,
94 pub next_attempt_at: DateTime<Utc>,
95 pub submitted_at: Option<DateTime<Utc>>,
96 pub resubmit_not_before: Option<DateTime<Utc>>,
97 pub student_number: Option<DbSecret>,
98 pub stuck_in_state: bool,
99 pub permanent_error: bool,
100 pub retry_window_expired: bool,
101 pub misregistered: bool,
102 pub too_many_attempts: bool,
103 pub outcome_uncertain: bool,
104 pub total_count: i64,
105 pub stuck_in_state_count: i64,
106 pub permanent_error_count: i64,
107 pub retry_window_expired_count: i64,
108 pub misregistered_count: i64,
109 pub too_many_attempts_count: i64,
110 pub outcome_uncertain_count: i64,
111 pub flagged_without_reason_count: i64,
114}
115
116impl AttentionRegistration {
117 pub fn resubmission_facts(&self) -> ResubmissionFacts {
120 ResubmissionFacts {
121 state: self.state,
122 is_superseded: false,
123 error_code: self.error_code,
124 resubmit_not_before: self.resubmit_not_before,
125 submitted_at: self.submitted_at,
126 }
127 }
128
129 pub fn reasons(&self) -> Vec<AttentionReason> {
131 AttentionReason::ALL
132 .into_iter()
133 .filter(|reason| match reason {
134 AttentionReason::StuckInState => self.stuck_in_state,
135 AttentionReason::PermanentError => self.permanent_error,
136 AttentionReason::RetryWindowExpired => self.retry_window_expired,
137 AttentionReason::Misregistered => self.misregistered,
138 AttentionReason::TooManyAttempts => self.too_many_attempts,
139 AttentionReason::OutcomeUncertain => self.outcome_uncertain,
140 })
141 .collect()
142 }
143
144 pub fn counts_by_reason(&self) -> Vec<(AttentionReason, i64)> {
146 vec![
147 (AttentionReason::StuckInState, self.stuck_in_state_count),
148 (AttentionReason::PermanentError, self.permanent_error_count),
149 (
150 AttentionReason::RetryWindowExpired,
151 self.retry_window_expired_count,
152 ),
153 (AttentionReason::Misregistered, self.misregistered_count),
154 (
155 AttentionReason::TooManyAttempts,
156 self.too_many_attempts_count,
157 ),
158 (
159 AttentionReason::OutcomeUncertain,
160 self.outcome_uncertain_count,
161 ),
162 ]
163 }
164}
165
166#[derive(Debug, Clone, Copy)]
171pub struct AttentionSelection<'a> {
172 pub reasons: &'a [AttentionReason],
173 pub only_without_reason: bool,
174 pub sort: AttentionSort,
175 pub limit: i64,
176 pub offset: i64,
177}
178
179impl Default for AttentionSelection<'_> {
180 fn default() -> Self {
181 Self {
182 reasons: &[],
183 only_without_reason: false,
184 sort: AttentionSort::TimeInState,
185 limit: 1,
186 offset: 0,
187 }
188 }
189}
190
191pub async fn get_attention_items(
203 conn: &mut PgConnection,
204 thresholds: &StuckThresholds,
205 too_many_attempts: i32,
206 selection: AttentionSelection<'_>,
207) -> ModelResult<Vec<AttentionRegistration>> {
208 let AttentionSelection {
209 reasons,
210 only_without_reason,
211 sort,
212 limit,
213 offset,
214 } = selection;
215 let (state_thresholds, threshold_secs) = thresholds.state_seconds_arrays();
216 let reason_names: Vec<&str> = reasons.iter().map(|reason| reason.as_str()).collect();
217 let res = sqlx::query_as!(
218 AttentionRegistration,
219 r#"
220SELECT cr.id,
221 cr.user_id,
222 ud.first_name AS "first_name?",
223 ud.last_name AS "last_name?",
224 ud.email AS "email?",
225 cr.course_id,
226 c.name AS course_name,
227 cr.course_module_id,
228 cm.name AS course_module_name,
229 cr.state,
230 cr.state_entered_at,
231 cr.error_code AS "error_code?",
232 cr.submit_retry_count + cr.verify_attempt_count AS "attempt_count!",
233 cr.needs_admin_attention,
234 cr.next_attempt_at,
235 cr.submitted_at,
236 cr.resubmit_not_before,
237 cr.student_number,
238 d.stuck_in_state AS "stuck_in_state!",
239 d.permanent_error AS "permanent_error!",
240 d.retry_window_expired AS "retry_window_expired!",
241 d.misregistered AS "misregistered!",
242 d.too_many_attempts AS "too_many_attempts!",
243 d.outcome_uncertain AS "outcome_uncertain!",
244 COUNT(*) OVER () AS "total_count!",
245 COUNT(*) FILTER (
246 WHERE d.stuck_in_state
247 ) OVER () AS "stuck_in_state_count!",
248 COUNT(*) FILTER (
249 WHERE d.permanent_error
250 ) OVER () AS "permanent_error_count!",
251 COUNT(*) FILTER (
252 WHERE d.retry_window_expired
253 ) OVER () AS "retry_window_expired_count!",
254 COUNT(*) FILTER (
255 WHERE d.misregistered
256 ) OVER () AS "misregistered_count!",
257 COUNT(*) FILTER (
258 WHERE d.too_many_attempts
259 ) OVER () AS "too_many_attempts_count!",
260 COUNT(*) FILTER (
261 WHERE d.outcome_uncertain
262 ) OVER () AS "outcome_uncertain_count!",
263 COUNT(*) FILTER (
264 WHERE NOT any_d.any_reason
265 ) OVER () AS "flagged_without_reason_count!"
266FROM credit_registrations cr
267 JOIN courses c ON c.id = cr.course_id
268 JOIN course_modules cm ON cm.id = cr.course_module_id
269 LEFT JOIN user_details ud ON ud.user_id = cr.user_id
270 LEFT JOIN LATERAL (
271 SELECT u.threshold_secs
272 FROM UNNEST($1::credit_registration_state [], $2::double precision []) AS u(state, threshold_secs)
273 WHERE u.state = cr.state
274 ) t ON TRUE
275 CROSS JOIN LATERAL (
276 SELECT cr.terminal_at IS NULL
277 AND t.threshold_secs IS NOT NULL
278 AND now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs) AS stuck_in_state,
279 cr.state = 'failed_permanent'
280 AND cr.needs_admin_attention AS permanent_error,
281 -- Coalesced because error_code is nullable and this is selected into a plain `bool`: a row
282 -- another detector picked while holding no error code would otherwise fail to decode and take
283 -- the whole table down with it.
284 COALESCE(cr.error_code = 'retry_window_expired', FALSE) AS retry_window_expired,
285 cr.state = 'misregistered' AS misregistered,
286 cr.submit_retry_count >= $3 AS too_many_attempts,
287 cr.state = 'submission_uncertain' AS outcome_uncertain
288 ) d
289 CROSS JOIN LATERAL (
290 SELECT d.stuck_in_state
291 OR d.permanent_error
292 OR d.retry_window_expired
293 OR d.misregistered
294 OR d.too_many_attempts
295 OR d.outcome_uncertain AS any_reason
296 ) any_d
297WHERE cr.superseded_by_id IS NULL
298 AND cr.deleted_at IS NULL
299 AND (
300 any_d.any_reason
301 OR cr.needs_admin_attention
302 )
303 AND (
304 NOT $8::bool
305 OR NOT any_d.any_reason
306 )
307 AND (
308 CARDINALITY($4::text []) = 0
309 OR (d.stuck_in_state AND 'stuck_in_state' = ANY($4))
310 OR (d.permanent_error AND 'permanent_error' = ANY($4))
311 OR (
312 d.retry_window_expired
313 AND 'retry_window_expired' = ANY($4)
314 )
315 OR (d.misregistered AND 'misregistered' = ANY($4))
316 OR (
317 d.too_many_attempts
318 AND 'too_many_attempts' = ANY($4)
319 )
320 OR (
321 d.outcome_uncertain
322 AND 'outcome_uncertain' = ANY($4)
323 )
324 )
325ORDER BY CASE
326 WHEN $5::text = 'course' THEN c.name
327 END,
328 CASE
329 WHEN $5::text = 'next_attempt' THEN cr.next_attempt_at
330 ELSE cr.state_entered_at
331 END,
332 cr.id
333LIMIT $6 OFFSET $7
334 "#,
335 &state_thresholds as &[CreditRegistrationState],
336 &threshold_secs as &[f64],
337 too_many_attempts,
338 &reason_names as &[&str],
339 sort.as_str(),
340 limit,
341 offset,
342 only_without_reason,
343 )
344 .fetch_all(conn)
345 .await?;
346 Ok(res)
347}
348
349pub async fn count_needing_attention(
354 conn: &mut PgConnection,
355 thresholds: &StuckThresholds,
356 too_many_attempts: i32,
357) -> ModelResult<Option<AttentionRegistration>> {
358 let rows = get_attention_items(
359 conn,
360 thresholds,
361 too_many_attempts,
362 AttentionSelection::default(),
363 )
364 .await?;
365 Ok(rows.into_iter().next())
366}