1use headless_lms_models::credit_registration_events::{
12 CreditRegistrationEventKind, suotar_exchange_details,
13};
14use headless_lms_models::credit_registration_phase_state::PhaseRunOutcome;
15use headless_lms_models::credit_registrations::{
16 CreditRegistration, CreditRegistrationErrorCode, CreditRegistrationState, RequestPurpose,
17 Transition, claim_due, increment_submit_retry_count, request_item_id, set_payload_snapshot,
18 transition,
19};
20use headless_lms_models::library::credit_registration::classification::map_code;
21use headless_lms_models::library::credit_registration::enrolment_selection::{
22 EnrolmentCriteria, any_attained_by_person, attainment_for_course_unit, select_enrolment,
23};
24use headless_lms_models::library::credit_registration::grade_mapping::{
25 GradeComparison, GradeSource, compare_grades, map_grade,
26};
27use headless_lms_models::library::credit_registration::outcomes::{
28 missing_context_outcome, submit_error_outcome, unanswered_item_outcome,
29};
30use headless_lms_models::library::credit_registration::payload::{
31 PayloadSources, build_payload_snapshot,
32};
33use headless_lms_models::library::credit_registration::submission_context::{
34 SubmissionContext, get_submission_contexts,
35};
36use headless_lms_utils::error::util_error::UtilError;
37use headless_lms_utils::services::suotar::{
38 EnrolmentResolutionResult, ResolveEnrolmentRequestItem, SuotarBatchResponse, SuotarCallContext,
39 SuotarEndpoint, SuotarItemStatus, SuotarResponseItem,
40};
41use sqlx::PgConnection;
42
43use super::{
44 OutcomeEvent, PhaseContext, PhaseScope, Prepared, SuotarBatchPhase, apply_outcome,
45 apply_request_level_outcome, counts_as_failed, outcome_transition, row_facts,
46 run_suotar_batch_phase,
47};
48
49pub async fn run(ctx: &PhaseContext<'_>, scope: &PhaseScope) -> anyhow::Result<PhaseRunOutcome> {
50 run_suotar_batch_phase(&mut ResolveEnrolments, ctx, scope).await
51}
52
53struct ResolveEnrolments;
54
55impl SuotarBatchPhase for ResolveEnrolments {
56 type Row = (CreditRegistration, SubmissionContext);
59 type Item = ResolveEnrolmentRequestItem;
60 type Result = EnrolmentResolutionResult;
61
62 const ALL_TRANSIENT_ERROR: &'static str =
63 "Every item of the batch came back transiently unavailable.";
64
65 async fn prepare(
66 &mut self,
67 _ctx: &PhaseContext<'_>,
68 conn: &mut PgConnection,
69 scope: &PhaseScope,
70 ) -> anyhow::Result<Prepared<Self::Row, Self::Item>> {
71 let claimed = claim_due(
72 conn,
73 &[CreditRegistrationState::ReadyToSubmit],
74 scope,
75 SuotarEndpoint::ResolveEnrolments.max_batch_size() as i64,
76 )
77 .await?;
78 let ids: Vec<_> = claimed.iter().map(|row| row.id).collect();
79 let mut contexts = get_submission_contexts(conn, &ids).await?;
80
81 let mut prepared = Prepared::default();
82 for row in claimed {
83 let Some(context) = contexts.remove(&row.id) else {
84 warn!(
85 "Credit registration {} has no completion or module to submit for.",
86 row.id
87 );
88 let outcome = missing_context_outcome(&row_facts(&row));
89 if outcome.increment_submit_retry_count {
90 increment_submit_retry_count(conn, row.id).await?;
91 }
92 transition(
93 conn,
94 row.id,
95 &Transition {
96 event_message: Some(
97 "There is no completion or module to submit for.".to_string(),
98 ),
99 ..outcome_transition(&outcome, Some(row.state))
100 },
101 )
102 .await?;
103 prepared.decided += 1;
104 prepared.failed += 1;
105 continue;
106 };
107 match preflight(&context) {
108 Ok(item) => {
109 transition(
114 conn,
115 row.id,
116 &Transition::to(CreditRegistrationState::ResolvingEnrolment),
117 )
118 .await?;
119 let request = ResolveEnrolmentRequestItem {
120 request_item_id: request_item_id(&row, RequestPurpose::Submission),
121 student_number: item.student_number,
122 course_code: item.course_code,
123 };
124 prepared.sendable.push(((row, context), request));
125 }
126 Err(problem) => {
127 transition(conn, row.id, &problem.transition()).await?;
128 prepared.decided += 1;
129 prepared.failed += 1;
130 }
131 }
132 }
133 Ok(prepared)
134 }
135
136 fn registration((row, _): &Self::Row) -> &CreditRegistration {
137 row
138 }
139
140 fn request_item_id((row, _): &Self::Row) -> String {
141 request_item_id(row, RequestPurpose::Submission)
142 }
143
144 fn sent_student_number((_, context): &Self::Row) -> Option<&str> {
145 context.student_number.as_deref()
146 }
147
148 async fn send(
149 &self,
150 ctx: &PhaseContext<'_>,
151 rows: &[Self::Row],
152 items: Vec<Self::Item>,
153 ) -> Result<SuotarBatchResponse<Self::Result>, UtilError> {
154 ctx.suotar_client
155 .resolve_enrolments(
156 SuotarCallContext::new(
157 ctx.worker_name(super::CreditRegistrationPhase::ResolveEnrolments),
158 )
159 .for_registrations(rows.iter().map(|(row, _)| row.id).collect()),
160 items,
161 )
162 .await
163 }
164
165 async fn apply(
166 &self,
167 conn: &mut PgConnection,
168 (row, context): &Self::Row,
169 item: Option<&SuotarResponseItem<Self::Result>>,
170 event: OutcomeEvent<'_>,
171 ) -> anyhow::Result<bool> {
172 apply_answer(conn, row, context, item, event).await
173 }
174
175 async fn apply_request_rejection(
176 &self,
177 conn: &mut PgConnection,
178 (row, _): &Self::Row,
179 request: &serde_json::Value,
180 error: &UtilError,
181 ) -> anyhow::Result<bool> {
182 apply_request_level_outcome(
183 conn,
184 SuotarEndpoint::ResolveEnrolments,
185 row,
186 request,
187 error,
188 CreditRegistrationState::ResolvingEnrolment,
189 )
190 .await
191 }
192}
193
194async fn apply_answer(
197 conn: &mut PgConnection,
198 row: &CreditRegistration,
199 context: &SubmissionContext,
200 item: Option<&SuotarResponseItem<EnrolmentResolutionResult>>,
201 event: OutcomeEvent<'_>,
202) -> anyhow::Result<bool> {
203 let facts = row_facts(row);
204 match item {
205 None => {
206 let outcome =
207 unanswered_item_outcome(SuotarEndpoint::ResolveEnrolments, row.state, &facts);
208 apply_outcome(
209 conn,
210 row,
211 &outcome,
212 OutcomeEvent {
213 message: Some("The study registry did not answer for this item."),
214 ..event
215 },
216 Some(CreditRegistrationState::ResolvingEnrolment),
217 )
218 .await?;
219 Ok(counts_as_failed(&outcome))
220 }
221 Some(item) if item.status == SuotarItemStatus::Error => {
222 let code = map_code(SuotarEndpoint::ResolveEnrolments, &item.code)
223 .unwrap_or(CreditRegistrationErrorCode::Unknown);
224 let outcome = submit_error_outcome(SuotarEndpoint::ResolveEnrolments, code, &facts);
225 apply_outcome(
226 conn,
227 row,
228 &outcome,
229 OutcomeEvent {
230 error_message: item.error.as_ref().map(|error| error.message.as_str()),
231 ..event
232 },
233 Some(CreditRegistrationState::ResolvingEnrolment),
234 )
235 .await?;
236 Ok(counts_as_failed(&outcome))
237 }
238 Some(item) => {
239 let no_enrolments = Vec::new();
240 let no_attainments = Vec::new();
241 let (enrolments, existing) = item
242 .result
243 .as_ref()
244 .map(|result| (&result.enrolments, &result.existing_attainments))
245 .unwrap_or((&no_enrolments, &no_attainments));
246 choose(conn, row, context, enrolments, existing, event).await
247 }
248 }
249}
250
251async fn choose(
253 conn: &mut PgConnection,
254 row: &CreditRegistration,
255 context: &SubmissionContext,
256 enrolments: &[headless_lms_utils::services::suotar::SuotarEnrolment],
257 existing: &[headless_lms_utils::services::suotar::ExistingAttainment],
258 event: OutcomeEvent<'_>,
259) -> anyhow::Result<bool> {
260 let details = suotar_exchange_details(event.request, event.response);
261 let already_attained = if enrolments.is_empty() {
264 any_attained_by_person(
267 existing,
268 context.sisu_person_id.as_deref().unwrap_or_default(),
269 )
270 .map(|attained| (attained, None))
271 } else {
272 enrolments.iter().find_map(|enrolment| {
273 attainment_for_course_unit(
274 existing,
275 &enrolment.course_unit_id,
276 &enrolment.assessment_item_id,
277 )
278 .map(|attained| (attained, Some(enrolment)))
279 })
280 };
281 let already_attained = already_attained.filter(|(attained, enrolment)| {
284 !improves_on(
285 attained,
286 context,
287 enrolment.map(|enrolment| enrolment.grade_scale_id.as_str()),
288 )
289 });
290 if let Some((attained, _)) = already_attained {
291 headless_lms_models::credit_registrations::set_sisu_attainment_if_unclaimed(
292 conn,
293 row.id,
294 &attained.id,
295 Some(&attained.attainment_type),
296 )
297 .await?;
298 transition(
299 conn,
300 row.id,
301 &Transition {
302 event_kind: CreditRegistrationEventKind::SuotarResponse,
303 event_message: Some(
304 "The study registry already holds an attainment for this course unit, so \
305 nothing was submitted."
306 .to_string(),
307 ),
308 suotar_api_call_id: event.suotar_api_call_id,
309 event_details: Some(details),
310 expected_from_state: Some(CreditRegistrationState::ResolvingEnrolment),
313 ..Transition::to(CreditRegistrationState::Duplicate)
314 },
315 )
316 .await?;
317 return Ok(false);
318 }
319
320 let credits = context.ects_credits.unwrap_or_default();
321 let attainment_date = headless_lms_models::library::credit_registration::payload::helsinki_date(
322 context.completion.completion_date,
323 );
324 let chosen = select_enrolment(
325 enrolments,
326 EnrolmentCriteria {
327 attainment_date,
328 credits,
329 configured_realisation_ids: &context.configured_realisation_ids,
330 },
331 );
332 let chosen = match chosen {
333 Ok(chosen) => chosen,
334 Err(reason) => {
335 let outcome = headless_lms_models::library::credit_registration::outcomes::Outcome {
336 error_code: Some(reason.error_code()),
337 ..submit_error_outcome(
338 SuotarEndpoint::ResolveEnrolments,
339 reason.error_code(),
340 &row_facts(row),
341 )
342 };
343 apply_outcome(
344 conn,
345 row,
346 &outcome,
347 OutcomeEvent {
348 message: Some(reason.message()),
349 ..event
350 },
351 Some(CreditRegistrationState::ResolvingEnrolment),
352 )
353 .await?;
354 return Ok(true);
355 }
356 };
357
358 let built = build_payload_snapshot(
359 &context.completion,
360 PayloadSources {
361 student_number: context.student_number.as_deref().unwrap_or_default(),
362 sisu_person_id: context.sisu_person_id.as_deref().unwrap_or_default(),
363 uh_course_code: context.uh_course_code.as_deref(),
364 ects_credits: context.ects_credits,
365 configured_grade_scale_id: context.configured_grade_scale_id.as_deref(),
366 enrolment: Some(chosen),
367 },
368 );
369 let built = match built {
370 Ok(built) => built,
371 Err(code) => {
372 apply_outcome(
373 conn,
374 row,
375 &submit_error_outcome(SuotarEndpoint::ResolveEnrolments, code, &row_facts(row)),
376 event,
377 Some(CreditRegistrationState::ResolvingEnrolment),
378 )
379 .await?;
380 return Ok(true);
381 }
382 };
383 set_payload_snapshot(conn, row.id, &built.snapshot).await?;
384 let clamped = built.clamped_credits_from.map(|from| {
385 format!(
386 "Credits adjusted from {from} to {} to fit the enrolment's range.",
387 built.snapshot.credits
388 )
389 });
390 transition(
393 conn,
394 row.id,
395 &Transition {
396 event_kind: CreditRegistrationEventKind::SuotarResponse,
397 event_message: clamped,
398 suotar_api_call_id: event.suotar_api_call_id,
399 event_details: Some(details),
400 expected_from_state: Some(CreditRegistrationState::ResolvingEnrolment),
401 ..Transition::to(CreditRegistrationState::CheckingEnrolment)
402 },
403 )
404 .await?;
405 Ok(false)
406}
407
408fn improves_on(
414 attained: &headless_lms_utils::services::suotar::ExistingAttainment,
415 context: &SubmissionContext,
416 enrolment_grade_scale_id: Option<&str>,
417) -> bool {
418 map_grade(GradeSource {
419 passed: context.completion.passed,
420 grade: context.completion.grade,
421 configured_grade_scale_id: context.configured_grade_scale_id.as_deref(),
422 enrolment_grade_scale_id,
423 })
424 .is_ok_and(|mapped| {
425 compare_grades(&attained.grade_scale_id, &attained.grade_id, &mapped)
426 == GradeComparison::Better
427 })
428}
429
430struct ResolveRequest {
431 student_number: String,
432 course_code: String,
433}
434
435enum Preflight {
438 NoStudentNumber,
439 Config(CreditRegistrationErrorCode),
440}
441
442impl Preflight {
443 fn transition(&self) -> Transition {
444 match self {
445 Self::NoStudentNumber => Transition {
446 event_message: Some(
447 "No verified student number is linked to the account.".to_string(),
448 ),
449 ..Transition::to(CreditRegistrationState::Pending)
450 },
451 Self::Config(code) => Transition {
452 error_code: Some(*code),
453 needs_admin_attention: Some(true),
454 event_message: Some(
455 "The module is not configured for credit registration.".to_string(),
456 ),
457 ..Transition::to(CreditRegistrationState::FailedPermanent)
458 },
459 }
460 }
461}
462
463fn preflight(context: &SubmissionContext) -> Result<ResolveRequest, Preflight> {
464 let student_number = context
465 .student_number
466 .clone()
467 .ok_or(Preflight::NoStudentNumber)?;
468 let course_code = context
469 .uh_course_code
470 .clone()
471 .filter(|code| !code.trim().is_empty())
472 .ok_or(Preflight::Config(
473 CreditRegistrationErrorCode::MissingUhCourseCode,
474 ))?;
475 if context.ects_credits.is_none() {
476 return Err(Preflight::Config(
477 CreditRegistrationErrorCode::MissingEctsCredits,
478 ));
479 }
480 Ok(ResolveRequest {
481 student_number,
482 course_code,
483 })
484}