Skip to main content

headless_lms_server/domain/credit_registration_phases/
import.rs

1//! The `import` phase: the one call that creates something in the study registry.
2//!
3//! A row is committed as `submitting` before the request leaves, and no path leads from that state
4//! or `submission_uncertain` back into a batch: a second import for one row would put a second
5//! attainment on a real transcript, and we could neither see it nor undo it.
6
7use 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
33/// The only state this phase claims; the absence of `submitting`, `submission_uncertain` and
34/// `resolving_enrolment` is what makes a second import for one row, or an import before its
35/// enrolment is resolved, unreachable.
36const 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        // Registrars only, not our own mirror rows: a grade improvement is deliberately a second
66        // submission for the same completion.
67        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                    // Committed before the request leaves: a row found in `submitting` after a
98                    // restart has an unknown outcome and is never sent again.
99                    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
173/// Applies the study registry's answer for one submitted row. Returns whether the row ended up in a
174/// failure state; errors with `PreconditionFailed` if the row left `submitting` meanwhile.
175///
176/// Anything the answer disclosed about the attainment is written before the transition, so a row
177/// that did move on still keeps the id support needs to find what was created.
178async 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        // Sent and unanswered: verified from here, never re-sent.
187        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                // A disclosed id turns the recovery into plain verification instead of a hunt
215                // through the student's existing attainments.
216                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                // A success code we do not know cannot be read as "nothing was created".
235                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                        // Accepted with nothing to verify by; recovery is a lookup among the
275                        // student's existing attainments, never a second import.
276                        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
322/// The timeline line for an answer that settled the row. `not_improved` names the grade the registry
323/// held, because "already equal or better" without it reads as a bug to whoever raised the grade.
324fn 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
342/// The registry's own grade for an attainment, with its scale named: "1" is a pass on one scale and
343/// a one out of five on the other.
344fn 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
370/// A frozen snapshot that cannot be sent; either would come back as a request-level rejection that
371/// takes the rest of the batch with it.
372enum 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
400/// Builds the request item from the frozen snapshot, or says why the row cannot go into a batch.
401fn 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    // The registry rejects an unknown scale or grade for the whole request, so this row leaves the
425    // batch rather than failing the rows around it.
426    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
442/// Rounds away the f32-to-f64 widening error before the value goes on the wire: ECTS credits are
443/// never finer than a hundredth, and 2.7f32 would otherwise be sent as 2.700000047683716.
444fn 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}