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 crate::credit_registrations::{CreditRegistrationErrorCode, PayloadSnapshot};
5use crate::prelude::*;
6
7use super::grade_mapping::{GradeSource, map_grade};
8use super::study_registry::RegistryEnrolment;
9
10/// What the completion contributes to the payload.
11#[derive(Debug, Clone, PartialEq)]
12pub struct CompletionFacts {
13    pub passed: bool,
14    pub grade: Option<i32>,
15    pub completion_date: DateTime<Utc>,
16    pub completion_language: String,
17}
18
19/// Everything outside the completion that the payload is built from.
20#[derive(Debug, Clone, Copy)]
21pub struct PayloadSources<'a> {
22    pub student_number: &'a DbSecret,
23    pub sisu_person_id: Option<&'a DbSecret>,
24    pub uh_course_code: Option<&'a str>,
25    pub ects_credits: Option<f32>,
26    pub enrolment: Option<&'a RegistryEnrolment>,
27}
28
29/// A snapshot and whatever had to be adjusted to make it acceptable.
30#[derive(Debug, Clone)]
31pub struct BuiltPayload {
32    pub snapshot: PayloadSnapshot,
33    /// Set when the module's credits did not fit the enrolment's range; recorded, not refused.
34    pub clamped_credits_from: Option<f32>,
35}
36
37pub fn build_payload_snapshot(
38    completion: &CompletionFacts,
39    sources: PayloadSources<'_>,
40) -> Result<BuiltPayload, CreditRegistrationErrorCode> {
41    // The last line of defence for "never push a failure", behind materialize's filter and the
42    // precondition recompute: this is what goes on the wire.
43    if !completion.passed {
44        return Err(CreditRegistrationErrorCode::NoGradeScaleMapping);
45    }
46    let uh_course_code = sources
47        .uh_course_code
48        .map(str::trim)
49        .filter(|code| !code.is_empty())
50        .ok_or(CreditRegistrationErrorCode::MissingUhCourseCode)?;
51    let ects_credits = sources
52        .ects_credits
53        .ok_or(CreditRegistrationErrorCode::MissingEctsCredits)?;
54    let grade = map_grade(GradeSource {
55        passed: completion.passed,
56        grade: completion.grade,
57        enrolment_grade_scale_id: sources
58            .enrolment
59            .and_then(|enrolment| enrolment.grade_scale_id.as_deref()),
60    })?;
61    let (credits, clamped_credits_from) = clamp_credits(ects_credits, sources.enrolment);
62
63    Ok(BuiltPayload {
64        snapshot: PayloadSnapshot {
65            student_number: sources.student_number.clone(),
66            sisu_person_id: sources.sisu_person_id.cloned(),
67            uh_course_code: uh_course_code.to_string(),
68            selected_enrolment_id: sources.enrolment.map(|enrolment| enrolment.id.clone()),
69            selected_enrolment_kind: sources
70                .enrolment
71                .and_then(|enrolment| enrolment.kind.clone()),
72            selected_enrolment_realisation_id: sources
73                .enrolment
74                .and_then(|enrolment| enrolment.course_unit_realisation_id.clone()),
75            selected_enrolment_realisation_name: sources
76                .enrolment
77                .and_then(|enrolment| enrolment.course_unit_realisation_name.as_ref())
78                .and_then(|name| serde_json::to_value(name).ok()),
79            attained_at: completion.completion_date,
80            attainment_language: attainment_language(&completion.completion_language),
81            grade_scale_id: grade.grade_scale_id,
82            grade_id: grade.grade_id,
83            credits,
84        },
85        clamped_credits_from,
86    })
87}
88
89fn clamp_credits(credits: f32, enrolment: Option<&RegistryEnrolment>) -> (f32, Option<f32>) {
90    let Some((min, max)) = enrolment
91        .and_then(|enrolment| enrolment.credits.as_ref())
92        .and_then(|range| range.min.zip(range.max))
93    else {
94        return (credits, None);
95    };
96    // Not f64::clamp, which panics if min > max: select_enrolment refuses such a range, but a wire
97    // value must not be able to crash the worker whatever upstream guarantees.
98    let clamped = f64::from(credits).max(min).min(max) as f32;
99    if clamped == credits {
100        (credits, None)
101    } else {
102        (clamped, Some(credits))
103    }
104}
105
106/// The two-letter code the registry's examples use; our column holds forms like `fi-FI`.
107fn attainment_language(completion_language: &str) -> String {
108    completion_language
109        .chars()
110        .take_while(|c| c.is_ascii_alphabetic())
111        .take(2)
112        .collect::<String>()
113        .to_lowercase()
114}
115
116#[cfg(test)]
117mod tests {
118    use std::sync::LazyLock;
119
120    use chrono::NaiveDate;
121
122    use super::super::grade_mapping::{NUMERIC_GRADE_SCALE_ID, PASS_FAIL_GRADE_SCALE_ID};
123    use super::super::study_registry::{CreditRange, DatePeriod, LocalizedName};
124    use super::*;
125
126    fn completion(passed: bool, grade: Option<i32>) -> CompletionFacts {
127        CompletionFacts {
128            passed,
129            grade,
130            completion_date: "2026-05-22T09:00:00Z".parse().expect("valid instant"),
131            completion_language: "fi-FI".to_string(),
132        }
133    }
134
135    fn enrolment(min: f64, max: f64) -> RegistryEnrolment {
136        RegistryEnrolment {
137            id: "otm-enrolment".to_string(),
138            state: Some("ENROLLED".to_string()),
139            kind: Some("degree".to_string()),
140            course_unit_realisation_id: Some("hy-CUR-1".to_string()),
141            course_unit_realisation_name: Some(LocalizedName {
142                fi: Some("kurssi".to_string()),
143                sv: Some("kurs".to_string()),
144                en: Some("course".to_string()),
145            }),
146            activity_period: Some(DatePeriod {
147                start_date: NaiveDate::from_ymd_opt(2026, 1, 1),
148                end_date: NaiveDate::from_ymd_opt(2026, 12, 31),
149            }),
150            grade_scale_id: Some(PASS_FAIL_GRADE_SCALE_ID.to_string()),
151            credits: Some(CreditRange {
152                min: Some(min),
153                max: Some(max),
154            }),
155            study_right_validity_period: None,
156            enrolment_date_time: Some(Utc::now()),
157        }
158    }
159
160    static STUDENT_NUMBER: LazyLock<DbSecret> = LazyLock::new(|| DbSecret::new("012345678"));
161    static SISU_PERSON_ID: LazyLock<DbSecret> = LazyLock::new(|| DbSecret::new("hy-hlo-1"));
162
163    fn sources<'a>(enrolment: Option<&'a RegistryEnrolment>) -> PayloadSources<'a> {
164        PayloadSources {
165            student_number: &STUDENT_NUMBER,
166            sisu_person_id: Some(&SISU_PERSON_ID),
167            uh_course_code: Some("TKT10001"),
168            ects_credits: Some(5.0),
169            enrolment,
170        }
171    }
172
173    #[test]
174    fn the_payload_takes_its_scale_from_the_chosen_enrolment() {
175        let enrolment = enrolment(1.0, 5.0);
176        let built =
177            build_payload_snapshot(&completion(true, None), sources(Some(&enrolment))).unwrap();
178        assert_eq!(built.snapshot.grade_scale_id, PASS_FAIL_GRADE_SCALE_ID);
179        assert_eq!(built.snapshot.grade_id, "1");
180        assert_eq!(built.snapshot.credits, 5.0);
181        assert_eq!(built.clamped_credits_from, None);
182        assert_eq!(
183            built.snapshot.selected_enrolment_id.as_deref(),
184            Some("otm-enrolment")
185        );
186    }
187
188    #[test]
189    fn credits_are_clamped_into_the_enrolments_range_rather_than_refused() {
190        let enrolment = enrolment(1.0, 4.0);
191        let built =
192            build_payload_snapshot(&completion(true, None), sources(Some(&enrolment))).unwrap();
193        assert_eq!(built.snapshot.credits, 4.0);
194        assert_eq!(built.clamped_credits_from, Some(5.0));
195    }
196
197    #[test]
198    fn a_module_with_no_course_code_or_credits_is_a_configuration_problem() {
199        assert_eq!(
200            build_payload_snapshot(
201                &completion(true, None),
202                PayloadSources {
203                    uh_course_code: None,
204                    ..sources(None)
205                }
206            )
207            .err(),
208            Some(CreditRegistrationErrorCode::MissingUhCourseCode)
209        );
210        assert_eq!(
211            build_payload_snapshot(
212                &completion(true, None),
213                PayloadSources {
214                    uh_course_code: Some("  "),
215                    ..sources(None)
216                }
217            )
218            .err(),
219            Some(CreditRegistrationErrorCode::MissingUhCourseCode)
220        );
221        assert_eq!(
222            build_payload_snapshot(
223                &completion(true, None),
224                PayloadSources {
225                    ects_credits: None,
226                    ..sources(None)
227                }
228            )
229            .err(),
230            Some(CreditRegistrationErrorCode::MissingEctsCredits)
231        );
232    }
233
234    #[test]
235    fn a_failed_completion_never_becomes_a_payload() {
236        assert!(build_payload_snapshot(&completion(false, Some(0)), sources(None)).is_err());
237    }
238
239    #[test]
240    fn the_language_is_sent_as_a_two_letter_code() {
241        assert_eq!(attainment_language("fi-FI"), "fi");
242        assert_eq!(attainment_language("en"), "en");
243        assert_eq!(attainment_language("sv-SE"), "sv");
244    }
245
246    #[test]
247    fn a_graded_completion_keeps_its_number() {
248        let built = build_payload_snapshot(&completion(true, Some(4)), sources(None)).unwrap();
249        assert_eq!(built.snapshot.grade_scale_id, NUMERIC_GRADE_SCALE_ID);
250        assert_eq!(built.snapshot.grade_id, "4");
251    }
252}