1use headless_lms_models::course_module_completion_registered_to_study_registries::completion_ids_registered_by_a_registrar;
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, CreditRegistrationErrorCode, CreditRegistrationState, RequestPurpose,
12 Transition, claim_due, request_item_id, set_sisu_attainment_if_unclaimed,
13 set_submitted_attainment, transition,
14};
15use headless_lms_models::library::credit_registration::classification::map_code;
16use headless_lms_models::library::credit_registration::grade_mapping::is_known_grade;
17use headless_lms_models::library::credit_registration::outcomes::{
18 import_success_outcome, import_success_state, submission_uncertain, submit_error_outcome,
19 unanswered_item_outcome,
20};
21use headless_lms_utils::error::util_error::UtilError;
22use headless_lms_utils::services::suotar::{
23 ImportAttainmentRequestItem, ImportAttainmentResult, SuotarAttainment, SuotarBatchResponse,
24 SuotarCallContext, SuotarEndpoint, SuotarItemStatus, SuotarResponseItem,
25};
26use sqlx::PgConnection;
27
28use super::{
29 CreditRegistrationPhase, OutcomeEvent, PhaseContext, PhaseScope, Prepared, SuotarBatchPhase,
30 apply_outcome, apply_request_level_outcome, row_facts, run_suotar_batch_phase,
31};
32
33const CLAIMED_STATES: [CreditRegistrationState; 1] = [CreditRegistrationState::CheckingEnrolment];
37
38pub async fn run(ctx: &PhaseContext<'_>, scope: &PhaseScope) -> anyhow::Result<PhaseRunOutcome> {
39 run_suotar_batch_phase(&mut Import, ctx, scope).await
40}
41
42struct Import;
43
44impl SuotarBatchPhase for Import {
45 type Row = CreditRegistration;
46 type Item = ImportAttainmentRequestItem;
47 type Result = ImportAttainmentResult;
48
49 const ALL_TRANSIENT_ERROR: &'static str =
50 "Every item of the batch came back transiently unavailable.";
51
52 async fn prepare(
53 &mut self,
54 _ctx: &PhaseContext<'_>,
55 conn: &mut PgConnection,
56 scope: &PhaseScope,
57 ) -> anyhow::Result<Prepared<Self::Row, Self::Item>> {
58 let claimed = claim_due(
59 conn,
60 &CLAIMED_STATES,
61 scope,
62 SuotarEndpoint::ImportAttainments.max_batch_size() as i64,
63 )
64 .await?;
65 let already_registered = completion_ids_registered_by_a_registrar(
68 conn,
69 &claimed
70 .iter()
71 .map(|row| row.course_module_completion_id)
72 .collect::<Vec<_>>(),
73 )
74 .await?;
75
76 let mut prepared = Prepared::default();
77 for row in claimed {
78 if already_registered.contains(&row.course_module_completion_id) {
79 transition(
80 conn,
81 row.id,
82 &Transition {
83 event_message: Some(
84 "Another registrar had already registered this completion, so nothing \
85 was submitted."
86 .to_string(),
87 ),
88 ..Transition::to(CreditRegistrationState::Duplicate)
89 },
90 )
91 .await?;
92 prepared.decided += 1;
93 continue;
94 }
95 match request_item(&row) {
96 Ok(item) => {
97 transition(
100 conn,
101 row.id,
102 &Transition::to(CreditRegistrationState::Submitting),
103 )
104 .await?;
105 prepared.sendable.push((row, item));
106 }
107 Err(problem) => {
108 transition(conn, row.id, &problem.transition()).await?;
109 prepared.decided += 1;
110 prepared.failed += 1;
111 }
112 }
113 }
114 Ok(prepared)
115 }
116
117 fn registration(row: &Self::Row) -> &CreditRegistration {
118 row
119 }
120
121 fn request_item_id(row: &Self::Row) -> String {
122 request_item_id(row, RequestPurpose::Submission)
123 }
124
125 fn sent_student_number(row: &Self::Row) -> Option<&str> {
126 row.student_number.as_deref()
127 }
128
129 async fn send(
130 &self,
131 ctx: &PhaseContext<'_>,
132 rows: &[Self::Row],
133 items: Vec<Self::Item>,
134 ) -> Result<SuotarBatchResponse<Self::Result>, UtilError> {
135 ctx.suotar_client
136 .import_attainments(
137 SuotarCallContext::new(ctx.worker_name(CreditRegistrationPhase::Import))
138 .for_registrations(rows.iter().map(|row| row.id).collect()),
139 items,
140 )
141 .await
142 }
143
144 async fn apply(
145 &self,
146 conn: &mut PgConnection,
147 row: &Self::Row,
148 item: Option<&SuotarResponseItem<Self::Result>>,
149 event: OutcomeEvent<'_>,
150 ) -> anyhow::Result<bool> {
151 apply_answer(conn, row, item, event).await
152 }
153
154 async fn apply_request_rejection(
155 &self,
156 conn: &mut PgConnection,
157 row: &Self::Row,
158 request: &serde_json::Value,
159 error: &UtilError,
160 ) -> anyhow::Result<bool> {
161 apply_request_level_outcome(
162 conn,
163 SuotarEndpoint::ImportAttainments,
164 row,
165 request,
166 error,
167 CreditRegistrationState::Submitting,
168 )
169 .await
170 }
171}
172
173async fn apply_answer(
179 conn: &mut PgConnection,
180 row: &CreditRegistration,
181 item: Option<&SuotarResponseItem<ImportAttainmentResult>>,
182 event: OutcomeEvent<'_>,
183) -> anyhow::Result<bool> {
184 let facts = row_facts(row);
185 match item {
186 None => {
188 apply_outcome(
189 conn,
190 row,
191 &unanswered_item_outcome(SuotarEndpoint::ImportAttainments, row.state, &facts),
192 OutcomeEvent {
193 message: Some(
194 "The study registry did not answer for this item, so whether the \
195 attainment was created is unknown.",
196 ),
197 ..event
198 },
199 Some(CreditRegistrationState::Submitting),
200 )
201 .await?;
202 Ok(true)
203 }
204 Some(item) if item.status == SuotarItemStatus::Error => {
205 let code = map_code(SuotarEndpoint::ImportAttainments, &item.code)
206 .unwrap_or(CreditRegistrationErrorCode::Unknown);
207 let outcome = submit_error_outcome(SuotarEndpoint::ImportAttainments, code, &facts);
208 if outcome.to_state == CreditRegistrationState::SubmissionUncertain
209 && let Some(disclosed) = item
210 .error
211 .as_ref()
212 .and_then(|error| error.submitted_attainment_id.as_deref())
213 {
214 set_submitted_attainment(conn, row.id, disclosed, None).await?;
217 }
218 apply_outcome(
219 conn,
220 row,
221 &outcome,
222 OutcomeEvent {
223 error_message: item.error.as_ref().map(|error| error.message.as_str()),
224 ..event
225 },
226 Some(CreditRegistrationState::Submitting),
227 )
228 .await?;
229 Ok(true)
230 }
231 Some(item) => {
232 let result = item.result.as_ref();
233 match import_success_state(&item.code) {
234 None => {
236 apply_outcome(
237 conn,
238 row,
239 &submission_uncertain(),
240 OutcomeEvent {
241 message: Some(
242 "The study registry answered with a success code we do not know, \
243 so whether the attainment was created is unknown.",
244 ),
245 ..event
246 },
247 Some(CreditRegistrationState::Submitting),
248 )
249 .await?;
250 Ok(true)
251 }
252 Some(CreditRegistrationState::AwaitingVerification) => {
253 let submitted = result.and_then(|result| {
254 result
255 .submitted_attainment_id
256 .as_deref()
257 .map(|id| (id, result.submitted_attainment_type.as_deref()))
258 });
259 match submitted {
260 Some((id, attainment_type)) => {
261 set_submitted_attainment(conn, row.id, id, attainment_type).await?;
262 apply_outcome(
263 conn,
264 row,
265 &import_success_outcome(
266 CreditRegistrationState::AwaitingVerification,
267 ),
268 event,
269 Some(CreditRegistrationState::Submitting),
270 )
271 .await?;
272 Ok(false)
273 }
274 None => {
277 apply_outcome(
278 conn,
279 row,
280 &submission_uncertain(),
281 OutcomeEvent {
282 message: Some(
283 "The submission was accepted without an id to verify it \
284 by.",
285 ),
286 ..event
287 },
288 Some(CreditRegistrationState::Submitting),
289 )
290 .await?;
291 Ok(true)
292 }
293 }
294 }
295 Some(state) => {
296 let attainment = result.and_then(|result| {
297 result
298 .attainment
299 .as_ref()
300 .or(result.previous_attainment.as_ref())
301 });
302 record_attainment(conn, row, attainment).await?;
303 let message = settled_message(state, attainment);
304 apply_outcome(
305 conn,
306 row,
307 &import_success_outcome(state),
308 OutcomeEvent {
309 message: message.as_deref(),
310 ..event
311 },
312 Some(CreditRegistrationState::Submitting),
313 )
314 .await?;
315 Ok(false)
316 }
317 }
318 }
319 }
320}
321
322fn settled_message(
325 state: CreditRegistrationState,
326 attainment: Option<&SuotarAttainment>,
327) -> Option<String> {
328 match state {
329 CreditRegistrationState::Duplicate => {
330 Some("The study registry already held a matching attainment.".to_string())
331 }
332 CreditRegistrationState::NotImproved => Some(match held_grade(attainment) {
333 Some(grade) => format!(
334 "The study registry already holds an equal or better attainment, graded {grade}."
335 ),
336 None => "The study registry already holds an equal or better attainment.".to_string(),
337 }),
338 _ => None,
339 }
340}
341
342fn held_grade(attainment: Option<&SuotarAttainment>) -> Option<String> {
345 let attainment = attainment?;
346 let grade_id = attainment.grade_id.as_deref()?;
347 Some(match attainment.grade_scale_id.as_deref() {
348 Some(scale) => format!("{grade_id} on {scale}"),
349 None => grade_id.to_string(),
350 })
351}
352
353async fn record_attainment(
354 conn: &mut PgConnection,
355 row: &CreditRegistration,
356 attainment: Option<&SuotarAttainment>,
357) -> anyhow::Result<()> {
358 if let Some(attainment) = attainment {
359 set_sisu_attainment_if_unclaimed(
360 conn,
361 row.id,
362 &attainment.id,
363 Some(&attainment.attainment_type),
364 )
365 .await?;
366 }
367 Ok(())
368}
369
370enum Unsendable {
373 Incomplete,
374 UnknownGrade,
375}
376
377impl Unsendable {
378 fn transition(&self) -> Transition {
379 match self {
380 Self::Incomplete => Transition {
381 event_kind: CreditRegistrationEventKind::StateChanged,
382 event_message: Some(
383 "The frozen payload is incomplete, so the enrolment is resolved again."
384 .to_string(),
385 ),
386 ..Transition::to(CreditRegistrationState::ReadyToSubmit)
387 },
388 Self::UnknownGrade => Transition {
389 error_code: Some(CreditRegistrationErrorCode::NoGradeScaleMapping),
390 needs_admin_attention: Some(true),
391 event_message: Some(
392 "The frozen grade is not one the study registry accepts.".to_string(),
393 ),
394 ..Transition::to(CreditRegistrationState::FailedPermanent)
395 },
396 }
397 }
398}
399
400fn request_item(row: &CreditRegistration) -> Result<ImportAttainmentRequestItem, Unsendable> {
402 let (
403 Some(student_number),
404 Some(course_code),
405 Some(enrolment_id),
406 Some(attainment_date),
407 Some(attainment_language),
408 Some(grade_scale_id),
409 Some(grade_id),
410 Some(credits),
411 ) = (
412 row.student_number.as_deref(),
413 row.uh_course_code.as_deref(),
414 row.selected_enrolment_id.as_deref(),
415 row.attainment_date,
416 row.attainment_language.as_deref(),
417 row.grade_scale_id.as_deref(),
418 row.grade_id.as_deref(),
419 row.credits,
420 )
421 else {
422 return Err(Unsendable::Incomplete);
423 };
424 if !is_known_grade(grade_scale_id, grade_id) {
427 return Err(Unsendable::UnknownGrade);
428 }
429 Ok(ImportAttainmentRequestItem {
430 request_item_id: request_item_id(row, RequestPurpose::Submission),
431 student_number: student_number.to_string(),
432 course_code: course_code.to_string(),
433 enrolment_id: enrolment_id.to_string(),
434 attainment_date,
435 attainment_language: attainment_language.to_string(),
436 grade_scale_id: grade_scale_id.to_string(),
437 grade_id: grade_id.to_string(),
438 credits: round_credits(credits),
439 })
440}
441
442fn round_credits(credits: f32) -> f64 {
445 (f64::from(credits) * 1000.0).round() / 1000.0
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn the_claim_states_cannot_reach_a_row_that_may_already_have_been_sent() {
454 for state in [
455 CreditRegistrationState::Submitting,
456 CreditRegistrationState::SubmissionUncertain,
457 CreditRegistrationState::AwaitingVerification,
458 CreditRegistrationState::Registered,
459 CreditRegistrationState::Cancelled,
460 CreditRegistrationState::ResolvingEnrolment,
461 ] {
462 assert!(!CLAIMED_STATES.contains(&state), "{state:?}");
463 }
464 }
465}