Skip to main content

headless_lms_server/domain/credit_registration_phases/
resolve_enrolments.rs

1//! The `resolve-enrolments` phase: which enrolment the attainment belongs to, and what we will send.
2//!
3//! Ends with the payload frozen and the row queued for import in `checking_enrolment`, never
4//! `submitting`: that state means a request may be in flight, and is the import phase's to write.
5//!
6//! The row spends the Suotar round trip itself in `resolving_enrolment`, not `checking_enrolment`:
7//! `import`'s claim query reads the latter, and the row's own claim lock is gone as soon as the
8//! preflight transaction below commits. Landing in a state `import` does not claim keeps a second
9//! tick of `import` from sending a request before the enrolment this one resolves is known.
10
11use headless_lms_models::credit_registration_events::{
12    CreditRegistrationEventKind, suotar_exchange_details,
13};
14use headless_lms_models::credit_registration_phase_state::PhaseRunOutcome;
15use headless_lms_models::credit_registrations::{
16    CreditRegistration, CreditRegistrationErrorCode, CreditRegistrationState, RequestPurpose,
17    Transition, claim_due, increment_submit_retry_count, request_item_id, set_payload_snapshot,
18    transition,
19};
20use headless_lms_models::library::credit_registration::classification::map_code;
21use headless_lms_models::library::credit_registration::enrolment_selection::{
22    EnrolmentCriteria, any_attained_by_person, attainment_for_course_unit, select_enrolment,
23};
24use headless_lms_models::library::credit_registration::grade_mapping::{
25    GradeComparison, GradeSource, compare_grades, map_grade,
26};
27use headless_lms_models::library::credit_registration::outcomes::{
28    missing_context_outcome, submit_error_outcome, unanswered_item_outcome,
29};
30use headless_lms_models::library::credit_registration::payload::{
31    PayloadSources, build_payload_snapshot,
32};
33use headless_lms_models::library::credit_registration::submission_context::{
34    SubmissionContext, get_submission_contexts,
35};
36use headless_lms_utils::error::util_error::UtilError;
37use headless_lms_utils::services::suotar::{
38    EnrolmentResolutionResult, ResolveEnrolmentRequestItem, SuotarBatchResponse, SuotarCallContext,
39    SuotarEndpoint, SuotarItemStatus, SuotarResponseItem,
40};
41use sqlx::PgConnection;
42
43use super::{
44    OutcomeEvent, PhaseContext, PhaseScope, Prepared, SuotarBatchPhase, apply_outcome,
45    apply_request_level_outcome, counts_as_failed, outcome_transition, row_facts,
46    run_suotar_batch_phase,
47};
48
49pub async fn run(ctx: &PhaseContext<'_>, scope: &PhaseScope) -> anyhow::Result<PhaseRunOutcome> {
50    run_suotar_batch_phase(&mut ResolveEnrolments, ctx, scope).await
51}
52
53struct ResolveEnrolments;
54
55impl SuotarBatchPhase for ResolveEnrolments {
56    /// The frozen context travels with the row: the answer is applied against what was asked, not
57    /// against a second read of the database.
58    type Row = (CreditRegistration, SubmissionContext);
59    type Item = ResolveEnrolmentRequestItem;
60    type Result = EnrolmentResolutionResult;
61
62    const ALL_TRANSIENT_ERROR: &'static str =
63        "Every item of the batch came back transiently unavailable.";
64
65    async fn prepare(
66        &mut self,
67        _ctx: &PhaseContext<'_>,
68        conn: &mut PgConnection,
69        scope: &PhaseScope,
70    ) -> anyhow::Result<Prepared<Self::Row, Self::Item>> {
71        let claimed = claim_due(
72            conn,
73            &[CreditRegistrationState::ReadyToSubmit],
74            scope,
75            SuotarEndpoint::ResolveEnrolments.max_batch_size() as i64,
76        )
77        .await?;
78        let ids: Vec<_> = claimed.iter().map(|row| row.id).collect();
79        let mut contexts = get_submission_contexts(conn, &ids).await?;
80
81        let mut prepared = Prepared::default();
82        for row in claimed {
83            let Some(context) = contexts.remove(&row.id) else {
84                warn!(
85                    "Credit registration {} has no completion or module to submit for.",
86                    row.id
87                );
88                let outcome = missing_context_outcome(&row_facts(&row));
89                if outcome.increment_submit_retry_count {
90                    increment_submit_retry_count(conn, row.id).await?;
91                }
92                transition(
93                    conn,
94                    row.id,
95                    &Transition {
96                        event_message: Some(
97                            "There is no completion or module to submit for.".to_string(),
98                        ),
99                        ..outcome_transition(&outcome, Some(row.state))
100                    },
101                )
102                .await?;
103                prepared.decided += 1;
104                prepared.failed += 1;
105                continue;
106            };
107            match preflight(&context) {
108                Ok(item) => {
109                    // Moved out of the state this phase reads, so a second tick cannot pick it up
110                    // while the request is out; `resolving_enrolment` rather than
111                    // `checking_enrolment` so `import` cannot claim it either before the payload
112                    // below is actually frozen.
113                    transition(
114                        conn,
115                        row.id,
116                        &Transition::to(CreditRegistrationState::ResolvingEnrolment),
117                    )
118                    .await?;
119                    let request = ResolveEnrolmentRequestItem {
120                        request_item_id: request_item_id(&row, RequestPurpose::Submission),
121                        student_number: item.student_number,
122                        course_code: item.course_code,
123                    };
124                    prepared.sendable.push(((row, context), request));
125                }
126                Err(problem) => {
127                    transition(conn, row.id, &problem.transition()).await?;
128                    prepared.decided += 1;
129                    prepared.failed += 1;
130                }
131            }
132        }
133        Ok(prepared)
134    }
135
136    fn registration((row, _): &Self::Row) -> &CreditRegistration {
137        row
138    }
139
140    fn request_item_id((row, _): &Self::Row) -> String {
141        request_item_id(row, RequestPurpose::Submission)
142    }
143
144    fn sent_student_number((_, context): &Self::Row) -> Option<&str> {
145        context.student_number.as_deref()
146    }
147
148    async fn send(
149        &self,
150        ctx: &PhaseContext<'_>,
151        rows: &[Self::Row],
152        items: Vec<Self::Item>,
153    ) -> Result<SuotarBatchResponse<Self::Result>, UtilError> {
154        ctx.suotar_client
155            .resolve_enrolments(
156                SuotarCallContext::new(
157                    ctx.worker_name(super::CreditRegistrationPhase::ResolveEnrolments),
158                )
159                .for_registrations(rows.iter().map(|(row, _)| row.id).collect()),
160                items,
161            )
162            .await
163    }
164
165    async fn apply(
166        &self,
167        conn: &mut PgConnection,
168        (row, context): &Self::Row,
169        item: Option<&SuotarResponseItem<Self::Result>>,
170        event: OutcomeEvent<'_>,
171    ) -> anyhow::Result<bool> {
172        apply_answer(conn, row, context, item, event).await
173    }
174
175    async fn apply_request_rejection(
176        &self,
177        conn: &mut PgConnection,
178        (row, _): &Self::Row,
179        request: &serde_json::Value,
180        error: &UtilError,
181    ) -> anyhow::Result<bool> {
182        apply_request_level_outcome(
183            conn,
184            SuotarEndpoint::ResolveEnrolments,
185            row,
186            request,
187            error,
188            CreditRegistrationState::ResolvingEnrolment,
189        )
190        .await
191    }
192}
193
194/// Applies the study registry's answer for one row. Returns whether the row ended up in a failure
195/// state; errors with `PreconditionFailed` if the row left `resolving_enrolment` meanwhile.
196async fn apply_answer(
197    conn: &mut PgConnection,
198    row: &CreditRegistration,
199    context: &SubmissionContext,
200    item: Option<&SuotarResponseItem<EnrolmentResolutionResult>>,
201    event: OutcomeEvent<'_>,
202) -> anyhow::Result<bool> {
203    let facts = row_facts(row);
204    match item {
205        None => {
206            let outcome =
207                unanswered_item_outcome(SuotarEndpoint::ResolveEnrolments, row.state, &facts);
208            apply_outcome(
209                conn,
210                row,
211                &outcome,
212                OutcomeEvent {
213                    message: Some("The study registry did not answer for this item."),
214                    ..event
215                },
216                Some(CreditRegistrationState::ResolvingEnrolment),
217            )
218            .await?;
219            Ok(counts_as_failed(&outcome))
220        }
221        Some(item) if item.status == SuotarItemStatus::Error => {
222            let code = map_code(SuotarEndpoint::ResolveEnrolments, &item.code)
223                .unwrap_or(CreditRegistrationErrorCode::Unknown);
224            let outcome = submit_error_outcome(SuotarEndpoint::ResolveEnrolments, code, &facts);
225            apply_outcome(
226                conn,
227                row,
228                &outcome,
229                OutcomeEvent {
230                    error_message: item.error.as_ref().map(|error| error.message.as_str()),
231                    ..event
232                },
233                Some(CreditRegistrationState::ResolvingEnrolment),
234            )
235            .await?;
236            Ok(counts_as_failed(&outcome))
237        }
238        Some(item) => {
239            let no_enrolments = Vec::new();
240            let no_attainments = Vec::new();
241            let (enrolments, existing) = item
242                .result
243                .as_ref()
244                .map(|result| (&result.enrolments, &result.existing_attainments))
245                .unwrap_or((&no_enrolments, &no_attainments));
246            choose(conn, row, context, enrolments, existing, event).await
247        }
248    }
249}
250
251/// Applies the choice for one answered row. Returns whether the row ended up in a failure state.
252async fn choose(
253    conn: &mut PgConnection,
254    row: &CreditRegistration,
255    context: &SubmissionContext,
256    enrolments: &[headless_lms_utils::services::suotar::SuotarEnrolment],
257    existing: &[headless_lms_utils::services::suotar::ExistingAttainment],
258    event: OutcomeEvent<'_>,
259) -> anyhow::Result<bool> {
260    let details = suotar_exchange_details(event.request, event.response);
261    // Before the enrolment is chosen: if the registry already holds the attainment the credit
262    // exists, so sending the student off to re-enrol would be wrong as well as unnecessary.
263    let already_attained = if enrolments.is_empty() {
264        // No enrolment to name the course unit by, but the response was scoped to this student and
265        // course code, so any attained entry of theirs is still a genuine duplicate.
266        any_attained_by_person(
267            existing,
268            context.sisu_person_id.as_deref().unwrap_or_default(),
269        )
270        .map(|attained| (attained, None))
271    } else {
272        enrolments.iter().find_map(|enrolment| {
273            attainment_for_course_unit(
274                existing,
275                &enrolment.course_unit_id,
276                &enrolment.assessment_item_id,
277            )
278            .map(|attained| (attained, Some(enrolment)))
279        })
280    };
281    // A grade improvement is the one case where an attainment we already hold is not a reason to
282    // stop: only the registry can say whether the better grade replaces it.
283    let already_attained = already_attained.filter(|(attained, enrolment)| {
284        !improves_on(
285            attained,
286            context,
287            enrolment.map(|enrolment| enrolment.grade_scale_id.as_str()),
288        )
289    });
290    if let Some((attained, _)) = already_attained {
291        headless_lms_models::credit_registrations::set_sisu_attainment_if_unclaimed(
292            conn,
293            row.id,
294            &attained.id,
295            Some(&attained.attainment_type),
296        )
297        .await?;
298        transition(
299            conn,
300            row.id,
301            &Transition {
302                event_kind: CreditRegistrationEventKind::SuotarResponse,
303                event_message: Some(
304                    "The study registry already holds an attainment for this course unit, so \
305                     nothing was submitted."
306                        .to_string(),
307                ),
308                suotar_api_call_id: event.suotar_api_call_id,
309                event_details: Some(details),
310                // The row spent the Suotar round trip unlocked, so an admin action may have already
311                // moved it out of `resolving_enrolment`.
312                expected_from_state: Some(CreditRegistrationState::ResolvingEnrolment),
313                ..Transition::to(CreditRegistrationState::Duplicate)
314            },
315        )
316        .await?;
317        return Ok(false);
318    }
319
320    let credits = context.ects_credits.unwrap_or_default();
321    let attainment_date = headless_lms_models::library::credit_registration::payload::helsinki_date(
322        context.completion.completion_date,
323    );
324    let chosen = select_enrolment(
325        enrolments,
326        EnrolmentCriteria {
327            attainment_date,
328            credits,
329            configured_realisation_ids: &context.configured_realisation_ids,
330        },
331    );
332    let chosen = match chosen {
333        Ok(chosen) => chosen,
334        Err(reason) => {
335            let outcome = headless_lms_models::library::credit_registration::outcomes::Outcome {
336                error_code: Some(reason.error_code()),
337                ..submit_error_outcome(
338                    SuotarEndpoint::ResolveEnrolments,
339                    reason.error_code(),
340                    &row_facts(row),
341                )
342            };
343            apply_outcome(
344                conn,
345                row,
346                &outcome,
347                OutcomeEvent {
348                    message: Some(reason.message()),
349                    ..event
350                },
351                Some(CreditRegistrationState::ResolvingEnrolment),
352            )
353            .await?;
354            return Ok(true);
355        }
356    };
357
358    let built = build_payload_snapshot(
359        &context.completion,
360        PayloadSources {
361            student_number: context.student_number.as_deref().unwrap_or_default(),
362            sisu_person_id: context.sisu_person_id.as_deref().unwrap_or_default(),
363            uh_course_code: context.uh_course_code.as_deref(),
364            ects_credits: context.ects_credits,
365            configured_grade_scale_id: context.configured_grade_scale_id.as_deref(),
366            enrolment: Some(chosen),
367        },
368    );
369    let built = match built {
370        Ok(built) => built,
371        Err(code) => {
372            apply_outcome(
373                conn,
374                row,
375                &submit_error_outcome(SuotarEndpoint::ResolveEnrolments, code, &row_facts(row)),
376                event,
377                Some(CreditRegistrationState::ResolvingEnrolment),
378            )
379            .await?;
380            return Ok(true);
381        }
382    };
383    set_payload_snapshot(conn, row.id, &built.snapshot).await?;
384    let clamped = built.clamped_credits_from.map(|from| {
385        format!(
386            "Credits adjusted from {from} to {} to fit the enrolment's range.",
387            built.snapshot.credits
388        )
389    });
390    // Only now does the row become claimable by `import`: the payload is frozen and the event
391    // records when the enrolment was resolved.
392    transition(
393        conn,
394        row.id,
395        &Transition {
396            event_kind: CreditRegistrationEventKind::SuotarResponse,
397            event_message: clamped,
398            suotar_api_call_id: event.suotar_api_call_id,
399            event_details: Some(details),
400            expected_from_state: Some(CreditRegistrationState::ResolvingEnrolment),
401            ..Transition::to(CreditRegistrationState::CheckingEnrolment)
402        },
403    )
404    .await?;
405    Ok(false)
406}
407
408/// Whether the grade we would send beats the one the registry already holds for this course unit.
409///
410/// Anything else — equal, worse, or a grade on a scale that does not rank against the held one —
411/// is false, so the duplicate guard stands and no second attainment can reach a transcript on a
412/// guess.
413fn improves_on(
414    attained: &headless_lms_utils::services::suotar::ExistingAttainment,
415    context: &SubmissionContext,
416    enrolment_grade_scale_id: Option<&str>,
417) -> bool {
418    map_grade(GradeSource {
419        passed: context.completion.passed,
420        grade: context.completion.grade,
421        configured_grade_scale_id: context.configured_grade_scale_id.as_deref(),
422        enrolment_grade_scale_id,
423    })
424    .is_ok_and(|mapped| {
425        compare_grades(&attained.grade_scale_id, &attained.grade_id, &mapped)
426            == GradeComparison::Better
427    })
428}
429
430struct ResolveRequest {
431    student_number: String,
432    course_code: String,
433}
434
435/// A row that cannot even be asked about: each of these is the student's or a teacher's to fix, and
436/// none of them is worth a call.
437enum Preflight {
438    NoStudentNumber,
439    Config(CreditRegistrationErrorCode),
440}
441
442impl Preflight {
443    fn transition(&self) -> Transition {
444        match self {
445            Self::NoStudentNumber => Transition {
446                event_message: Some(
447                    "No verified student number is linked to the account.".to_string(),
448                ),
449                ..Transition::to(CreditRegistrationState::Pending)
450            },
451            Self::Config(code) => Transition {
452                error_code: Some(*code),
453                needs_admin_attention: Some(true),
454                event_message: Some(
455                    "The module is not configured for credit registration.".to_string(),
456                ),
457                ..Transition::to(CreditRegistrationState::FailedPermanent)
458            },
459        }
460    }
461}
462
463fn preflight(context: &SubmissionContext) -> Result<ResolveRequest, Preflight> {
464    let student_number = context
465        .student_number
466        .clone()
467        .ok_or(Preflight::NoStudentNumber)?;
468    let course_code = context
469        .uh_course_code
470        .clone()
471        .filter(|code| !code.trim().is_empty())
472        .ok_or(Preflight::Config(
473            CreditRegistrationErrorCode::MissingUhCourseCode,
474        ))?;
475    if context.ects_credits.is_none() {
476        return Err(Preflight::Config(
477            CreditRegistrationErrorCode::MissingEctsCredits,
478        ));
479    }
480    Ok(ResolveRequest {
481        student_number,
482        course_code,
483    })
484}