headless_lms_models/library/credit_registration/
payload.rs1use crate::credit_registrations::{CreditRegistrationErrorCode, PayloadSnapshot};
5use crate::prelude::*;
6
7use super::grade_mapping::{GradeSource, map_grade};
8use super::study_registry::RegistryEnrolment;
9
10#[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#[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#[derive(Debug, Clone)]
31pub struct BuiltPayload {
32 pub snapshot: PayloadSnapshot,
33 pub clamped_credits_from: Option<f32>,
35}
36
37pub fn build_payload_snapshot(
38 completion: &CompletionFacts,
39 sources: PayloadSources<'_>,
40) -> Result<BuiltPayload, CreditRegistrationErrorCode> {
41 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 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
106fn 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}