Skip to main content

headless_lms_models/library/credit_registration/
enrolment_selection.rs

1//! Which of a student's enrolments the attainment is registered against. A study right that covers
2//! the attainment date first, since Sisu refuses one that does not; then degree before open
3//! university: a degree student who also holds an open-university study right wants the credit
4//! inside their degree.
5
6use chrono::{DateTime, NaiveDate, Utc};
7
8use crate::credit_registrations::CreditRegistrationErrorCode;
9
10pub const ENROLLED_STATE: &str = "ENROLLED";
11pub const FAILED_STATE: &str = "FAILED";
12pub const DEGREE_KIND: &str = "degree";
13
14use super::grade_mapping::{MappedGrade, same_grade_scale};
15use super::study_registry::{
16    ATTAINMENT_TYPE_COURSE_UNIT, CreditRange, DatePeriod, RegistryAttainment, RegistryEnrolment,
17};
18
19/// Why no enrolment could carry the attainment; each variant reads differently to the student.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum NoUsableEnrolment {
22    /// The registry knows of no enrolment at all for this student on this course.
23    None,
24    /// There are enrolments, but none of them is accepted.
25    NotAccepted,
26    /// The enrolment cannot carry this many credits, which is a mismatch in our configuration.
27    CreditsOutOfRange,
28    /// The registry gives the course unit no usable credit range, so no enrolment on it can carry
29    /// an attainment.
30    NoCreditRange,
31}
32
33impl NoUsableEnrolment {
34    pub fn error_code(self) -> CreditRegistrationErrorCode {
35        match self {
36            Self::None => CreditRegistrationErrorCode::EnrolmentNotFound,
37            Self::NotAccepted => CreditRegistrationErrorCode::EnrolmentNotAccepted,
38            Self::CreditsOutOfRange | Self::NoCreditRange => {
39                CreditRegistrationErrorCode::InvalidCredits
40            }
41        }
42    }
43
44    /// Recorded on the row so the student-facing copy can be specific about what to do.
45    pub fn message(self) -> &'static str {
46        match self {
47            Self::None => "Sisu has no enrolment for this course.",
48            Self::NotAccepted => "No enrolment for this course has been accepted.",
49            Self::CreditsOutOfRange => {
50                "No enrolment can carry the credits configured for this module."
51            }
52            Self::NoCreditRange => "The study registry gives no credit range for this course.",
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub struct EnrolmentCriteria {
59    pub attainment_date: NaiveDate,
60    pub credits: f32,
61}
62
63/// The enrolment to register an attainment against, or why none will do: an accepted one whose
64/// credit range carries `criteria.credits`, preferring one whose study right covers the
65/// attainment date, then a degree study right, then one whose activity period covers it, then
66/// the latest enrolment.
67pub fn select_enrolment(
68    enrolments: &[RegistryEnrolment],
69    criteria: EnrolmentCriteria,
70) -> Result<&RegistryEnrolment, NoUsableEnrolment> {
71    if enrolments.is_empty() {
72        return Err(NoUsableEnrolment::None);
73    }
74    let accepted: Vec<&RegistryEnrolment> = enrolments
75        .iter()
76        .filter(|enrolment| enrolment.state.as_deref() == Some(ENROLLED_STATE))
77        .collect();
78    if accepted.is_empty() {
79        return Err(NoUsableEnrolment::NotAccepted);
80    }
81    if accepted
82        .iter()
83        .all(|enrolment| valid_range(enrolment.credits.as_ref()).is_none())
84    {
85        return Err(NoUsableEnrolment::NoCreditRange);
86    }
87    let usable: Vec<&RegistryEnrolment> = accepted
88        .into_iter()
89        .filter(|enrolment| credits_fit(enrolment.credits.as_ref(), criteria.credits))
90        .collect();
91    if usable.is_empty() {
92        return Err(NoUsableEnrolment::CreditsOutOfRange);
93    }
94    usable
95        .into_iter()
96        .max_by_key(|enrolment| {
97            let covers_attainment_date = |period: Option<&DatePeriod>| {
98                period.is_some_and(|period| period.contains(criteria.attainment_date))
99            };
100            (
101                // Only a preference: an unresolved study right is not proof of an invalid one.
102                covers_attainment_date(enrolment.study_right_validity_period.as_ref()),
103                enrolment.kind.as_deref() == Some(DEGREE_KIND),
104                covers_attainment_date(enrolment.activity_period.as_ref()),
105                enrolment.enrolment_date_time,
106            )
107        })
108        .ok_or(NoUsableEnrolment::None)
109}
110
111/// Slack for the f32-to-f64 widening. Real credit amounts are never finer than 0.1.
112const CREDITS_TOLERANCE: f64 = 1e-4;
113
114/// `(min, max)`. A missing bound or `min > max` is the registry's own data at fault, and as unusable
115/// as no range.
116fn valid_range(range: Option<&CreditRange>) -> Option<(f64, f64)> {
117    let range = range?;
118    range.min.zip(range.max).filter(|(min, max)| min <= max)
119}
120
121/// Whether an enrolment's registry-declared credit range can carry the module's credits.
122fn credits_fit(range: Option<&CreditRange>, credits: f32) -> bool {
123    let Some((min, max)) = valid_range(range) else {
124        return false;
125    };
126    let credits = f64::from(credits);
127    (min - CREDITS_TOLERANCE..=max + CREDITS_TOLERANCE).contains(&credits)
128}
129
130/// The attainments a new one must improve on: every entry on the student and course code that is not
131/// `failed`, matching the scope Suotar's own import checks against.
132///
133/// Suotar excludes a misregistered entry by a flag this endpoint does not pass through to us, so an
134/// unrecognised `state` counts as a real attainment rather than being whitelisted away: undercounting
135/// here risks a duplicate Sisu registration, which is worse than the reverse.
136pub fn attained_candidates(existing: &[RegistryAttainment]) -> Vec<&RegistryAttainment> {
137    existing
138        .iter()
139        .filter(|attainment| is_valid_attainment(attainment))
140        .collect()
141}
142
143fn is_valid_attainment(attainment: &RegistryAttainment) -> bool {
144    attainment.state.as_deref() != Some(FAILED_STATE)
145}
146
147/// How long after the submission its attainment may be registered and still count as its own.
148const RECOVERY_REGISTRATION_WINDOW_DAYS: i64 = 2;
149
150/// The attainment `sisu_attainment_id` records: the course unit one when there is one, else the
151/// latest.
152pub fn preferred_attainment<'a>(
153    candidates: &[&'a RegistryAttainment],
154) -> Option<&'a RegistryAttainment> {
155    candidates.iter().copied().max_by_key(|attainment| {
156        (
157            attainment.attainment_type == ATTAINMENT_TYPE_COURSE_UNIT,
158            attainment.registration_date,
159            attainment.attainment_date,
160        )
161    })
162}
163
164/// The attainment a submission we lost track of would have produced, matched on what we sent.
165///
166/// Suotar may move the attainment date into the study right without telling us, so an attainment
167/// registered within a couple of days of `submitted_at` matches on its grade alone. The course unit
168/// attainment wins over the assessment item one it is built from.
169pub fn attainment_matching_submission<'a>(
170    existing: &'a [RegistryAttainment],
171    attainment_date: NaiveDate,
172    submitted_at: Option<DateTime<Utc>>,
173    grade: &MappedGrade,
174) -> Option<&'a RegistryAttainment> {
175    let submitted_on = submitted_at.map(|submitted_at| submitted_at.date_naive());
176    existing
177        .iter()
178        .filter(|attainment| {
179            is_valid_attainment(attainment)
180                && (attainment.attainment_date == Some(attainment_date)
181                    || attainment.registration_date.zip(submitted_on).is_some_and(
182                        |(registered_on, submitted_on)| {
183                            (0..=RECOVERY_REGISTRATION_WINDOW_DAYS)
184                                .contains(&(registered_on - submitted_on).num_days())
185                        },
186                    ))
187                && attainment.grade_id.as_deref() == Some(grade.grade_id.as_str())
188                && attainment
189                    .grade_scale_id
190                    .as_deref()
191                    .is_some_and(|scale| same_grade_scale(scale, &grade.grade_scale_id))
192        })
193        .min_by_key(|attainment| attainment.attainment_type != ATTAINMENT_TYPE_COURSE_UNIT)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::super::study_registry::LocalizedName;
199    use super::*;
200    use crate::prelude::*;
201
202    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
203        NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
204    }
205
206    fn period(start: NaiveDate, end: NaiveDate) -> DatePeriod {
207        DatePeriod {
208            start_date: Some(start),
209            end_date: Some(end),
210        }
211    }
212
213    fn enrolment(id: &str, kind: &str) -> RegistryEnrolment {
214        RegistryEnrolment {
215            id: id.to_string(),
216            state: Some(ENROLLED_STATE.to_string()),
217            kind: Some(kind.to_string()),
218            course_unit_realisation_id: Some(format!("hy-CUR-{id}")),
219            course_unit_realisation_name: Some(LocalizedName {
220                fi: Some("kurssi".to_string()),
221                sv: Some("kurs".to_string()),
222                en: Some("course".to_string()),
223            }),
224            activity_period: Some(period(date(2026, 1, 1), date(2026, 12, 31))),
225            grade_scale_id: Some("sis-hyl-hyv".to_string()),
226            credits: Some(CreditRange {
227                min: Some(1.0),
228                max: Some(5.0),
229            }),
230            study_right_validity_period: None,
231            enrolment_date_time: Some(Utc::now()),
232        }
233    }
234
235    fn criteria() -> EnrolmentCriteria {
236        EnrolmentCriteria {
237            attainment_date: date(2026, 5, 22),
238            credits: 5.0,
239        }
240    }
241
242    #[test]
243    fn nothing_to_choose_from_is_its_own_reason() {
244        assert_eq!(
245            select_enrolment(&[], criteria()),
246            Err(NoUsableEnrolment::None)
247        );
248    }
249
250    #[test]
251    fn an_enrolment_that_was_never_accepted_is_not_usable() {
252        let mut pending = enrolment("a", DEGREE_KIND);
253        pending.state = Some("NOT_ENROLLED".to_string());
254        let candidates = [pending];
255        assert_eq!(
256            select_enrolment(&candidates, criteria()),
257            Err(NoUsableEnrolment::NotAccepted)
258        );
259    }
260
261    #[test]
262    fn an_enrolment_too_small_for_the_credits_is_a_configuration_problem() {
263        let mut small = enrolment("a", DEGREE_KIND);
264        small.credits = Some(CreditRange {
265            min: Some(1.0),
266            max: Some(2.0),
267        });
268        let candidates = [small];
269        assert_eq!(
270            select_enrolment(&candidates, criteria()),
271            Err(NoUsableEnrolment::CreditsOutOfRange)
272        );
273        assert_eq!(
274            NoUsableEnrolment::CreditsOutOfRange.error_code(),
275            CreditRegistrationErrorCode::InvalidCredits
276        );
277    }
278
279    #[test]
280    fn a_degree_enrolment_wins_over_an_open_university_one() {
281        let candidates = [
282            enrolment("open", "openUniversity"),
283            enrolment("degree", DEGREE_KIND),
284        ];
285        let chosen = select_enrolment(&candidates, criteria()).expect("a usable enrolment");
286        assert_eq!(chosen.id, "degree");
287    }
288
289    #[test]
290    fn a_study_right_covering_the_attainment_date_wins_over_the_degree_preference() {
291        let mut expired_degree = enrolment("degree", DEGREE_KIND);
292        expired_degree.study_right_validity_period =
293            Some(period(date(2020, 1, 1), criteria().attainment_date));
294        let mut open = enrolment("open", "openUniversity");
295        open.study_right_validity_period = Some(period(date(2026, 1, 1), date(2026, 12, 31)));
296        let candidates = [expired_degree, open];
297        let chosen = select_enrolment(&candidates, criteria()).expect("a usable enrolment");
298        assert_eq!(chosen.id, "open");
299    }
300
301    #[test]
302    fn an_unresolved_study_right_is_still_usable() {
303        let candidates = [enrolment("unresolved", DEGREE_KIND)];
304        let chosen = select_enrolment(&candidates, criteria()).expect("a usable enrolment");
305        assert_eq!(chosen.id, "unresolved");
306    }
307
308    #[test]
309    fn a_realisation_running_when_the_work_was_done_wins_over_an_older_one() {
310        let mut past = enrolment("past", DEGREE_KIND);
311        past.activity_period = Some(period(date(2024, 1, 1), date(2024, 12, 31)));
312        past.enrolment_date_time = Some(Utc::now());
313        let mut current = enrolment("current", DEGREE_KIND);
314        current.enrolment_date_time = Some(Utc::now() - chrono::Duration::days(365));
315        let candidates = [past, current];
316        let chosen = select_enrolment(&candidates, criteria()).expect("a usable enrolment");
317        assert_eq!(chosen.id, "current");
318    }
319
320    #[test]
321    fn the_most_recent_enrolment_breaks_a_remaining_tie() {
322        let mut older = enrolment("older", DEGREE_KIND);
323        older.enrolment_date_time = Some(Utc::now() - chrono::Duration::days(30));
324        let candidates = [older, enrolment("newer", DEGREE_KIND)];
325        let chosen = select_enrolment(&candidates, criteria()).expect("a usable enrolment");
326        assert_eq!(chosen.id, "newer");
327    }
328
329    fn attainment(scale: &str, grade: &str, day: u32) -> RegistryAttainment {
330        RegistryAttainment {
331            id: format!("hy-att-{day}"),
332            attainment_type: "CourseUnitAttainment".to_string(),
333            state: Some("ATTAINED".to_string()),
334            attainment_date: Some(date(2026, 5, day)),
335            registration_date: Some(date(2026, 5, day)),
336            grade_scale_id: Some(scale.to_string()),
337            grade_id: Some(grade.to_string()),
338        }
339    }
340
341    #[test]
342    fn a_failed_attempt_does_not_count_as_one_the_registry_holds() {
343        let mut failed = attainment("sis-0-5", "0", 22);
344        failed.state = Some(FAILED_STATE.to_string());
345        let existing = [failed];
346        assert!(attained_candidates(&existing).is_empty());
347    }
348
349    fn grade(grade_scale_id: &str, grade_id: &str) -> MappedGrade {
350        MappedGrade {
351            grade_scale_id: grade_scale_id.to_string(),
352            grade_id: grade_id.to_string(),
353        }
354    }
355
356    #[test]
357    fn a_lost_submission_is_recognised_across_both_scale_spellings() {
358        let existing = [attainment("sis-hyv-hyl", "1", 22)];
359        assert!(
360            attainment_matching_submission(
361                &existing,
362                date(2026, 5, 22),
363                None,
364                &grade("sis-hyl-hyv", "1")
365            )
366            .is_some()
367        );
368        assert!(
369            attainment_matching_submission(
370                &existing,
371                date(2026, 5, 23),
372                None,
373                &grade("sis-hyl-hyv", "1")
374            )
375            .is_none()
376        );
377        assert!(
378            attainment_matching_submission(
379                &existing,
380                date(2026, 5, 22),
381                None,
382                &grade("sis-hyl-hyv", "0")
383            )
384            .is_none()
385        );
386    }
387}