1use headless_lms_models::credit_registrations::{
5 CreditRegistrationState, LiveSuccessForModule, RecordedCredit,
6 get_recorded_credits_for_same_module, lock_live_successes_for_same_module,
7};
8use headless_lms_models::library::credit_registration::grade_mapping::{
9 improves_on_all, map_grade,
10};
11use headless_lms_models::library::credit_registration::outcomes::{Outcome, submit_error_outcome};
12use headless_lms_models::library::credit_registration::payload::{
13 BuiltPayload, PayloadSources, build_payload_snapshot,
14};
15use headless_lms_models::secret::DbSecret;
16use headless_lms_utils::prelude::Utc;
17use sqlx::PgConnection;
18use uuid::Uuid;
19
20use super::answer::{AnsweredLookup, EnrolmentLookupResult};
21use crate::error::CreditRegistrationResult;
22use crate::registry::{BatchRequest, EnrolmentLookup};
23use crate::workflow::{Decision, PayloadChange};
24
25pub(super) struct CompetingCredits {
29 registered_by_us: Vec<LiveSuccessForModule>,
30 recorded_locally: Vec<RecordedCredit>,
31}
32
33impl CompetingCredits {
34 pub(super) async fn load(
38 tx: &mut PgConnection,
39 lookup: &AnsweredLookup<'_>,
40 ) -> CreditRegistrationResult<Self> {
41 let row_id = lookup.claim().id();
42 let registered_by_us = match lookup.result() {
43 EnrolmentLookupResult::Listed { .. } => {
44 lock_live_successes_for_same_module(tx, row_id).await?
45 }
46 EnrolmentLookupResult::Refused { .. } => Vec::new(),
47 };
48 let recorded_locally = if lookup.result().is_enrolment_error() {
49 get_recorded_credits_for_same_module(tx, row_id).await?
50 } else {
51 Vec::new()
52 };
53 Ok(Self {
54 registered_by_us,
55 recorded_locally,
56 })
57 }
58
59 fn supersedes(&self) -> Vec<Uuid> {
61 self.registered_by_us
62 .iter()
63 .map(|replaced| replaced.id)
64 .collect()
65 }
66}
67
68pub(super) fn resolve<'a>(
72 lookup: &AnsweredLookup<'a>,
73 competing: &CompetingCredits,
74) -> Decision<'a> {
75 let ours = lookup.our_grade();
76 let registered_grades: Vec<_> = competing
77 .registered_by_us
78 .iter()
79 .map(|success| success.credit.held_grade())
80 .collect();
81 if !registered_grades.is_empty() && !improves_on_all(®istered_grades, ours) {
82 return unsent_duplicate(
83 lookup,
84 "A grade at least as good is already registered for this module from another \
85 attempt, so nothing was submitted.",
86 );
87 }
88 let recorded_grades: Vec<_> = competing
92 .recorded_locally
93 .iter()
94 .map(RecordedCredit::held_grade)
95 .collect();
96 if lookup.result().is_enrolment_error()
97 && !recorded_grades.is_empty()
98 && !improves_on_all(&recorded_grades, ours)
99 {
100 return unsent_duplicate(
101 lookup,
102 "The study registry has no usable enrolment, but our records already hold a credit at \
103 least as good for this module, so nothing was submitted.",
104 );
105 }
106 let facts = lookup.claim().facts(Utc::now());
107 let chosen = match lookup.result() {
108 EnrolmentLookupResult::Refused {
109 code,
110 error_message,
111 } => {
112 return Decision::new(submit_error_outcome(
113 EnrolmentLookup::OPERATION,
114 code,
115 &facts,
116 ))
117 .with_row_error(error_message);
118 }
119 EnrolmentLookupResult::Listed {
120 chosen: Err(reason),
121 ..
122 } => {
123 return Decision::new(submit_error_outcome(
124 EnrolmentLookup::OPERATION,
125 reason.error_code(),
126 &facts,
127 ))
128 .with_message(reason.message());
129 }
130 EnrolmentLookupResult::Listed {
131 chosen: Ok(chosen), ..
132 } => chosen,
133 };
134 let submission = lookup.submission();
135 let absent = DbSecret::new("");
136 let built = build_payload_snapshot(
137 &submission.completion,
138 PayloadSources {
139 student_number: submission.student_number.as_ref().unwrap_or(&absent),
140 sisu_person_id: submission.sisu_person_id.as_ref(),
141 uh_course_code: submission.uh_course_code.as_deref(),
142 ects_credits: submission.ects_credits,
143 enrolment: Some(chosen),
144 },
145 );
146 match built {
147 Ok(built) => freeze(built, competing.supersedes()),
148 Err(code) => Decision::new(submit_error_outcome(
149 EnrolmentLookup::OPERATION,
150 code,
151 &facts,
152 )),
153 }
154}
155
156pub(super) fn unsent_duplicate<'a>(
160 lookup: &AnsweredLookup<'a>,
161 message: &'static str,
162) -> Decision<'a> {
163 Decision::new(Outcome::to(CreditRegistrationState::Duplicate))
164 .with_message(message)
165 .with_payload(PayloadChange::Unsent {
166 weighed_grade: map_grade(lookup.our_grade()).ok(),
167 })
168}
169
170fn freeze(built: BuiltPayload, supersedes: Vec<Uuid>) -> Decision<'static> {
173 let message = freeze_message(&built, &supersedes);
174 let decision = Decision::new(Outcome::to(CreditRegistrationState::CheckingEnrolment))
175 .with_payload(PayloadChange::Frozen {
176 snapshot: Box::new(built.snapshot),
177 supersedes,
178 });
179 match message {
180 Some(message) => decision.with_message(message),
181 None => decision,
182 }
183}
184
185fn freeze_message(built: &BuiltPayload, supersedes: &[Uuid]) -> Option<String> {
188 let superseded_message = (!supersedes.is_empty()).then(|| {
189 format!(
190 "This attempt's grade {} beats the one registered from another attempt, which this \
191 one supersedes once it is registered.",
192 built.snapshot.grade_id
193 )
194 });
195 let clamped_message = built.clamped_credits_from.map(|from| {
196 format!(
197 "Credits adjusted from {from} to {} to fit the enrolment's range.",
198 built.snapshot.credits
199 )
200 });
201 [superseded_message, clamped_message]
202 .into_iter()
203 .flatten()
204 .reduce(|first, second| format!("{first} {second}"))
205}
206
207#[cfg(test)]
208mod tests {
209 use headless_lms_models::credit_registrations::CreditRegistrationErrorCode as Code;
210 use headless_lms_models::credit_registrations::CreditRegistrationState as State;
211 use headless_lms_models::credit_registrations::PayloadSnapshot;
212 use headless_lms_models::library::credit_registration::enrolment_selection::NoUsableEnrolment;
213 use headless_lms_models::library::credit_registration::payload::CompletionFacts;
214 use headless_lms_models::library::credit_registration::study_registry::{
215 RegistryAttainment, RegistryEnrolment,
216 };
217 use headless_lms_models::library::credit_registration::submission_context::SubmissionContext;
218
219 use super::super::Resolvable;
220 use super::super::fixtures::{context, enrolment, refused, resolvable};
221 use super::*;
222 use crate::test_fixtures::{attainment, date};
223
224 fn registered(grade_id: &str) -> LiveSuccessForModule {
225 LiveSuccessForModule {
226 id: Uuid::new_v4(),
227 credit: recorded(grade_id),
228 }
229 }
230
231 fn recorded(grade_id: &str) -> RecordedCredit {
232 RecordedCredit {
233 grade_scale_id: Some("sis-0-5".to_string()),
234 grade_id: Some(grade_id.to_string()),
235 completion_passed: true,
236 completion_grade: grade_id.parse().ok(),
237 }
238 }
239
240 fn competing(
241 registered_by_us: Vec<LiveSuccessForModule>,
242 recorded_locally: Vec<RecordedCredit>,
243 ) -> CompetingCredits {
244 CompetingCredits {
245 registered_by_us,
246 recorded_locally,
247 }
248 }
249
250 fn usable(listed: &[RegistryEnrolment]) -> EnrolmentLookupResult<'_> {
251 EnrolmentLookupResult::Listed {
252 enrolments: listed,
253 chosen: Ok(&listed[0]),
254 }
255 }
256
257 fn resolved<'a>(
258 resolvable: &'a Resolvable,
259 lookup_result: EnrolmentLookupResult<'a>,
260 competing: &CompetingCredits,
261 ) -> Decision<'a> {
262 resolve(
263 &AnsweredLookup::new(resolvable, lookup_result, &[]),
264 competing,
265 )
266 }
267
268 fn frozen(decision: Decision<'_>) -> (PayloadSnapshot, Vec<Uuid>) {
270 assert_eq!(decision.outcome.to_state, State::CheckingEnrolment);
271 match decision.atomic.payload {
272 Some(PayloadChange::Frozen {
273 snapshot,
274 supersedes,
275 }) => (*snapshot, supersedes),
276 other => panic!("not frozen: {other:?}"),
277 }
278 }
279
280 #[test]
281 fn a_usable_enrolment_freezes_the_payload_for_import() {
282 let listed = [enrolment("ENROLLED")];
283 let resolvable = resolvable(context(Some(4)));
284 let decision = resolved(&resolvable, usable(&listed), &competing(vec![], vec![]));
285 assert_eq!(decision.message, None);
286 let (snapshot, supersedes) = frozen(decision);
287 assert_eq!(snapshot.uh_course_code, "TKT10002");
288 assert_eq!(
289 snapshot.selected_enrolment_id.as_deref(),
290 Some("enrolment-1")
291 );
292 assert_eq!(snapshot.grade_scale_id, "sis-0-5");
293 assert_eq!(snapshot.grade_id, "4");
294 assert!(supersedes.is_empty());
295 }
296
297 #[test]
298 fn a_grade_no_better_than_another_registered_attempt_is_held_here() {
299 let listed = [enrolment("ENROLLED")];
300 let resolvable = resolvable(context(Some(4)));
301 for held in ["4", "5"] {
302 let decision = resolved(
303 &resolvable,
304 usable(&listed),
305 &competing(vec![registered(held)], vec![]),
306 );
307 assert_eq!(decision.outcome.to_state, State::Duplicate);
308 }
309 let (_, supersedes) = frozen(resolved(
310 &resolvable,
311 usable(&listed),
312 &competing(vec![registered("3")], vec![]),
313 ));
314 assert_eq!(supersedes.len(), 1);
315 }
316
317 #[test]
318 fn an_enrolment_error_is_held_here_when_our_records_hold_a_credit_as_good() {
319 let resolvable = resolvable(context(Some(4)));
320 let decision = resolved(
321 &resolvable,
322 refused(Code::EnrolmentNotFound),
323 &competing(vec![], vec![recorded("4")]),
324 );
325 assert_eq!(decision.outcome.to_state, State::Duplicate);
326 }
327
328 #[test]
329 fn an_enrolment_error_still_fails_a_grade_that_beats_our_records() {
330 let resolvable = resolvable(context(Some(5)));
331 let decision = resolved(
332 &resolvable,
333 refused(Code::EnrolmentNotFound),
334 &competing(vec![], vec![recorded("4")]),
335 );
336 assert_eq!(decision.outcome.to_state, State::NoUsableEnrolment);
337 assert_eq!(decision.row_error, Some("item error"));
338 }
339
340 #[test]
341 fn a_refused_lookup_fails_with_the_item_code_and_error() {
342 let resolvable = resolvable(context(Some(4)));
343 let decision = resolved(
344 &resolvable,
345 refused(Code::CourseNotAllowed),
346 &competing(vec![], vec![]),
347 );
348 assert_eq!(decision.outcome.to_state, State::FailedPermanent);
349 assert_eq!(decision.outcome.error_code, Some(Code::CourseNotAllowed));
350 assert_eq!(decision.row_error, Some("item error"));
351 assert!(decision.atomic.payload.is_none());
352 }
353
354 #[test]
355 fn no_usable_enrolment_parks_the_row_with_the_reason() {
356 let listed = [enrolment("PROCESSING")];
357 let resolvable = resolvable(context(Some(4)));
358 let decision = resolved(
359 &resolvable,
360 EnrolmentLookupResult::Listed {
361 enrolments: &listed,
362 chosen: Err(NoUsableEnrolment::NotAccepted),
363 },
364 &competing(vec![], vec![]),
365 );
366 assert_eq!(decision.outcome.to_state, State::NoUsableEnrolment);
367 assert_eq!(
368 decision.outcome.error_code,
369 Some(Code::EnrolmentNotAccepted)
370 );
371 assert_eq!(
372 decision.message.as_deref(),
373 Some(NoUsableEnrolment::NotAccepted.message())
374 );
375 assert!(decision.row_error.is_none());
376 }
377
378 #[test]
379 fn a_failed_completion_is_never_frozen() {
380 let listed = [enrolment("ENROLLED")];
381 let not_passed = SubmissionContext {
382 completion: CompletionFacts {
383 passed: false,
384 ..context(Some(4)).completion
385 },
386 ..context(Some(4))
387 };
388 let resolvable = resolvable(not_passed);
389 let decision = resolved(&resolvable, usable(&listed), &competing(vec![], vec![]));
390 assert_eq!(decision.outcome.to_state, State::FailedPermanent);
391 assert_eq!(decision.outcome.error_code, Some(Code::NoGradeScaleMapping));
392 }
393
394 #[test]
395 fn sisu_holding_an_attainment_as_good_settles_the_row() {
396 let listed = [enrolment("ENROLLED")];
397 let held = attainment("sis-0-5", "4", date(2026, 8, 1));
398 let existing = [held.clone()];
399 let as_good = resolvable(context(Some(4)));
400 assert_eq!(
401 AnsweredLookup::new(&as_good, usable(&listed), &existing).held_in_sisu(),
402 Some(&held)
403 );
404 let better = resolvable(context(Some(5)));
405 assert_eq!(
406 AnsweredLookup::new(&better, usable(&listed), &existing).held_in_sisu(),
407 None
408 );
409 }
410
411 #[test]
412 fn only_a_listed_answer_or_an_enrolment_error_says_what_sisu_holds() {
413 let existing = [attainment("sis-0-5", "5", date(2026, 8, 1))];
414 let resolvable = resolvable(context(Some(4)));
415 assert!(
416 AnsweredLookup::new(&resolvable, refused(Code::EnrolmentNotFound), &existing)
417 .held_in_sisu()
418 .is_some()
419 );
420 assert_eq!(
421 AnsweredLookup::new(&resolvable, refused(Code::CourseNotAllowed), &existing)
422 .held_in_sisu(),
423 None
424 );
425 }
426
427 #[test]
428 fn a_failed_attainment_in_sisu_holds_nothing() {
429 let listed = [enrolment("ENROLLED")];
430 let existing = [RegistryAttainment {
431 state: Some("FAILED".to_string()),
432 ..attainment("sis-0-5", "5", date(2026, 8, 1))
433 }];
434 let resolvable = resolvable(context(Some(4)));
435 assert_eq!(
436 AnsweredLookup::new(&resolvable, usable(&listed), &existing).held_in_sisu(),
437 None
438 );
439 }
440
441 #[test]
442 fn an_unsent_duplicate_keeps_the_grade_a_regrade_has_to_beat() {
443 let listed = [enrolment("ENROLLED")];
444 let resolvable = resolvable(context(Some(4)));
445 let lookup = AnsweredLookup::new(&resolvable, usable(&listed), &[]);
446 let decision = unsent_duplicate(&lookup, "held");
447 assert_eq!(decision.outcome.to_state, State::Duplicate);
448 assert_eq!(decision.message.as_deref(), Some("held"));
449 let Some(PayloadChange::Unsent {
450 weighed_grade: Some(grade),
451 }) = decision.atomic.payload
452 else {
453 panic!("no weighed grade");
454 };
455 assert_eq!(
456 (grade.grade_scale_id.as_str(), grade.grade_id.as_str()),
457 ("sis-0-5", "4")
458 );
459 }
460
461 #[test]
462 fn the_freeze_message_names_what_it_supersedes_and_adjusts() {
463 let listed = [enrolment("ENROLLED")];
464 let resolvable = resolvable(context(Some(4)));
465 let (snapshot, _) = frozen(resolved(
466 &resolvable,
467 usable(&listed),
468 &competing(vec![], vec![]),
469 ));
470 let built = BuiltPayload {
471 snapshot,
472 clamped_credits_from: None,
473 };
474 assert_eq!(freeze_message(&built, &[]), None);
475 assert!(
476 freeze_message(&built, &[Uuid::new_v4()])
477 .is_some_and(|message| message.contains("supersedes"))
478 );
479 let clamped = BuiltPayload {
480 clamped_credits_from: Some(6.0),
481 ..built
482 };
483 let message = freeze_message(&clamped, &[Uuid::new_v4()]).expect("message");
484 assert!(message.contains("supersedes") && message.contains("adjusted from 6"));
485 }
486}