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. Degree before open
2//! university: a degree student who also holds an open-university study right wants the credit
3//! inside their degree.
4
5use chrono::NaiveDate;
6use headless_lms_utils::services::suotar::{CreditRange, ExistingAttainment, SuotarEnrolment};
7
8use crate::credit_registrations::CreditRegistrationErrorCode;
9
10pub const ENROLLED_STATE: &str = "ENROLLED";
11pub const ATTAINED_STATE: &str = "ATTAINED";
12pub const DEGREE_KIND: &str = "degree";
13
14use super::grade_mapping::same_grade_scale;
15
16/// Why no enrolment could carry the attainment; each variant reads differently to the student.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum NoUsableEnrolment {
19    /// The registry knows of no enrolment at all for this student on this course.
20    None,
21    /// There are enrolments, but none of them is accepted.
22    NotAccepted,
23    /// The study right does not cover the day the work was completed.
24    StudyRightExpired,
25    /// The enrolment cannot carry this many credits, which is a mismatch in our configuration.
26    CreditsOutOfRange,
27}
28
29impl NoUsableEnrolment {
30    pub fn error_code(self) -> CreditRegistrationErrorCode {
31        match self {
32            Self::None => CreditRegistrationErrorCode::EnrolmentNotFound,
33            Self::NotAccepted => CreditRegistrationErrorCode::EnrolmentNotAccepted,
34            Self::StudyRightExpired => CreditRegistrationErrorCode::StudyRightNotValid,
35            Self::CreditsOutOfRange => CreditRegistrationErrorCode::InvalidCredits,
36        }
37    }
38
39    /// Recorded on the row so the student-facing copy can be specific about what to do.
40    pub fn message(self) -> &'static str {
41        match self {
42            Self::None => "The study registry holds no enrolment for this course.",
43            Self::NotAccepted => "No enrolment for this course has been accepted.",
44            Self::StudyRightExpired => {
45                "No enrolment has a study right covering the completion date."
46            }
47            Self::CreditsOutOfRange => {
48                "No enrolment can carry the credits configured for this module."
49            }
50        }
51    }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct EnrolmentCriteria<'a> {
56    pub attainment_date: NaiveDate,
57    pub credits: f32,
58    /// Being enrolled on one of these is the strongest signal we have of the right enrolment.
59    pub configured_realisation_ids: &'a [String],
60}
61
62pub fn select_enrolment<'a>(
63    enrolments: &'a [SuotarEnrolment],
64    criteria: EnrolmentCriteria<'_>,
65) -> Result<&'a SuotarEnrolment, NoUsableEnrolment> {
66    if enrolments.is_empty() {
67        return Err(NoUsableEnrolment::None);
68    }
69    let accepted: Vec<&SuotarEnrolment> = enrolments
70        .iter()
71        .filter(|enrolment| enrolment.state == ENROLLED_STATE)
72        .collect();
73    if accepted.is_empty() {
74        return Err(NoUsableEnrolment::NotAccepted);
75    }
76    // Checked here rather than paid for as a round trip that comes back studyRightNotValid.
77    let valid: Vec<&SuotarEnrolment> = accepted
78        .into_iter()
79        .filter(|enrolment| {
80            contains(
81                criteria.attainment_date,
82                enrolment.study_right_validity_period.start_date,
83                enrolment.study_right_validity_period.end_date,
84            )
85        })
86        .collect();
87    if valid.is_empty() {
88        return Err(NoUsableEnrolment::StudyRightExpired);
89    }
90    let usable: Vec<&SuotarEnrolment> = valid
91        .into_iter()
92        .filter(|enrolment| credits_fit(&enrolment.credits, criteria.credits))
93        .collect();
94    if usable.is_empty() {
95        return Err(NoUsableEnrolment::CreditsOutOfRange);
96    }
97    usable
98        .into_iter()
99        .max_by_key(|enrolment| {
100            (
101                criteria
102                    .configured_realisation_ids
103                    .iter()
104                    .any(|id| id == &enrolment.course_unit_realisation_id),
105                enrolment.kind == DEGREE_KIND,
106                contains(
107                    criteria.attainment_date,
108                    enrolment.activity_period.start_date,
109                    enrolment.activity_period.end_date,
110                ),
111                enrolment.enrolment_date_time,
112            )
113        })
114        .ok_or(NoUsableEnrolment::None)
115}
116
117/// Slack for the f32-to-f64 widening. Real credit amounts are never finer than 0.1.
118const CREDITS_TOLERANCE: f64 = 1e-4;
119
120/// Whether an enrolment's registry-declared credit range can carry the module's credits. A range
121/// with `min > max` is the registry's own data at fault, so it is never usable.
122fn credits_fit(range: &CreditRange, credits: f32) -> bool {
123    if range.min > range.max {
124        return false;
125    }
126    let credits = f64::from(credits);
127    (range.min - CREDITS_TOLERANCE..=range.max + CREDITS_TOLERANCE).contains(&credits)
128}
129
130/// An attainment the registry already holds for this course unit: importing would duplicate it.
131pub fn attainment_for_course_unit<'a>(
132    existing: &'a [ExistingAttainment],
133    course_unit_id: &str,
134    assessment_item_id: &str,
135) -> Option<&'a ExistingAttainment> {
136    existing.iter().find(|attainment| {
137        attainment.state == ATTAINED_STATE
138            && (same_id(&attainment.course_unit_id, course_unit_id)
139                || same_id(&attainment.assessment_item_id, assessment_item_id))
140    })
141}
142
143/// A blank never matches: a response that omits an id must not thereby match every attainment.
144fn same_id(left: &str, right: &str) -> bool {
145    !left.is_empty() && left == right
146}
147
148/// Any attainment the registry holds, for when no enrolment names the course unit. The response is
149/// scoped to one student and one course code already; the person is checked because nothing else
150/// here is.
151pub fn any_attained_by_person<'a>(
152    existing: &'a [ExistingAttainment],
153    sisu_person_id: &str,
154) -> Option<&'a ExistingAttainment> {
155    existing.iter().find(|attainment| {
156        attainment.state == ATTAINED_STATE && same_id(&attainment.person_id, sisu_person_id)
157    })
158}
159
160/// The attainment a submission we lost track of would have produced, matched on what we sent.
161pub fn attainment_matching_submission<'a>(
162    existing: &'a [ExistingAttainment],
163    attainment_date: NaiveDate,
164    grade_scale_id: &str,
165    grade_id: &str,
166) -> Option<&'a ExistingAttainment> {
167    existing.iter().find(|attainment| {
168        attainment.state == ATTAINED_STATE
169            && attainment.attainment_date == attainment_date
170            && attainment.grade_id == grade_id
171            && same_grade_scale(&attainment.grade_scale_id, grade_scale_id)
172    })
173}
174
175fn contains(date: NaiveDate, start: NaiveDate, end: NaiveDate) -> bool {
176    date >= start && date <= end
177}
178
179#[cfg(test)]
180mod tests {
181    use headless_lms_utils::services::suotar::{CreditRange, DatePeriod, LocalizedName};
182
183    use super::*;
184    use crate::prelude::*;
185
186    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
187        NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
188    }
189
190    fn period(start: NaiveDate, end: NaiveDate) -> DatePeriod {
191        DatePeriod {
192            start_date: start,
193            end_date: end,
194        }
195    }
196
197    fn enrolment(id: &str, kind: &str) -> SuotarEnrolment {
198        SuotarEnrolment {
199            id: id.to_string(),
200            state: ENROLLED_STATE.to_string(),
201            kind: kind.to_string(),
202            course_unit_id: "hy-CU-1".to_string(),
203            assessment_item_id: "hy-AI-1".to_string(),
204            course_unit_realisation_id: format!("hy-CUR-{id}"),
205            course_unit_realisation_name: LocalizedName {
206                fi: "kurssi".to_string(),
207                sv: "kurs".to_string(),
208                en: "course".to_string(),
209            },
210            activity_period: period(date(2026, 1, 1), date(2026, 12, 31)),
211            grade_scale_id: "sis-hyl-hyv".to_string(),
212            credits: CreditRange { min: 1.0, max: 5.0 },
213            study_right_id: "hy-SR-1".to_string(),
214            study_right_validity_period: period(date(2020, 1, 1), date(2030, 1, 1)),
215            enrolment_date_time: Utc::now(),
216        }
217    }
218
219    fn criteria() -> EnrolmentCriteria<'static> {
220        EnrolmentCriteria {
221            attainment_date: date(2026, 5, 22),
222            credits: 5.0,
223            configured_realisation_ids: &[],
224        }
225    }
226
227    #[test]
228    fn nothing_to_choose_from_is_its_own_reason() {
229        assert_eq!(
230            select_enrolment(&[], criteria()),
231            Err(NoUsableEnrolment::None)
232        );
233    }
234
235    #[test]
236    fn an_enrolment_that_was_never_accepted_is_not_usable() {
237        let mut pending = enrolment("a", DEGREE_KIND);
238        pending.state = "NOT_ENROLLED".to_string();
239        let candidates = [pending];
240        assert_eq!(
241            select_enrolment(&candidates, criteria()),
242            Err(NoUsableEnrolment::NotAccepted)
243        );
244    }
245
246    #[test]
247    fn a_study_right_that_does_not_cover_the_completion_is_not_usable() {
248        let mut expired = enrolment("a", DEGREE_KIND);
249        expired.study_right_validity_period = period(date(2020, 1, 1), date(2021, 1, 1));
250        let candidates = [expired];
251        assert_eq!(
252            select_enrolment(&candidates, criteria()),
253            Err(NoUsableEnrolment::StudyRightExpired)
254        );
255    }
256
257    #[test]
258    fn an_enrolment_too_small_for_the_credits_is_a_configuration_problem() {
259        let mut small = enrolment("a", DEGREE_KIND);
260        small.credits = CreditRange { min: 1.0, max: 2.0 };
261        let candidates = [small];
262        assert_eq!(
263            select_enrolment(&candidates, criteria()),
264            Err(NoUsableEnrolment::CreditsOutOfRange)
265        );
266        assert_eq!(
267            NoUsableEnrolment::CreditsOutOfRange.error_code(),
268            CreditRegistrationErrorCode::InvalidCredits
269        );
270    }
271
272    #[test]
273    fn a_degree_enrolment_wins_over_an_open_university_one() {
274        let candidates = [
275            enrolment("open", "openUniversity"),
276            enrolment("degree", DEGREE_KIND),
277        ];
278        let chosen = select_enrolment(&candidates, criteria()).expect("a usable enrolment");
279        assert_eq!(chosen.id, "degree");
280    }
281
282    #[test]
283    fn a_configured_realisation_wins_over_the_kind() {
284        let candidates = [
285            enrolment("open", "openUniversity"),
286            enrolment("degree", DEGREE_KIND),
287        ];
288        let configured = [candidates[0].course_unit_realisation_id.clone()];
289        let chosen = select_enrolment(
290            &candidates,
291            EnrolmentCriteria {
292                configured_realisation_ids: &configured,
293                ..criteria()
294            },
295        )
296        .expect("a usable enrolment");
297        assert_eq!(chosen.id, "open");
298    }
299
300    #[test]
301    fn a_realisation_running_when_the_work_was_done_wins_over_an_older_one() {
302        let mut past = enrolment("past", DEGREE_KIND);
303        past.activity_period = period(date(2024, 1, 1), date(2024, 12, 31));
304        past.enrolment_date_time = Utc::now();
305        let mut current = enrolment("current", DEGREE_KIND);
306        current.enrolment_date_time = Utc::now() - chrono::Duration::days(365);
307        let candidates = [past, current];
308        let chosen = select_enrolment(&candidates, criteria()).expect("a usable enrolment");
309        assert_eq!(chosen.id, "current");
310    }
311
312    #[test]
313    fn the_most_recent_enrolment_breaks_a_remaining_tie() {
314        let mut older = enrolment("older", DEGREE_KIND);
315        older.enrolment_date_time = Utc::now() - chrono::Duration::days(30);
316        let candidates = [older, enrolment("newer", DEGREE_KIND)];
317        let chosen = select_enrolment(&candidates, criteria()).expect("a usable enrolment");
318        assert_eq!(chosen.id, "newer");
319    }
320
321    fn attainment(scale: &str, grade: &str, day: u32) -> ExistingAttainment {
322        ExistingAttainment {
323            id: format!("hy-att-{day}"),
324            attainment_type: "CourseUnitAttainment".to_string(),
325            state: ATTAINED_STATE.to_string(),
326            person_id: "hy-hlo-1".to_string(),
327            course_unit_id: "hy-CU-1".to_string(),
328            assessment_item_id: "hy-AI-1".to_string(),
329            course_unit_realisation_id: "hy-CUR-a".to_string(),
330            attainment_date: date(2026, 5, day),
331            registration_date: date(2026, 5, day),
332            grade_scale_id: scale.to_string(),
333            grade_id: grade.to_string(),
334            passed: true,
335        }
336    }
337
338    #[test]
339    fn an_attainment_the_registry_already_holds_is_found_before_we_import() {
340        let existing = [attainment("sis-hyl-hyv", "1", 22)];
341        assert!(attainment_for_course_unit(&existing, "hy-CU-1", "hy-AI-9").is_some());
342        assert!(attainment_for_course_unit(&existing, "hy-CU-9", "hy-AI-9").is_none());
343    }
344
345    #[test]
346    fn a_reversed_attainment_does_not_count_as_one_the_registry_holds() {
347        let mut reversed = attainment("sis-hyl-hyv", "1", 22);
348        reversed.state = "MISREGISTERED".to_string();
349        let existing = [reversed];
350        assert!(attainment_for_course_unit(&existing, "hy-CU-1", "hy-AI-1").is_none());
351    }
352
353    #[test]
354    fn a_lost_submission_is_recognised_across_both_scale_spellings() {
355        let existing = [attainment("sis-hyv-hyl", "1", 22)];
356        assert!(
357            attainment_matching_submission(&existing, date(2026, 5, 22), "sis-hyl-hyv", "1")
358                .is_some()
359        );
360        assert!(
361            attainment_matching_submission(&existing, date(2026, 5, 23), "sis-hyl-hyv", "1")
362                .is_none()
363        );
364        assert!(
365            attainment_matching_submission(&existing, date(2026, 5, 22), "sis-hyl-hyv", "0")
366                .is_none()
367        );
368    }
369}