1use headless_lms_models::credit_registration_events::{self, ErrorCodeWindowCounts};
4use headless_lms_models::credit_registrations::{
5 self, AttentionRegistration, CreditRegistrationErrorCode, CreditRegistrationState,
6};
7use headless_lms_models::library::credit_registration::classification::{
8 Retryability, retryability,
9};
10use headless_lms_models::suotar_api_calls::SuotarEndpoint;
11use utoipa::ToSchema;
12
13use crate::domain::credit_registration::health::{
14 CreditRegistrationAlertThresholds, stuck_thresholds, thresholds,
15};
16use crate::prelude::*;
17
18use super::authorize_credit_registration_admin;
19
20const TOO_MANY_ATTEMPTS: i32 = 5;
22const ATTENTION_LIMIT: i64 = 500;
25const DEFAULT_ERROR_WINDOW_SECS: i64 = 24 * 60 * 60;
26const MAX_ERROR_WINDOW_SECS: i64 = 90 * 24 * 60 * 60;
27
28#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
30#[serde(rename_all = "snake_case")]
31pub enum CreditRegistrationAttentionReason {
32 StuckInState,
34 PermanentError,
35 RetryWindowExpired,
36 Misregistered,
37 TooManyAttempts,
38 OutcomeUncertain,
40 FlaggedByPipeline,
42}
43
44#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
45pub struct CreditRegistrationAttentionItem {
46 pub credit_registration_id: Uuid,
47 pub user_id: Uuid,
48 pub first_name: Option<String>,
49 pub last_name: Option<String>,
50 pub email: Option<String>,
52 pub course_id: Uuid,
53 pub course_name: String,
54 pub course_module_id: Uuid,
55 pub course_module_name: Option<String>,
56 pub state: CreditRegistrationState,
57 pub state_entered_at: DateTime<Utc>,
58 pub error_code: Option<CreditRegistrationErrorCode>,
59 pub attempt_count: i32,
60 pub next_attempt_at: DateTime<Utc>,
61 pub student_number: Option<String>,
62 pub reasons: Vec<CreditRegistrationAttentionReason>,
64}
65
66#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
67pub struct CreditRegistrationAttentionReasonCount {
68 pub reason: CreditRegistrationAttentionReason,
69 pub count: i64,
70}
71
72#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
73pub struct CreditRegistrationAttentionItems {
74 pub items: Vec<CreditRegistrationAttentionItem>,
75 pub total_count: i64,
78 pub counts_by_reason: Vec<CreditRegistrationAttentionReasonCount>,
79 pub max_items: i64,
80}
81
82#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
84pub struct CreditRegistrationErrorCodeWindow {
85 pub error_code: CreditRegistrationErrorCode,
86 pub retryability: Retryability,
88 pub current_count: i64,
89 pub previous_count: i64,
90 pub user_count: i64,
91 pub course_count: i64,
92 pub first_seen_at: Option<DateTime<Utc>>,
93 pub last_seen_at: Option<DateTime<Utc>>,
94 pub endpoints: Vec<SuotarEndpoint>,
95}
96
97#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
100pub struct CreditRegistrationTerminalVerdicts {
101 pub registered_count: i64,
102 pub duplicate_and_not_improved_count: i64,
103 pub failed_permanent_count: i64,
104 pub cancelled_count: i64,
105 pub total_count: i64,
107}
108
109#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
110pub struct CreditRegistrationErrorsByCode {
111 pub window_secs: i64,
112 pub codes: Vec<CreditRegistrationErrorCodeWindow>,
113 pub verdicts: CreditRegistrationTerminalVerdicts,
114}
115
116#[derive(Debug, Deserialize)]
117pub struct ErrorWindowQuery {
118 window_secs: Option<i64>,
119}
120
121#[instrument(skip(pool))]
129#[utoipa::path(
130 get,
131 path = "/thresholds",
132 operation_id = "getCreditRegistrationThresholds",
133 tag = "credit-registration-admin",
134 responses(
135 (status = 200, description = "The thresholds every rule and detector shares", body = CreditRegistrationAlertThresholds)
136 )
137)]
138pub async fn get_credit_registration_thresholds(
139 user: AuthUser,
140 pool: web::Data<PgPool>,
141) -> ControllerResult<web::Json<CreditRegistrationAlertThresholds>> {
142 let mut conn = pool.acquire().await?;
143 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
144 token.authorized_ok(web::Json(thresholds()))
145}
146
147#[instrument(skip(pool))]
154#[utoipa::path(
155 get,
156 path = "/attention",
157 operation_id = "getCreditRegistrationAttentionItems",
158 tag = "credit-registration-admin",
159 responses(
160 (status = 200, description = "Rows needing a human, and how many for each reason", body = CreditRegistrationAttentionItems)
161 )
162)]
163pub async fn get_credit_registration_attention_items(
164 user: AuthUser,
165 pool: web::Data<PgPool>,
166) -> ControllerResult<web::Json<CreditRegistrationAttentionItems>> {
167 let mut conn = pool.acquire().await?;
168 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
169
170 let items: Vec<CreditRegistrationAttentionItem> = credit_registrations::get_attention_items(
171 &mut conn,
172 &stuck_thresholds(),
173 TOO_MANY_ATTEMPTS,
174 ATTENTION_LIMIT,
175 )
176 .await?
177 .into_iter()
178 .map(to_attention_item)
179 .collect();
180
181 let counts_by_reason = ALL_ATTENTION_REASONS
182 .into_iter()
183 .map(|reason| CreditRegistrationAttentionReasonCount {
184 reason,
185 count: items
186 .iter()
187 .filter(|item| item.reasons.contains(&reason))
188 .count() as i64,
189 })
190 .filter(|row| row.count > 0)
191 .collect();
192
193 token.authorized_ok(web::Json(CreditRegistrationAttentionItems {
194 total_count: items.len() as i64,
195 items,
196 counts_by_reason,
197 max_items: ATTENTION_LIMIT,
198 }))
199}
200
201#[instrument(skip(pool))]
209#[utoipa::path(
210 get,
211 path = "/errors/by-code",
212 operation_id = "getCreditRegistrationErrorsByCode",
213 tag = "credit-registration-admin",
214 params(("window_secs" = Option<i64>, Query, description = "Window length in seconds; the same length before it is the comparison")),
215 responses(
216 (status = 200, description = "Per-code counts and the window's verdicts", body = CreditRegistrationErrorsByCode)
217 )
218)]
219pub async fn get_credit_registration_errors_by_code(
220 user: AuthUser,
221 pool: web::Data<PgPool>,
222 query: web::Query<ErrorWindowQuery>,
223) -> ControllerResult<web::Json<CreditRegistrationErrorsByCode>> {
224 let mut conn = pool.acquire().await?;
225 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
226
227 let window_secs = query
228 .window_secs
229 .unwrap_or(DEFAULT_ERROR_WINDOW_SECS)
230 .clamp(60, MAX_ERROR_WINDOW_SECS);
231 let codes =
232 credit_registration_events::get_error_code_counts_for_window(&mut conn, window_secs)
233 .await?
234 .into_iter()
235 .map(to_error_code_window)
236 .collect();
237 let totals = credit_registrations::count_terminal_outcomes_since(
238 &mut conn,
239 Utc::now() - chrono::Duration::seconds(window_secs),
240 )
241 .await?;
242
243 token.authorized_ok(web::Json(CreditRegistrationErrorsByCode {
244 window_secs,
245 codes,
246 verdicts: CreditRegistrationTerminalVerdicts {
247 registered_count: totals.registered_count,
248 duplicate_and_not_improved_count: totals.success_count - totals.registered_count,
249 failed_permanent_count: totals.failed_permanent_count,
250 cancelled_count: totals.cancelled_count,
251 total_count: totals.total_count,
252 },
253 }))
254}
255
256const ALL_ATTENTION_REASONS: [CreditRegistrationAttentionReason; 7] = [
257 CreditRegistrationAttentionReason::StuckInState,
258 CreditRegistrationAttentionReason::PermanentError,
259 CreditRegistrationAttentionReason::RetryWindowExpired,
260 CreditRegistrationAttentionReason::Misregistered,
261 CreditRegistrationAttentionReason::TooManyAttempts,
262 CreditRegistrationAttentionReason::OutcomeUncertain,
263 CreditRegistrationAttentionReason::FlaggedByPipeline,
264];
265
266fn to_attention_item(row: AttentionRegistration) -> CreditRegistrationAttentionItem {
267 let flags = [
268 (
269 row.stuck_in_state,
270 CreditRegistrationAttentionReason::StuckInState,
271 ),
272 (
273 row.permanent_error,
274 CreditRegistrationAttentionReason::PermanentError,
275 ),
276 (
277 row.retry_window_expired,
278 CreditRegistrationAttentionReason::RetryWindowExpired,
279 ),
280 (
281 row.misregistered,
282 CreditRegistrationAttentionReason::Misregistered,
283 ),
284 (
285 row.too_many_attempts,
286 CreditRegistrationAttentionReason::TooManyAttempts,
287 ),
288 (
289 row.outcome_uncertain,
290 CreditRegistrationAttentionReason::OutcomeUncertain,
291 ),
292 (
293 row.flagged_by_pipeline,
294 CreditRegistrationAttentionReason::FlaggedByPipeline,
295 ),
296 ];
297 CreditRegistrationAttentionItem {
298 reasons: flags
299 .into_iter()
300 .filter_map(|(fired, reason)| fired.then_some(reason))
301 .collect(),
302 credit_registration_id: row.id,
303 user_id: row.user_id,
304 first_name: row.first_name,
305 last_name: row.last_name,
306 email: row.email,
307 course_id: row.course_id,
308 course_name: row.course_name,
309 course_module_id: row.course_module_id,
310 course_module_name: row.course_module_name,
311 state: row.state,
312 state_entered_at: row.state_entered_at,
313 error_code: row.error_code,
314 attempt_count: row.attempt_count,
315 next_attempt_at: row.next_attempt_at,
316 student_number: row.student_number,
317 }
318}
319
320fn to_error_code_window(row: ErrorCodeWindowCounts) -> CreditRegistrationErrorCodeWindow {
321 CreditRegistrationErrorCodeWindow {
322 retryability: retryability(row.error_code),
323 error_code: row.error_code,
324 current_count: row.current_count,
325 previous_count: row.previous_count,
326 user_count: row.user_count,
327 course_count: row.course_count,
328 first_seen_at: row.first_seen_at,
329 last_seen_at: row.last_seen_at,
330 endpoints: row.endpoints,
331 }
332}
333
334pub fn _add_routes(cfg: &mut ServiceConfig) {
335 cfg.route(
336 "/thresholds",
337 web::get().to(get_credit_registration_thresholds),
338 )
339 .route(
340 "/attention",
341 web::get().to(get_credit_registration_attention_items),
342 )
343 .route(
344 "/errors/by-code",
345 web::get().to(get_credit_registration_errors_by_code),
346 );
347}