headless_lms_models/library/credit_registration/
payload.rs1use chrono::{Datelike, NaiveDate, Weekday};
5use headless_lms_utils::services::suotar::SuotarEnrolment;
6
7use crate::course_module_completions::CourseModuleCompletion;
8use crate::credit_registrations::{CreditRegistrationErrorCode, PayloadSnapshot};
9use crate::prelude::*;
10
11use super::grade_mapping::{GradeSource, map_grade};
12
13#[derive(Debug, Clone, PartialEq)]
15pub struct CompletionFacts {
16 pub passed: bool,
17 pub grade: Option<i32>,
18 pub completion_date: DateTime<Utc>,
19 pub completion_language: String,
20}
21
22impl From<&CourseModuleCompletion> for CompletionFacts {
23 fn from(completion: &CourseModuleCompletion) -> Self {
24 Self {
25 passed: completion.passed,
26 grade: completion.grade,
27 completion_date: completion.completion_date,
28 completion_language: completion.completion_language.clone(),
29 }
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct PayloadSources<'a> {
36 pub student_number: &'a str,
37 pub sisu_person_id: &'a str,
38 pub uh_course_code: Option<&'a str>,
39 pub ects_credits: Option<f32>,
40 pub configured_grade_scale_id: Option<&'a str>,
41 pub enrolment: Option<&'a SuotarEnrolment>,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub struct BuiltPayload {
47 pub snapshot: PayloadSnapshot,
48 pub clamped_credits_from: Option<f32>,
50}
51
52pub fn build_payload_snapshot(
53 completion: &CompletionFacts,
54 sources: PayloadSources<'_>,
55) -> Result<BuiltPayload, CreditRegistrationErrorCode> {
56 if !completion.passed {
59 return Err(CreditRegistrationErrorCode::NoGradeScaleMapping);
60 }
61 let uh_course_code = sources
62 .uh_course_code
63 .filter(|code| !code.trim().is_empty())
64 .ok_or(CreditRegistrationErrorCode::MissingUhCourseCode)?;
65 let ects_credits = sources
66 .ects_credits
67 .ok_or(CreditRegistrationErrorCode::MissingEctsCredits)?;
68 let grade = map_grade(GradeSource {
69 passed: completion.passed,
70 grade: completion.grade,
71 configured_grade_scale_id: sources.configured_grade_scale_id,
72 enrolment_grade_scale_id: sources
73 .enrolment
74 .map(|enrolment| enrolment.grade_scale_id.as_str()),
75 })?;
76 let (credits, clamped_credits_from) = clamp_credits(ects_credits, sources.enrolment);
77
78 Ok(BuiltPayload {
79 snapshot: PayloadSnapshot {
80 student_number: sources.student_number.to_string(),
81 sisu_person_id: sources.sisu_person_id.to_string(),
82 uh_course_code: uh_course_code.to_string(),
83 selected_enrolment_id: sources.enrolment.map(|enrolment| enrolment.id.clone()),
84 selected_enrolment_kind: sources.enrolment.map(|enrolment| enrolment.kind.clone()),
85 selected_enrolment_realisation_id: sources
86 .enrolment
87 .map(|enrolment| enrolment.course_unit_realisation_id.clone()),
88 attainment_date: helsinki_date(completion.completion_date),
89 attainment_language: attainment_language(&completion.completion_language),
90 grade_scale_id: grade.grade_scale_id,
91 grade_id: grade.grade_id,
92 credits,
93 },
94 clamped_credits_from,
95 })
96}
97
98fn clamp_credits(credits: f32, enrolment: Option<&SuotarEnrolment>) -> (f32, Option<f32>) {
99 let Some(range) = enrolment.map(|enrolment| &enrolment.credits) else {
100 return (credits, None);
101 };
102 let clamped = f64::from(credits).max(range.min).min(range.max) as f32;
105 if clamped == credits {
106 (credits, None)
107 } else {
108 (clamped, Some(credits))
109 }
110}
111
112fn attainment_language(completion_language: &str) -> String {
114 completion_language
115 .chars()
116 .take_while(|c| c.is_ascii_alphabetic())
117 .take(2)
118 .collect::<String>()
119 .to_lowercase()
120}
121
122pub fn helsinki_date(instant: DateTime<Utc>) -> NaiveDate {
126 let offset = chrono::Duration::hours(if in_eu_summer_time(instant) { 3 } else { 2 });
127 (instant + offset).date_naive()
128}
129
130fn in_eu_summer_time(instant: DateTime<Utc>) -> bool {
131 let year = instant.year();
132 let Some(starts) = last_sunday(year, 3).and_then(|day| day.and_hms_opt(1, 0, 0)) else {
133 return false;
134 };
135 let Some(ends) = last_sunday(year, 10).and_then(|day| day.and_hms_opt(1, 0, 0)) else {
136 return false;
137 };
138 let naive = instant.naive_utc();
139 naive >= starts && naive < ends
140}
141
142fn last_sunday(year: i32, month: u32) -> Option<NaiveDate> {
143 let first_of_next = if month == 12 {
144 NaiveDate::from_ymd_opt(year + 1, 1, 1)
145 } else {
146 NaiveDate::from_ymd_opt(year, month + 1, 1)
147 }?;
148 let last = first_of_next.pred_opt()?;
149 Some(last - chrono::Duration::days(i64::from(last.weekday().days_since(Weekday::Sun))))
150}
151
152#[cfg(test)]
153mod tests {
154 use headless_lms_utils::services::suotar::{CreditRange, DatePeriod, LocalizedName};
155
156 use super::super::grade_mapping::{NUMERIC_GRADE_SCALE_ID, PASS_FAIL_GRADE_SCALE_ID};
157 use super::*;
158
159 fn completion(passed: bool, grade: Option<i32>) -> CompletionFacts {
160 CompletionFacts {
161 passed,
162 grade,
163 completion_date: "2026-05-22T09:00:00Z".parse().expect("valid instant"),
164 completion_language: "fi-FI".to_string(),
165 }
166 }
167
168 fn enrolment(min: f64, max: f64) -> SuotarEnrolment {
169 SuotarEnrolment {
170 id: "otm-enrolment".to_string(),
171 state: "ENROLLED".to_string(),
172 kind: "degree".to_string(),
173 course_unit_id: "hy-CU-1".to_string(),
174 assessment_item_id: "hy-AI-1".to_string(),
175 course_unit_realisation_id: "hy-CUR-1".to_string(),
176 course_unit_realisation_name: LocalizedName {
177 fi: "kurssi".to_string(),
178 sv: "kurs".to_string(),
179 en: "course".to_string(),
180 },
181 activity_period: DatePeriod {
182 start_date: NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"),
183 end_date: NaiveDate::from_ymd_opt(2026, 12, 31).expect("valid date"),
184 },
185 grade_scale_id: PASS_FAIL_GRADE_SCALE_ID.to_string(),
186 credits: CreditRange { min, max },
187 study_right_id: "hy-SR-1".to_string(),
188 study_right_validity_period: DatePeriod {
189 start_date: NaiveDate::from_ymd_opt(2020, 1, 1).expect("valid date"),
190 end_date: NaiveDate::from_ymd_opt(2030, 1, 1).expect("valid date"),
191 },
192 enrolment_date_time: Utc::now(),
193 }
194 }
195
196 fn sources<'a>(enrolment: Option<&'a SuotarEnrolment>) -> PayloadSources<'a> {
197 PayloadSources {
198 student_number: "012345678",
199 sisu_person_id: "hy-hlo-1",
200 uh_course_code: Some("TKT10001"),
201 ects_credits: Some(5.0),
202 configured_grade_scale_id: None,
203 enrolment,
204 }
205 }
206
207 #[test]
208 fn the_payload_takes_its_scale_from_the_chosen_enrolment() {
209 let enrolment = enrolment(1.0, 5.0);
210 let built =
211 build_payload_snapshot(&completion(true, None), sources(Some(&enrolment))).unwrap();
212 assert_eq!(built.snapshot.grade_scale_id, PASS_FAIL_GRADE_SCALE_ID);
213 assert_eq!(built.snapshot.grade_id, "1");
214 assert_eq!(built.snapshot.credits, 5.0);
215 assert_eq!(built.clamped_credits_from, None);
216 assert_eq!(
217 built.snapshot.selected_enrolment_id.as_deref(),
218 Some("otm-enrolment")
219 );
220 }
221
222 #[test]
223 fn credits_are_clamped_into_the_enrolments_range_rather_than_refused() {
224 let enrolment = enrolment(1.0, 4.0);
225 let built =
226 build_payload_snapshot(&completion(true, None), sources(Some(&enrolment))).unwrap();
227 assert_eq!(built.snapshot.credits, 4.0);
228 assert_eq!(built.clamped_credits_from, Some(5.0));
229 }
230
231 #[test]
232 fn a_module_with_no_course_code_or_credits_is_a_configuration_problem() {
233 assert_eq!(
234 build_payload_snapshot(
235 &completion(true, None),
236 PayloadSources {
237 uh_course_code: None,
238 ..sources(None)
239 }
240 ),
241 Err(CreditRegistrationErrorCode::MissingUhCourseCode)
242 );
243 assert_eq!(
244 build_payload_snapshot(
245 &completion(true, None),
246 PayloadSources {
247 uh_course_code: Some(" "),
248 ..sources(None)
249 }
250 ),
251 Err(CreditRegistrationErrorCode::MissingUhCourseCode)
252 );
253 assert_eq!(
254 build_payload_snapshot(
255 &completion(true, None),
256 PayloadSources {
257 ects_credits: None,
258 ..sources(None)
259 }
260 ),
261 Err(CreditRegistrationErrorCode::MissingEctsCredits)
262 );
263 }
264
265 #[test]
266 fn a_failed_completion_never_becomes_a_payload() {
267 assert!(build_payload_snapshot(&completion(false, Some(0)), sources(None)).is_err());
268 }
269
270 #[test]
271 fn the_language_is_sent_as_a_two_letter_code() {
272 assert_eq!(attainment_language("fi-FI"), "fi");
273 assert_eq!(attainment_language("en"), "en");
274 assert_eq!(attainment_language("sv-SE"), "sv");
275 }
276
277 #[test]
278 fn a_graded_completion_keeps_its_number() {
279 let built = build_payload_snapshot(
280 &completion(true, Some(4)),
281 PayloadSources {
282 configured_grade_scale_id: Some(NUMERIC_GRADE_SCALE_ID),
283 ..sources(None)
284 },
285 )
286 .unwrap();
287 assert_eq!(built.snapshot.grade_id, "4");
288 }
289
290 #[test]
291 fn the_attainment_date_is_the_helsinki_date() {
292 let winter_evening: DateTime<Utc> = "2026-01-31T23:30:00Z".parse().expect("valid instant");
293 assert_eq!(
294 helsinki_date(winter_evening),
295 NaiveDate::from_ymd_opt(2026, 2, 1).expect("valid date")
296 );
297 let summer_evening: DateTime<Utc> = "2026-07-31T21:30:00Z".parse().expect("valid instant");
298 assert_eq!(
299 helsinki_date(summer_evening),
300 NaiveDate::from_ymd_opt(2026, 8, 1).expect("valid date")
301 );
302 let summer_afternoon: DateTime<Utc> =
303 "2026-07-31T12:00:00Z".parse().expect("valid instant");
304 assert_eq!(
305 helsinki_date(summer_afternoon),
306 NaiveDate::from_ymd_opt(2026, 7, 31).expect("valid date")
307 );
308 }
309
310 #[test]
311 fn summer_time_starts_and_ends_on_the_documented_sundays() {
312 let before_spring: DateTime<Utc> = "2026-03-29T00:59:00Z".parse().expect("valid instant");
313 let after_spring: DateTime<Utc> = "2026-03-29T01:00:00Z".parse().expect("valid instant");
314 assert!(!in_eu_summer_time(before_spring));
315 assert!(in_eu_summer_time(after_spring));
316
317 let before_autumn: DateTime<Utc> = "2026-10-25T00:59:00Z".parse().expect("valid instant");
318 let after_autumn: DateTime<Utc> = "2026-10-25T01:00:00Z".parse().expect("valid instant");
319 assert!(in_eu_summer_time(before_autumn));
320 assert!(!in_eu_summer_time(after_autumn));
321 }
322}