1use headless_lms_base::error::backend_error::BackendError;
8use headless_lms_models::credit_registration_events::CreditRegistrationEventKind;
9use headless_lms_models::credit_registration_phase_state::PhaseRunOutcome;
10use headless_lms_models::credit_registrations::{
11 CreditRegistration, CreditRegistrationState, RequestPurpose, Transition, claim_due,
12 increment_verify_attempt_counts, request_item_id, schedule_next_attempts,
13 set_sisu_attainment_if_unclaimed, transition,
14};
15use headless_lms_models::library::credit_registration::classification::{map_code, settled_state};
16use headless_lms_models::library::credit_registration::enrolment_selection::attainment_matching_submission;
17use headless_lms_models::library::credit_registration::outcomes::{
18 Outcome, RowFacts, uncertain_recheck_outcome, verify_error_outcome,
19 verify_not_registered_outcome, verify_poll_lease_until,
20};
21use headless_lms_models::library::credit_registration::submission_context::get_submission_contexts;
22use headless_lms_utils::error::util_error::UtilError;
23use headless_lms_utils::prelude::Utc;
24use headless_lms_utils::services::suotar::{
25 EnrolmentResolutionResult, ResolveEnrolmentRequestItem, SuotarBatchResponse, SuotarCallContext,
26 SuotarEndpoint, SuotarItemStatus, SuotarResponseItem, VerifyAttainmentRequestItem,
27 VerifyAttainmentResult,
28};
29use sqlx::{Connection, PgConnection};
30
31use super::{
32 CreditRegistrationPhase, OutcomeEvent, PhaseContext, PhaseScope, Prepared, SuotarBatchPhase,
33 apply_outcome, counts_as_failed, row_facts, run_suotar_batch_phase,
34};
35
36const CLAIMED_STATES: [CreditRegistrationState; 2] = [
39 CreditRegistrationState::AwaitingVerification,
40 CreditRegistrationState::SubmissionUncertain,
41];
42
43struct Poll {
46 row: CreditRegistration,
47 attempt: i32,
48 submitted_attainment_id: String,
49}
50
51struct Recovery {
54 row: CreditRegistration,
55 attempt: i32,
56}
57
58pub async fn run(ctx: &PhaseContext<'_>, scope: &PhaseScope) -> anyhow::Result<PhaseRunOutcome> {
59 let mut conn = ctx.pool.acquire().await?;
60 let mut tx = conn.begin().await?;
61 let claimed = claim_due(
62 &mut tx,
63 &CLAIMED_STATES,
64 scope,
65 SuotarEndpoint::VerifyAttainments.max_batch_size() as i64,
66 )
67 .await?;
68 let attempts = increment_verify_attempt_counts(
71 &mut tx,
72 &claimed.iter().map(|row| row.id).collect::<Vec<_>>(),
73 )
74 .await?;
75 let now = Utc::now();
78 let scheduled: Vec<_> = attempts
79 .iter()
80 .map(|(id, attempt)| (*id, verify_poll_lease_until(now, *attempt)))
81 .collect();
82 schedule_next_attempts(&mut tx, &scheduled).await?;
83
84 let mut polls = Vec::new();
85 let mut recoveries = Vec::new();
86 for row in claimed {
87 let Some(attempt) = attempts.get(&row.id).copied() else {
88 continue;
89 };
90 match row.submitted_attainment_id.clone() {
91 Some(submitted_attainment_id) => polls.push(Poll {
92 row,
93 attempt,
94 submitted_attainment_id,
95 }),
96 None if row.state == CreditRegistrationState::SubmissionUncertain => {
97 recoveries.push(Recovery { row, attempt })
98 }
99 None => {
100 warn!(
101 "Credit registration {} is awaiting verification with no submitted attainment id.",
102 row.id
103 );
104 }
105 }
106 }
107 tx.commit().await?;
108 drop(conn);
110
111 let mut outcome = PhaseRunOutcome::default();
112 if !polls.is_empty() {
113 add(
114 &mut outcome,
115 run_suotar_batch_phase(&mut VerifyPoll { polls }, ctx, scope).await?,
116 );
117 }
118 let batch_size = SuotarEndpoint::ResolveEnrolments.max_batch_size();
121 while !recoveries.is_empty() {
122 let rest = recoveries.split_off(batch_size.min(recoveries.len()));
123 let mut flow = UncertainRecovery { recoveries };
124 add(
125 &mut outcome,
126 run_suotar_batch_phase(&mut flow, ctx, scope).await?,
127 );
128 recoveries = rest;
129 }
130 Ok(outcome)
131}
132
133fn add(total: &mut PhaseRunOutcome, part: PhaseRunOutcome) {
135 total.items_processed += part.items_processed;
136 total.items_failed += part.items_failed;
137 total.error = total.error.take().or(part.error);
138}
139
140struct VerifyPoll {
142 polls: Vec<Poll>,
143}
144
145impl SuotarBatchPhase for VerifyPoll {
146 type Row = Poll;
147 type Item = VerifyAttainmentRequestItem;
148 type Result = VerifyAttainmentResult;
149
150 const ALL_TRANSIENT_ERROR: &'static str =
151 "Every verify poll came back transiently unavailable.";
152
153 async fn prepare(
156 &mut self,
157 _ctx: &PhaseContext<'_>,
158 _conn: &mut PgConnection,
159 _scope: &PhaseScope,
160 ) -> anyhow::Result<Prepared<Self::Row, Self::Item>> {
161 Ok(Prepared {
162 sendable: std::mem::take(&mut self.polls)
163 .into_iter()
164 .map(|poll| {
165 let item = VerifyAttainmentRequestItem {
166 request_item_id: Self::request_item_id(&poll),
167 submitted_attainment_id: poll.submitted_attainment_id.clone(),
168 };
169 (poll, item)
170 })
171 .collect(),
172 ..Prepared::default()
173 })
174 }
175
176 fn registration(poll: &Self::Row) -> &CreditRegistration {
177 &poll.row
178 }
179
180 fn request_item_id(poll: &Self::Row) -> String {
181 request_item_id(&poll.row, RequestPurpose::VerifyPoll(poll.attempt))
182 }
183
184 async fn send(
185 &self,
186 ctx: &PhaseContext<'_>,
187 rows: &[Self::Row],
188 items: Vec<Self::Item>,
189 ) -> Result<SuotarBatchResponse<Self::Result>, UtilError> {
190 ctx.suotar_client
191 .verify_attainments(
192 SuotarCallContext::new(ctx.worker_name(CreditRegistrationPhase::Verify))
193 .for_registrations(rows.iter().map(|poll| poll.row.id).collect()),
194 items,
195 )
196 .await
197 }
198
199 async fn apply(
200 &self,
201 conn: &mut PgConnection,
202 poll: &Self::Row,
203 item: Option<&SuotarResponseItem<Self::Result>>,
204 event: OutcomeEvent<'_>,
205 ) -> anyhow::Result<bool> {
206 apply_poll_answer(conn, poll, item, event).await
207 }
208
209 async fn apply_request_rejection(
213 &self,
214 conn: &mut PgConnection,
215 poll: &Self::Row,
216 request: &serde_json::Value,
217 error: &UtilError,
218 ) -> anyhow::Result<bool> {
219 apply_outcome(
220 conn,
221 &poll.row,
222 &verify_not_registered_outcome(poll.row.state, &poll.facts()),
223 OutcomeEvent {
224 message: Some("Could not verify this submission this time."),
225 error_message: Some(error.message()),
226 request: Some(request),
227 ..OutcomeEvent::default()
228 },
229 Some(poll.row.state),
230 )
231 .await?;
232 Ok(false)
233 }
234}
235
236impl Poll {
237 fn facts(&self) -> RowFacts {
240 RowFacts {
241 verify_attempt_count: self.attempt,
242 ..row_facts(&self.row)
243 }
244 }
245}
246
247async fn apply_poll_answer(
250 conn: &mut PgConnection,
251 poll: &Poll,
252 item: Option<&SuotarResponseItem<VerifyAttainmentResult>>,
253 event: OutcomeEvent<'_>,
254) -> anyhow::Result<bool> {
255 let row = &poll.row;
256 let registered = item.is_some_and(|item| {
257 item.status == SuotarItemStatus::Ok
258 && settled_state(SuotarEndpoint::VerifyAttainments, &item.code)
259 == Some(CreditRegistrationState::Registered)
260 });
261 if registered {
262 if let Some(result) = item.and_then(|item| item.result.as_ref()) {
263 set_sisu_attainment_if_unclaimed(
264 conn,
265 row.id,
266 &result.attainment.id,
267 Some(&result.attainment.attainment_type),
268 )
269 .await?;
270 }
271 apply_outcome(
272 conn,
273 row,
274 &Outcome {
275 needs_admin_attention: Some(false),
277 ..Outcome::to(CreditRegistrationState::Registered)
278 },
279 event,
280 Some(row.state),
281 )
282 .await?;
283 return Ok(false);
284 }
285 let facts = poll.facts();
286 let outcome = item
287 .and_then(|item| map_code(SuotarEndpoint::VerifyAttainments, &item.code))
288 .map(|code| verify_error_outcome(row.state, code, &facts))
289 .unwrap_or_else(|| verify_not_registered_outcome(row.state, &facts));
290 apply_outcome(
291 conn,
292 row,
293 &outcome,
294 OutcomeEvent {
295 error_message: item
296 .and_then(|item| item.error.as_ref())
297 .map(|error| error.message.as_str()),
298 ..event
299 },
300 Some(row.state),
301 )
302 .await?;
303 Ok(counts_as_failed(&outcome))
304}
305
306struct UncertainRecovery {
309 recoveries: Vec<Recovery>,
310}
311
312impl SuotarBatchPhase for UncertainRecovery {
313 type Row = Recovery;
314 type Item = ResolveEnrolmentRequestItem;
315 type Result = EnrolmentResolutionResult;
316
317 const ALL_TRANSIENT_ERROR: &'static str =
318 "Every recovery lookup came back transiently unavailable.";
319
320 async fn prepare(
323 &mut self,
324 _ctx: &PhaseContext<'_>,
325 conn: &mut PgConnection,
326 _scope: &PhaseScope,
327 ) -> anyhow::Result<Prepared<Self::Row, Self::Item>> {
328 let recoveries = std::mem::take(&mut self.recoveries);
329 let contexts = get_submission_contexts(
330 conn,
331 &recoveries
332 .iter()
333 .map(|recovery| recovery.row.id)
334 .collect::<Vec<_>>(),
335 )
336 .await?;
337 let mut prepared = Prepared::default();
338 for recovery in recoveries {
339 let Some(context) = contexts.get(&recovery.row.id) else {
340 continue;
341 };
342 let (Some(student_number), Some(course_code)) = (
343 recovery
344 .row
345 .student_number
346 .clone()
347 .or_else(|| context.student_number.clone()),
348 recovery
349 .row
350 .uh_course_code
351 .clone()
352 .or_else(|| context.uh_course_code.clone()),
353 ) else {
354 continue;
355 };
356 let item = ResolveEnrolmentRequestItem {
357 request_item_id: Self::request_item_id(&recovery),
358 student_number,
359 course_code,
360 };
361 prepared.sendable.push((recovery, item));
362 }
363 Ok(prepared)
364 }
365
366 fn registration(recovery: &Self::Row) -> &CreditRegistration {
367 &recovery.row
368 }
369
370 fn request_item_id(recovery: &Self::Row) -> String {
371 request_item_id(
372 &recovery.row,
373 RequestPurpose::UncertainRecovery(recovery.attempt),
374 )
375 }
376
377 async fn send(
378 &self,
379 ctx: &PhaseContext<'_>,
380 rows: &[Self::Row],
381 items: Vec<Self::Item>,
382 ) -> Result<SuotarBatchResponse<Self::Result>, UtilError> {
383 ctx.suotar_client
384 .resolve_enrolments(
385 SuotarCallContext::new(ctx.worker_name(CreditRegistrationPhase::Verify))
386 .for_registrations(rows.iter().map(|recovery| recovery.row.id).collect()),
387 items,
388 )
389 .await
390 }
391
392 async fn apply(
393 &self,
394 conn: &mut PgConnection,
395 recovery: &Self::Row,
396 item: Option<&SuotarResponseItem<Self::Result>>,
397 event: OutcomeEvent<'_>,
398 ) -> anyhow::Result<bool> {
399 apply_recovery_answer(conn, recovery, item, event).await
400 }
401
402 async fn apply_request_rejection(
405 &self,
406 conn: &mut PgConnection,
407 recovery: &Self::Row,
408 request: &serde_json::Value,
409 error: &UtilError,
410 ) -> anyhow::Result<bool> {
411 apply_outcome(
412 conn,
413 &recovery.row,
414 &uncertain_recheck_outcome(&recovery.facts()),
415 OutcomeEvent {
416 message: Some("Could not look for the attainment this time."),
417 error_message: Some(error.message()),
418 request: Some(request),
419 ..OutcomeEvent::default()
420 },
421 Some(recovery.row.state),
422 )
423 .await?;
424 Ok(false)
425 }
426}
427
428impl Recovery {
429 fn facts(&self) -> RowFacts {
431 RowFacts {
432 verify_attempt_count: self.attempt,
433 ..row_facts(&self.row)
434 }
435 }
436}
437
438async fn apply_recovery_answer(
439 conn: &mut PgConnection,
440 recovery: &Recovery,
441 item: Option<&SuotarResponseItem<EnrolmentResolutionResult>>,
442 event: OutcomeEvent<'_>,
443) -> anyhow::Result<bool> {
444 let row = &recovery.row;
445 let found = match item {
446 Some(item) if item.status == SuotarItemStatus::Ok => item
447 .result
448 .as_ref()
449 .zip(row.attainment_date)
450 .and_then(|(result, attainment_date)| {
451 attainment_matching_submission(
452 &result.existing_attainments,
453 attainment_date,
454 row.grade_scale_id.as_deref().unwrap_or_default(),
455 row.grade_id.as_deref().unwrap_or_default(),
456 )
457 }),
458 _ => None,
459 };
460 let Some(attainment) = found else {
461 apply_outcome(
462 conn,
463 row,
464 &uncertain_recheck_outcome(&recovery.facts()),
465 OutcomeEvent {
466 message: Some(
467 "No matching attainment yet, so whether the submission landed is still unknown.",
468 ),
469 ..event
470 },
471 Some(row.state),
472 )
473 .await?;
474 return Ok(false);
477 };
478 set_sisu_attainment_if_unclaimed(
479 conn,
480 row.id,
481 &attainment.id,
482 Some(&attainment.attainment_type),
483 )
484 .await?;
485 transition(
486 conn,
487 row.id,
488 &Transition {
489 event_kind: CreditRegistrationEventKind::SuotarResponse,
490 event_message: Some(
491 "The attainment this submission would have created is in the study registry, so \
492 it landed after all."
493 .to_string(),
494 ),
495 needs_admin_attention: Some(false),
496 suotar_api_call_id: event.suotar_api_call_id,
497 event_details: Some(
498 headless_lms_models::credit_registration_events::suotar_exchange_details(
499 event.request,
500 event.response,
501 ),
502 ),
503 expected_from_state: Some(row.state),
504 ..Transition::to(CreditRegistrationState::Duplicate)
505 },
506 )
507 .await?;
508 Ok(false)
509}
510
511#[cfg(test)]
512mod tests {
513 use headless_lms_models::credit_registrations::{
514 recovery_request_item_id, verify_request_item_id,
515 };
516
517 use super::*;
518
519 #[test]
521 fn the_poller_owns_exactly_the_two_states_a_submission_can_still_be_in() {
522 assert!(CLAIMED_STATES.contains(&CreditRegistrationState::AwaitingVerification));
523 assert!(CLAIMED_STATES.contains(&CreditRegistrationState::SubmissionUncertain));
524 assert!(!CLAIMED_STATES.contains(&CreditRegistrationState::Submitting));
525 assert!(!CLAIMED_STATES.contains(&CreditRegistrationState::Cancelled));
526 }
527
528 #[test]
531 fn two_calls_about_one_row_are_addressed_apart() {
532 let id = uuid::Uuid::new_v4();
533 assert_eq!(verify_request_item_id(id, 1), format!("vf-{id}-1"));
534 assert_ne!(verify_request_item_id(id, 1), verify_request_item_id(id, 2));
535 assert_ne!(
536 recovery_request_item_id(id, 1),
537 verify_request_item_id(id, 1)
538 );
539 assert_ne!(recovery_request_item_id(id, 1), format!("cr-{id}"));
540 }
541}