Skip to main content

headless_lms_credit_registration/use_cases/resolve_enrolments/enrolments/
mod.rs

1//! The second half of a `resolve-enrolments` iteration: the enrolment lookup, and freezing the
2//! payload the import will send.
3//!
4//! [`request`] decides whether a row can be asked about, [`answer`] what the registry's answer
5//! means, and [`resolution`] what that comes to for the row; the orchestration, with its
6//! transaction, is here.
7
8mod answer;
9mod request;
10mod resolution;
11
12use headless_lms_models::credit_registrations::claim_due_for_resolve;
13use headless_lms_models::library::credit_registration::enrolment_checks::EnrolmentCheckAnswer;
14use headless_lms_models::library::credit_registration::enrolment_selection::{
15    EnrolmentCriteria, select_enrolment,
16};
17use headless_lms_models::library::credit_registration::outcomes::{
18    missing_context, unanswered_item_outcome,
19};
20use headless_lms_models::library::credit_registration::submission_context::{
21    SubmissionContext, get_submission_contexts,
22};
23use headless_lms_utils::prelude::Utc;
24use sqlx::{Connection, PgConnection};
25
26use super::{claim_for_lookup, hold_in_flight, keep_lookups_in_flight};
27use crate::error::CreditRegistrationResult;
28use crate::registry::{BatchRequest, EnrolmentAnswer, EnrolmentLookup, ExchangeAudit};
29use crate::use_cases::batch_flow::{BatchFlowContext, Prepared, RegistryBatchFlow};
30use crate::workflow::{
31    Applied, Claimed, ClaimedRegistration, Decision, RefusalPolicy, write_decision,
32    write_decision_committing_if_written, write_unasked_move,
33};
34
35use answer::{AnsweredLookup, EnrolmentLookupResult};
36use request::{Askable, Unaskable, enrolment_lookup};
37use resolution::{CompetingCredits, resolve, unsent_duplicate};
38
39pub(super) struct ResolveEnrolments;
40
41/// What a row's lookup was built from: the submission context, and the criteria the lookup's
42/// enrolment must fit. The answer is applied against what was asked, not against a second read of
43/// the database.
44pub(super) struct LookupBasis {
45    submission: SubmissionContext,
46    criteria: EnrolmentCriteria,
47}
48
49/// A row claimed for its enrolment lookup.
50pub(super) type Resolvable = Claimed<LookupBasis>;
51
52impl RegistryBatchFlow for ResolveEnrolments {
53    type Extra = LookupBasis;
54    type Request = EnrolmentLookup;
55
56    const ALL_UNAVAILABLE_ERROR: &'static str = "Every item of the batch came back unavailable.";
57    const REFUSAL: RefusalPolicy<LookupBasis> = RefusalPolicy::RequestLevel;
58
59    async fn claim(
60        ctx: &BatchFlowContext<'_>,
61        conn: &mut PgConnection,
62        limit: usize,
63    ) -> CreditRegistrationResult<Prepared<LookupBasis, EnrolmentLookup>> {
64        let claimed =
65            claim_due_for_resolve(conn, ctx.scope, i64::try_from(limit).unwrap_or(i64::MAX))
66                .await?;
67        let ids: Vec<_> = claimed.iter().map(|row| row.id).collect();
68        let mut contexts = get_submission_contexts(conn, &ids).await?;
69
70        let mut prepared = Prepared::new();
71        for row in claimed {
72            let Some(context) = contexts.remove(&row.id) else {
73                warn!(
74                    credit_registration_id = %row.id,
75                    "Credit registration has no completion or module to submit for"
76                );
77                let claim = ClaimedRegistration::left_in_place(row);
78                let applied =
79                    write_unasked_move(conn, &claim, missing_context(&claim.facts(Utc::now())))
80                        .await?;
81                prepared.record_applied(claim.id(), applied);
82                continue;
83            };
84            // Left for the next iteration's person lookup rather than frozen without the person.
85            if context.student_number.is_some() && context.sisu_person_id.is_none() {
86                debug!(
87                    credit_registration_id = %row.id,
88                    "Leaving row claimable: waiting for a Sisu person id"
89                );
90                continue;
91            }
92            match enrolment_lookup(&context) {
93                Ok(Askable { request, criteria }) => {
94                    let row = Claimed {
95                        claim: claim_for_lookup(row),
96                        extra: LookupBasis {
97                            submission: context,
98                            criteria,
99                        },
100                    };
101                    prepared.send(row, request);
102                }
103                Err(problem) => {
104                    match &problem {
105                        Unaskable::Config(code) => {
106                            warn!(
107                                credit_registration_id = %row.id,
108                                error_code = ?code,
109                                "Course module is not configured for credit registration"
110                            );
111                        }
112                        Unaskable::NoStudentNumber => {
113                            debug!(
114                                credit_registration_id = %row.id,
115                                "No verified student number; sending back to pending"
116                            );
117                        }
118                    }
119                    // The claim holds the row's lock, so the guard only confirms the state it read.
120                    let claim = ClaimedRegistration::left_in_place(row);
121                    let applied = write_unasked_move(conn, &claim, problem.unasked_move()).await?;
122                    prepared.record_applied(claim.id(), applied);
123                }
124            }
125        }
126        hold_in_flight(
127            conn,
128            prepared.sendable().iter().map(|entry| &entry.row.claim),
129        )
130        .await?;
131        Ok(prepared)
132    }
133
134    async fn apply_answer(
135        conn: &mut PgConnection,
136        resolvable: &Resolvable,
137        answer: Option<&EnrolmentAnswer>,
138        audit: &ExchangeAudit,
139    ) -> CreditRegistrationResult<Applied> {
140        let claim = &resolvable.claim;
141        let row = claim.registration();
142        let enrolments = answer
143            .map(|answer| answer.enrolments.as_slice())
144            .unwrap_or_default();
145        let chosen = select_enrolment(enrolments, resolvable.extra.criteria);
146        let check = row
147            .enrolment_check_anchor_at
148            .is_some()
149            .then(|| EnrolmentCheckAnswer {
150                checked: row,
151                usable_enrolment: chosen.ok(),
152                listed_enrolments: enrolments,
153            });
154        let Some(answer) = answer else {
155            let outcome = unanswered_item_outcome(
156                EnrolmentLookup::OPERATION,
157                row.state,
158                &claim.facts(Utc::now()),
159            );
160            let decision = Decision::new(outcome)
161                .with_message("Sisu did not answer for this item.")
162                .with_enrolment_check(check);
163            return write_decision(conn, claim, decision, audit).await;
164        };
165        let lookup = AnsweredLookup::new(
166            resolvable,
167            EnrolmentLookupResult::read(answer, chosen),
168            &answer.existing_attainments,
169        );
170        apply_enrolment_answer(conn, &lookup, check, audit).await
171    }
172
173    async fn keep_in_flight(
174        conn: &mut PgConnection,
175        rows: &[&Resolvable],
176    ) -> CreditRegistrationResult<()> {
177        keep_lookups_in_flight(conn, rows.iter().map(|row| &row.claim)).await
178    }
179}
180
181/// Writes an answered lookup: settles the row as `duplicate` when a credit it would not beat is
182/// already held, fails it when there is nothing to register against, and otherwise freezes the
183/// payload and queues the row for import.
184///
185/// What Sisu itself holds is weighed first: if the attainment exists the credit does, so sending
186/// the student off to re-enrol would be wrong as well as unnecessary.
187async fn apply_enrolment_answer(
188    conn: &mut PgConnection,
189    lookup: &AnsweredLookup<'_>,
190    check: Option<EnrolmentCheckAnswer<'_>>,
191    audit: &ExchangeAudit,
192) -> CreditRegistrationResult<Applied> {
193    // Before the transaction below: a Sisu attainment must not be written inside one.
194    if let Some(attained) = lookup.held_in_sisu() {
195        let decision = unsent_duplicate(
196            lookup,
197            "Sisu already has an equal or better grade for this course, so nothing was submitted.",
198        )
199        .with_sisu_attainment(Some(attained))
200        .with_enrolment_check(check);
201        return write_decision(conn, lookup.claim(), decision, audit).await;
202    }
203
204    // Our other attempts' successes are locked in the transaction that freezes the payload, so two
205    // attempts cannot both decide theirs is the grade to send.
206    let mut tx = conn.begin().await?;
207    let competing = CompetingCredits::load(&mut tx, lookup).await?;
208    let decision = resolve(lookup, &competing).with_enrolment_check(check);
209    write_decision_committing_if_written(tx, lookup.claim(), decision, audit).await
210}
211
212#[cfg(test)]
213mod fixtures {
214    use headless_lms_models::credit_registrations::{
215        CreditRegistrationErrorCode, CreditRegistrationState,
216    };
217    use headless_lms_models::library::credit_registration::payload::CompletionFacts;
218    use headless_lms_models::library::credit_registration::study_registry::{
219        CreditRange, RegistryEnrolment,
220    };
221    use headless_lms_models::secret::DbSecret;
222    use uuid::Uuid;
223
224    use super::answer::EnrolmentLookupResult;
225    use super::request::enrolment_lookup;
226    use super::{LookupBasis, Resolvable, SubmissionContext};
227    use crate::test_fixtures::{now, registration};
228    use crate::workflow::{Claimed, ClaimedRegistration};
229
230    pub(super) fn context(grade: Option<i32>) -> SubmissionContext {
231        SubmissionContext {
232            registration_id: Uuid::new_v4(),
233            student_number: Some(DbSecret::new("012345678")),
234            sisu_person_id: Some(DbSecret::new("person-1")),
235            uh_course_code: Some(" TKT10002 ".to_string()),
236            ects_credits: Some(5.0),
237            completion: CompletionFacts {
238                passed: true,
239                grade,
240                completion_date: now(),
241                completion_language: "en".to_string(),
242            },
243        }
244    }
245
246    /// A row on its first resolve, as the claim holds it.
247    pub(super) fn resolvable(context: SubmissionContext) -> Resolvable {
248        let Ok(askable) = enrolment_lookup(&context) else {
249            panic!("fixture context must be askable");
250        };
251        Claimed {
252            claim: ClaimedRegistration::left_in_place(registration(
253                CreditRegistrationState::ResolvingEnrolment,
254            )),
255            extra: LookupBasis {
256                submission: context,
257                criteria: askable.criteria,
258            },
259        }
260    }
261
262    pub(super) fn enrolment(state: &str) -> RegistryEnrolment {
263        RegistryEnrolment {
264            id: "enrolment-1".to_string(),
265            state: Some(state.to_string()),
266            kind: None,
267            course_unit_realisation_id: None,
268            course_unit_realisation_name: None,
269            activity_period: None,
270            grade_scale_id: Some("sis-0-5".to_string()),
271            credits: Some(CreditRange {
272                min: Some(5.0),
273                max: Some(5.0),
274            }),
275            study_right_validity_period: None,
276            enrolment_date_time: None,
277        }
278    }
279
280    pub(super) fn refused(code: CreditRegistrationErrorCode) -> EnrolmentLookupResult<'static> {
281        EnrolmentLookupResult::Refused {
282            code,
283            error_message: Some("item error"),
284        }
285    }
286}