1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum NoUsableEnrolment {
22 None,
24 NotAccepted,
26 CreditsOutOfRange,
28 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 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
63pub 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 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
111const CREDITS_TOLERANCE: f64 = 1e-4;
113
114fn 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
121fn 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
130pub 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
147const RECOVERY_REGISTRATION_WINDOW_DAYS: i64 = 2;
149
150pub 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
164pub 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}