Skip to main content

headless_lms_models/library/credit_registration/
payload.rs

1//! The frozen copy of what we submit: written once before the row leaves enrolment resolution and
2//! never rewritten, so a later regrade cannot silently change something already sent.
3
4use 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/// What the completion contributes to the payload.
14#[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/// Everything outside the completion that the payload is built from.
34#[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/// A snapshot and whatever had to be adjusted to make it acceptable.
45#[derive(Debug, Clone, PartialEq)]
46pub struct BuiltPayload {
47    pub snapshot: PayloadSnapshot,
48    /// Set when the module's credits did not fit the enrolment's range; recorded, not refused.
49    pub clamped_credits_from: Option<f32>,
50}
51
52pub fn build_payload_snapshot(
53    completion: &CompletionFacts,
54    sources: PayloadSources<'_>,
55) -> Result<BuiltPayload, CreditRegistrationErrorCode> {
56    // The last line of defence for "never push a failure", behind materialize's filter and the
57    // precondition recompute: this is what goes on the wire.
58    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    // Not f64::clamp, which panics if min > max: select_enrolment refuses such a range, but a wire
103    // value must not be able to crash the worker whatever upstream guarantees.
104    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
112/// The two-letter code the registry's examples use; our column holds forms like `fi-FI`.
113fn 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
122/// The attainment date as the university reckons it, which is the date an official transcript gets:
123/// a completion at 23:30 UTC on the 31st is the 1st in Helsinki. The EU summer-time rule is written
124/// out rather than read from a timezone database, which this crate does not carry.
125pub 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}