Skip to main content

headless_lms_credit_registration/use_cases/resolve_enrolments/enrolments/
resolution.rs

1//! Given everything known once the lookup answered, what happens to the registration: settled as a
2//! duplicate of a credit already held, failed, or its payload frozen for import.
3
4use headless_lms_models::credit_registrations::{
5    CreditRegistrationState, LiveSuccessForModule, RecordedCredit,
6    get_recorded_credits_for_same_module, lock_live_successes_for_same_module,
7};
8use headless_lms_models::library::credit_registration::grade_mapping::{
9    improves_on_all, map_grade,
10};
11use headless_lms_models::library::credit_registration::outcomes::{Outcome, submit_error_outcome};
12use headless_lms_models::library::credit_registration::payload::{
13    BuiltPayload, PayloadSources, build_payload_snapshot,
14};
15use headless_lms_models::secret::DbSecret;
16use headless_lms_utils::prelude::Utc;
17use sqlx::PgConnection;
18use uuid::Uuid;
19
20use super::answer::{AnsweredLookup, EnrolmentLookupResult};
21use crate::error::CreditRegistrationResult;
22use crate::registry::{BatchRequest, EnrolmentLookup};
23use crate::workflow::{Decision, PayloadChange};
24
25/// The credits outside Sisu this attempt's grade competes with for the module: what we registered
26/// from the student's other attempts, which Suotar's copy of Sisu may predate, and the credits our
27/// records hold.
28pub(super) struct CompetingCredits {
29    registered_by_us: Vec<LiveSuccessForModule>,
30    recorded_locally: Vec<RecordedCredit>,
31}
32
33impl CompetingCredits {
34    /// Locks our registered successes only for a listed answer, the one that can freeze a payload
35    /// superseding them, and reads the recorded credits only for an enrolment error, the one answer
36    /// they can overrule. Call inside the transaction that writes the resolution.
37    pub(super) async fn load(
38        tx: &mut PgConnection,
39        lookup: &AnsweredLookup<'_>,
40    ) -> CreditRegistrationResult<Self> {
41        let row_id = lookup.claim().id();
42        let registered_by_us = match lookup.result() {
43            EnrolmentLookupResult::Listed { .. } => {
44                lock_live_successes_for_same_module(tx, row_id).await?
45            }
46            EnrolmentLookupResult::Refused { .. } => Vec::new(),
47        };
48        let recorded_locally = if lookup.result().is_enrolment_error() {
49            get_recorded_credits_for_same_module(tx, row_id).await?
50        } else {
51            Vec::new()
52        };
53        Ok(Self {
54            registered_by_us,
55            recorded_locally,
56        })
57    }
58
59    /// The registrations a frozen payload replaces once it is registered.
60    fn supersedes(&self) -> Vec<Uuid> {
61        self.registered_by_us
62            .iter()
63            .map(|replaced| replaced.id)
64            .collect()
65    }
66}
67
68/// What the answer comes to once Sisu is known not to hold the credit: nothing sent when our own
69/// records hold a credit the grade would not beat, a failure when there is nothing to register
70/// against, and otherwise the payload frozen for import.
71pub(super) fn resolve<'a>(
72    lookup: &AnsweredLookup<'a>,
73    competing: &CompetingCredits,
74) -> Decision<'a> {
75    let ours = lookup.our_grade();
76    let registered_grades: Vec<_> = competing
77        .registered_by_us
78        .iter()
79        .map(|success| success.credit.held_grade())
80        .collect();
81    if !registered_grades.is_empty() && !improves_on_all(&registered_grades, ours) {
82        return unsent_duplicate(
83            lookup,
84            "A grade at least as good is already registered for this module from another \
85             attempt, so nothing was submitted.",
86        );
87    }
88    // Suotar's copy of Sisu may predate a pull-path registration, so a student who holds the
89    // credit would be told to enrol again. A better grade still gets the error, since an
90    // improvement needs an enrolment too.
91    let recorded_grades: Vec<_> = competing
92        .recorded_locally
93        .iter()
94        .map(RecordedCredit::held_grade)
95        .collect();
96    if lookup.result().is_enrolment_error()
97        && !recorded_grades.is_empty()
98        && !improves_on_all(&recorded_grades, ours)
99    {
100        return unsent_duplicate(
101            lookup,
102            "The study registry has no usable enrolment, but our records already hold a credit at \
103             least as good for this module, so nothing was submitted.",
104        );
105    }
106    let facts = lookup.claim().facts(Utc::now());
107    let chosen = match lookup.result() {
108        EnrolmentLookupResult::Refused {
109            code,
110            error_message,
111        } => {
112            return Decision::new(submit_error_outcome(
113                EnrolmentLookup::OPERATION,
114                code,
115                &facts,
116            ))
117            .with_row_error(error_message);
118        }
119        EnrolmentLookupResult::Listed {
120            chosen: Err(reason),
121            ..
122        } => {
123            return Decision::new(submit_error_outcome(
124                EnrolmentLookup::OPERATION,
125                reason.error_code(),
126                &facts,
127            ))
128            .with_message(reason.message());
129        }
130        EnrolmentLookupResult::Listed {
131            chosen: Ok(chosen), ..
132        } => chosen,
133    };
134    let submission = lookup.submission();
135    let absent = DbSecret::new("");
136    let built = build_payload_snapshot(
137        &submission.completion,
138        PayloadSources {
139            student_number: submission.student_number.as_ref().unwrap_or(&absent),
140            sisu_person_id: submission.sisu_person_id.as_ref(),
141            uh_course_code: submission.uh_course_code.as_deref(),
142            ects_credits: submission.ects_credits,
143            enrolment: Some(chosen),
144        },
145    );
146    match built {
147        Ok(built) => freeze(built, competing.supersedes()),
148        Err(code) => Decision::new(submit_error_outcome(
149            EnrolmentLookup::OPERATION,
150            code,
151            &facts,
152        )),
153    }
154}
155
156/// The row is not sent, because a credit it would not beat is already held, and moves to
157/// `duplicate`. The grade we would have sent stays on the row as the one a later regrade has to
158/// beat.
159pub(super) fn unsent_duplicate<'a>(
160    lookup: &AnsweredLookup<'a>,
161    message: &'static str,
162) -> Decision<'a> {
163    Decision::new(Outcome::to(CreditRegistrationState::Duplicate))
164        .with_message(message)
165        .with_payload(PayloadChange::Unsent {
166            weighed_grade: map_grade(lookup.our_grade()).ok(),
167        })
168}
169
170/// Only now does the row become claimable by `import`: the payload is frozen and the event records
171/// when the enrolment was resolved.
172fn freeze(built: BuiltPayload, supersedes: Vec<Uuid>) -> Decision<'static> {
173    let message = freeze_message(&built, &supersedes);
174    let decision = Decision::new(Outcome::to(CreditRegistrationState::CheckingEnrolment))
175        .with_payload(PayloadChange::Frozen {
176            snapshot: Box::new(built.snapshot),
177            supersedes,
178        });
179    match message {
180        Some(message) => decision.with_message(message),
181        None => decision,
182    }
183}
184
185/// The timeline line for a freeze: that it replaces another attempt's registered grade, and that
186/// its credits were adjusted to fit the enrolment.
187fn freeze_message(built: &BuiltPayload, supersedes: &[Uuid]) -> Option<String> {
188    let superseded_message = (!supersedes.is_empty()).then(|| {
189        format!(
190            "This attempt's grade {} beats the one registered from another attempt, which this \
191             one supersedes once it is registered.",
192            built.snapshot.grade_id
193        )
194    });
195    let clamped_message = built.clamped_credits_from.map(|from| {
196        format!(
197            "Credits adjusted from {from} to {} to fit the enrolment's range.",
198            built.snapshot.credits
199        )
200    });
201    [superseded_message, clamped_message]
202        .into_iter()
203        .flatten()
204        .reduce(|first, second| format!("{first} {second}"))
205}
206
207#[cfg(test)]
208mod tests {
209    use headless_lms_models::credit_registrations::CreditRegistrationErrorCode as Code;
210    use headless_lms_models::credit_registrations::CreditRegistrationState as State;
211    use headless_lms_models::credit_registrations::PayloadSnapshot;
212    use headless_lms_models::library::credit_registration::enrolment_selection::NoUsableEnrolment;
213    use headless_lms_models::library::credit_registration::payload::CompletionFacts;
214    use headless_lms_models::library::credit_registration::study_registry::{
215        RegistryAttainment, RegistryEnrolment,
216    };
217    use headless_lms_models::library::credit_registration::submission_context::SubmissionContext;
218
219    use super::super::Resolvable;
220    use super::super::fixtures::{context, enrolment, refused, resolvable};
221    use super::*;
222    use crate::test_fixtures::{attainment, date};
223
224    fn registered(grade_id: &str) -> LiveSuccessForModule {
225        LiveSuccessForModule {
226            id: Uuid::new_v4(),
227            credit: recorded(grade_id),
228        }
229    }
230
231    fn recorded(grade_id: &str) -> RecordedCredit {
232        RecordedCredit {
233            grade_scale_id: Some("sis-0-5".to_string()),
234            grade_id: Some(grade_id.to_string()),
235            completion_passed: true,
236            completion_grade: grade_id.parse().ok(),
237        }
238    }
239
240    fn competing(
241        registered_by_us: Vec<LiveSuccessForModule>,
242        recorded_locally: Vec<RecordedCredit>,
243    ) -> CompetingCredits {
244        CompetingCredits {
245            registered_by_us,
246            recorded_locally,
247        }
248    }
249
250    fn usable(listed: &[RegistryEnrolment]) -> EnrolmentLookupResult<'_> {
251        EnrolmentLookupResult::Listed {
252            enrolments: listed,
253            chosen: Ok(&listed[0]),
254        }
255    }
256
257    fn resolved<'a>(
258        resolvable: &'a Resolvable,
259        lookup_result: EnrolmentLookupResult<'a>,
260        competing: &CompetingCredits,
261    ) -> Decision<'a> {
262        resolve(
263            &AnsweredLookup::new(resolvable, lookup_result, &[]),
264            competing,
265        )
266    }
267
268    /// The frozen payload and the registrations it supersedes.
269    fn frozen(decision: Decision<'_>) -> (PayloadSnapshot, Vec<Uuid>) {
270        assert_eq!(decision.outcome.to_state, State::CheckingEnrolment);
271        match decision.atomic.payload {
272            Some(PayloadChange::Frozen {
273                snapshot,
274                supersedes,
275            }) => (*snapshot, supersedes),
276            other => panic!("not frozen: {other:?}"),
277        }
278    }
279
280    #[test]
281    fn a_usable_enrolment_freezes_the_payload_for_import() {
282        let listed = [enrolment("ENROLLED")];
283        let resolvable = resolvable(context(Some(4)));
284        let decision = resolved(&resolvable, usable(&listed), &competing(vec![], vec![]));
285        assert_eq!(decision.message, None);
286        let (snapshot, supersedes) = frozen(decision);
287        assert_eq!(snapshot.uh_course_code, "TKT10002");
288        assert_eq!(
289            snapshot.selected_enrolment_id.as_deref(),
290            Some("enrolment-1")
291        );
292        assert_eq!(snapshot.grade_scale_id, "sis-0-5");
293        assert_eq!(snapshot.grade_id, "4");
294        assert!(supersedes.is_empty());
295    }
296
297    #[test]
298    fn a_grade_no_better_than_another_registered_attempt_is_held_here() {
299        let listed = [enrolment("ENROLLED")];
300        let resolvable = resolvable(context(Some(4)));
301        for held in ["4", "5"] {
302            let decision = resolved(
303                &resolvable,
304                usable(&listed),
305                &competing(vec![registered(held)], vec![]),
306            );
307            assert_eq!(decision.outcome.to_state, State::Duplicate);
308        }
309        let (_, supersedes) = frozen(resolved(
310            &resolvable,
311            usable(&listed),
312            &competing(vec![registered("3")], vec![]),
313        ));
314        assert_eq!(supersedes.len(), 1);
315    }
316
317    #[test]
318    fn an_enrolment_error_is_held_here_when_our_records_hold_a_credit_as_good() {
319        let resolvable = resolvable(context(Some(4)));
320        let decision = resolved(
321            &resolvable,
322            refused(Code::EnrolmentNotFound),
323            &competing(vec![], vec![recorded("4")]),
324        );
325        assert_eq!(decision.outcome.to_state, State::Duplicate);
326    }
327
328    #[test]
329    fn an_enrolment_error_still_fails_a_grade_that_beats_our_records() {
330        let resolvable = resolvable(context(Some(5)));
331        let decision = resolved(
332            &resolvable,
333            refused(Code::EnrolmentNotFound),
334            &competing(vec![], vec![recorded("4")]),
335        );
336        assert_eq!(decision.outcome.to_state, State::NoUsableEnrolment);
337        assert_eq!(decision.row_error, Some("item error"));
338    }
339
340    #[test]
341    fn a_refused_lookup_fails_with_the_item_code_and_error() {
342        let resolvable = resolvable(context(Some(4)));
343        let decision = resolved(
344            &resolvable,
345            refused(Code::CourseNotAllowed),
346            &competing(vec![], vec![]),
347        );
348        assert_eq!(decision.outcome.to_state, State::FailedPermanent);
349        assert_eq!(decision.outcome.error_code, Some(Code::CourseNotAllowed));
350        assert_eq!(decision.row_error, Some("item error"));
351        assert!(decision.atomic.payload.is_none());
352    }
353
354    #[test]
355    fn no_usable_enrolment_parks_the_row_with_the_reason() {
356        let listed = [enrolment("PROCESSING")];
357        let resolvable = resolvable(context(Some(4)));
358        let decision = resolved(
359            &resolvable,
360            EnrolmentLookupResult::Listed {
361                enrolments: &listed,
362                chosen: Err(NoUsableEnrolment::NotAccepted),
363            },
364            &competing(vec![], vec![]),
365        );
366        assert_eq!(decision.outcome.to_state, State::NoUsableEnrolment);
367        assert_eq!(
368            decision.outcome.error_code,
369            Some(Code::EnrolmentNotAccepted)
370        );
371        assert_eq!(
372            decision.message.as_deref(),
373            Some(NoUsableEnrolment::NotAccepted.message())
374        );
375        assert!(decision.row_error.is_none());
376    }
377
378    #[test]
379    fn a_failed_completion_is_never_frozen() {
380        let listed = [enrolment("ENROLLED")];
381        let not_passed = SubmissionContext {
382            completion: CompletionFacts {
383                passed: false,
384                ..context(Some(4)).completion
385            },
386            ..context(Some(4))
387        };
388        let resolvable = resolvable(not_passed);
389        let decision = resolved(&resolvable, usable(&listed), &competing(vec![], vec![]));
390        assert_eq!(decision.outcome.to_state, State::FailedPermanent);
391        assert_eq!(decision.outcome.error_code, Some(Code::NoGradeScaleMapping));
392    }
393
394    #[test]
395    fn sisu_holding_an_attainment_as_good_settles_the_row() {
396        let listed = [enrolment("ENROLLED")];
397        let held = attainment("sis-0-5", "4", date(2026, 8, 1));
398        let existing = [held.clone()];
399        let as_good = resolvable(context(Some(4)));
400        assert_eq!(
401            AnsweredLookup::new(&as_good, usable(&listed), &existing).held_in_sisu(),
402            Some(&held)
403        );
404        let better = resolvable(context(Some(5)));
405        assert_eq!(
406            AnsweredLookup::new(&better, usable(&listed), &existing).held_in_sisu(),
407            None
408        );
409    }
410
411    #[test]
412    fn only_a_listed_answer_or_an_enrolment_error_says_what_sisu_holds() {
413        let existing = [attainment("sis-0-5", "5", date(2026, 8, 1))];
414        let resolvable = resolvable(context(Some(4)));
415        assert!(
416            AnsweredLookup::new(&resolvable, refused(Code::EnrolmentNotFound), &existing)
417                .held_in_sisu()
418                .is_some()
419        );
420        assert_eq!(
421            AnsweredLookup::new(&resolvable, refused(Code::CourseNotAllowed), &existing)
422                .held_in_sisu(),
423            None
424        );
425    }
426
427    #[test]
428    fn a_failed_attainment_in_sisu_holds_nothing() {
429        let listed = [enrolment("ENROLLED")];
430        let existing = [RegistryAttainment {
431            state: Some("FAILED".to_string()),
432            ..attainment("sis-0-5", "5", date(2026, 8, 1))
433        }];
434        let resolvable = resolvable(context(Some(4)));
435        assert_eq!(
436            AnsweredLookup::new(&resolvable, usable(&listed), &existing).held_in_sisu(),
437            None
438        );
439    }
440
441    #[test]
442    fn an_unsent_duplicate_keeps_the_grade_a_regrade_has_to_beat() {
443        let listed = [enrolment("ENROLLED")];
444        let resolvable = resolvable(context(Some(4)));
445        let lookup = AnsweredLookup::new(&resolvable, usable(&listed), &[]);
446        let decision = unsent_duplicate(&lookup, "held");
447        assert_eq!(decision.outcome.to_state, State::Duplicate);
448        assert_eq!(decision.message.as_deref(), Some("held"));
449        let Some(PayloadChange::Unsent {
450            weighed_grade: Some(grade),
451        }) = decision.atomic.payload
452        else {
453            panic!("no weighed grade");
454        };
455        assert_eq!(
456            (grade.grade_scale_id.as_str(), grade.grade_id.as_str()),
457            ("sis-0-5", "4")
458        );
459    }
460
461    #[test]
462    fn the_freeze_message_names_what_it_supersedes_and_adjusts() {
463        let listed = [enrolment("ENROLLED")];
464        let resolvable = resolvable(context(Some(4)));
465        let (snapshot, _) = frozen(resolved(
466            &resolvable,
467            usable(&listed),
468            &competing(vec![], vec![]),
469        ));
470        let built = BuiltPayload {
471            snapshot,
472            clamped_credits_from: None,
473        };
474        assert_eq!(freeze_message(&built, &[]), None);
475        assert!(
476            freeze_message(&built, &[Uuid::new_v4()])
477                .is_some_and(|message| message.contains("supersedes"))
478        );
479        let clamped = BuiltPayload {
480            clamped_credits_from: Some(6.0),
481            ..built
482        };
483        let message = freeze_message(&clamped, &[Uuid::new_v4()]).expect("message");
484        assert!(message.contains("supersedes") && message.contains("adjusted from 6"));
485    }
486}