1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum NoUsableEnrolment {
19 None,
21 NotAccepted,
23 StudyRightExpired,
25 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 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 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 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
117const CREDITS_TOLERANCE: f64 = 1e-4;
119
120fn 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
130pub 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
143fn same_id(left: &str, right: &str) -> bool {
145 !left.is_empty() && left == right
146}
147
148pub 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
160pub 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}