headless_lms_credit_registration/use_cases/resolve_enrolments/enrolments/
mod.rs1mod answer;
9mod request;
10mod resolution;
11
12use headless_lms_models::credit_registrations::claim_due_for_resolve;
13use headless_lms_models::library::credit_registration::enrolment_checks::EnrolmentCheckAnswer;
14use headless_lms_models::library::credit_registration::enrolment_selection::{
15 EnrolmentCriteria, select_enrolment,
16};
17use headless_lms_models::library::credit_registration::outcomes::{
18 missing_context, unanswered_item_outcome,
19};
20use headless_lms_models::library::credit_registration::submission_context::{
21 SubmissionContext, get_submission_contexts,
22};
23use headless_lms_utils::prelude::Utc;
24use sqlx::{Connection, PgConnection};
25
26use super::{claim_for_lookup, hold_in_flight, keep_lookups_in_flight};
27use crate::error::CreditRegistrationResult;
28use crate::registry::{BatchRequest, EnrolmentAnswer, EnrolmentLookup, ExchangeAudit};
29use crate::use_cases::batch_flow::{BatchFlowContext, Prepared, RegistryBatchFlow};
30use crate::workflow::{
31 Applied, Claimed, ClaimedRegistration, Decision, RefusalPolicy, write_decision,
32 write_decision_committing_if_written, write_unasked_move,
33};
34
35use answer::{AnsweredLookup, EnrolmentLookupResult};
36use request::{Askable, Unaskable, enrolment_lookup};
37use resolution::{CompetingCredits, resolve, unsent_duplicate};
38
39pub(super) struct ResolveEnrolments;
40
41pub(super) struct LookupBasis {
45 submission: SubmissionContext,
46 criteria: EnrolmentCriteria,
47}
48
49pub(super) type Resolvable = Claimed<LookupBasis>;
51
52impl RegistryBatchFlow for ResolveEnrolments {
53 type Extra = LookupBasis;
54 type Request = EnrolmentLookup;
55
56 const ALL_UNAVAILABLE_ERROR: &'static str = "Every item of the batch came back unavailable.";
57 const REFUSAL: RefusalPolicy<LookupBasis> = RefusalPolicy::RequestLevel;
58
59 async fn claim(
60 ctx: &BatchFlowContext<'_>,
61 conn: &mut PgConnection,
62 limit: usize,
63 ) -> CreditRegistrationResult<Prepared<LookupBasis, EnrolmentLookup>> {
64 let claimed =
65 claim_due_for_resolve(conn, ctx.scope, i64::try_from(limit).unwrap_or(i64::MAX))
66 .await?;
67 let ids: Vec<_> = claimed.iter().map(|row| row.id).collect();
68 let mut contexts = get_submission_contexts(conn, &ids).await?;
69
70 let mut prepared = Prepared::new();
71 for row in claimed {
72 let Some(context) = contexts.remove(&row.id) else {
73 warn!(
74 credit_registration_id = %row.id,
75 "Credit registration has no completion or module to submit for"
76 );
77 let claim = ClaimedRegistration::left_in_place(row);
78 let applied =
79 write_unasked_move(conn, &claim, missing_context(&claim.facts(Utc::now())))
80 .await?;
81 prepared.record_applied(claim.id(), applied);
82 continue;
83 };
84 if context.student_number.is_some() && context.sisu_person_id.is_none() {
86 debug!(
87 credit_registration_id = %row.id,
88 "Leaving row claimable: waiting for a Sisu person id"
89 );
90 continue;
91 }
92 match enrolment_lookup(&context) {
93 Ok(Askable { request, criteria }) => {
94 let row = Claimed {
95 claim: claim_for_lookup(row),
96 extra: LookupBasis {
97 submission: context,
98 criteria,
99 },
100 };
101 prepared.send(row, request);
102 }
103 Err(problem) => {
104 match &problem {
105 Unaskable::Config(code) => {
106 warn!(
107 credit_registration_id = %row.id,
108 error_code = ?code,
109 "Course module is not configured for credit registration"
110 );
111 }
112 Unaskable::NoStudentNumber => {
113 debug!(
114 credit_registration_id = %row.id,
115 "No verified student number; sending back to pending"
116 );
117 }
118 }
119 let claim = ClaimedRegistration::left_in_place(row);
121 let applied = write_unasked_move(conn, &claim, problem.unasked_move()).await?;
122 prepared.record_applied(claim.id(), applied);
123 }
124 }
125 }
126 hold_in_flight(
127 conn,
128 prepared.sendable().iter().map(|entry| &entry.row.claim),
129 )
130 .await?;
131 Ok(prepared)
132 }
133
134 async fn apply_answer(
135 conn: &mut PgConnection,
136 resolvable: &Resolvable,
137 answer: Option<&EnrolmentAnswer>,
138 audit: &ExchangeAudit,
139 ) -> CreditRegistrationResult<Applied> {
140 let claim = &resolvable.claim;
141 let row = claim.registration();
142 let enrolments = answer
143 .map(|answer| answer.enrolments.as_slice())
144 .unwrap_or_default();
145 let chosen = select_enrolment(enrolments, resolvable.extra.criteria);
146 let check = row
147 .enrolment_check_anchor_at
148 .is_some()
149 .then(|| EnrolmentCheckAnswer {
150 checked: row,
151 usable_enrolment: chosen.ok(),
152 listed_enrolments: enrolments,
153 });
154 let Some(answer) = answer else {
155 let outcome = unanswered_item_outcome(
156 EnrolmentLookup::OPERATION,
157 row.state,
158 &claim.facts(Utc::now()),
159 );
160 let decision = Decision::new(outcome)
161 .with_message("Sisu did not answer for this item.")
162 .with_enrolment_check(check);
163 return write_decision(conn, claim, decision, audit).await;
164 };
165 let lookup = AnsweredLookup::new(
166 resolvable,
167 EnrolmentLookupResult::read(answer, chosen),
168 &answer.existing_attainments,
169 );
170 apply_enrolment_answer(conn, &lookup, check, audit).await
171 }
172
173 async fn keep_in_flight(
174 conn: &mut PgConnection,
175 rows: &[&Resolvable],
176 ) -> CreditRegistrationResult<()> {
177 keep_lookups_in_flight(conn, rows.iter().map(|row| &row.claim)).await
178 }
179}
180
181async fn apply_enrolment_answer(
188 conn: &mut PgConnection,
189 lookup: &AnsweredLookup<'_>,
190 check: Option<EnrolmentCheckAnswer<'_>>,
191 audit: &ExchangeAudit,
192) -> CreditRegistrationResult<Applied> {
193 if let Some(attained) = lookup.held_in_sisu() {
195 let decision = unsent_duplicate(
196 lookup,
197 "Sisu already has an equal or better grade for this course, so nothing was submitted.",
198 )
199 .with_sisu_attainment(Some(attained))
200 .with_enrolment_check(check);
201 return write_decision(conn, lookup.claim(), decision, audit).await;
202 }
203
204 let mut tx = conn.begin().await?;
207 let competing = CompetingCredits::load(&mut tx, lookup).await?;
208 let decision = resolve(lookup, &competing).with_enrolment_check(check);
209 write_decision_committing_if_written(tx, lookup.claim(), decision, audit).await
210}
211
212#[cfg(test)]
213mod fixtures {
214 use headless_lms_models::credit_registrations::{
215 CreditRegistrationErrorCode, CreditRegistrationState,
216 };
217 use headless_lms_models::library::credit_registration::payload::CompletionFacts;
218 use headless_lms_models::library::credit_registration::study_registry::{
219 CreditRange, RegistryEnrolment,
220 };
221 use headless_lms_models::secret::DbSecret;
222 use uuid::Uuid;
223
224 use super::answer::EnrolmentLookupResult;
225 use super::request::enrolment_lookup;
226 use super::{LookupBasis, Resolvable, SubmissionContext};
227 use crate::test_fixtures::{now, registration};
228 use crate::workflow::{Claimed, ClaimedRegistration};
229
230 pub(super) fn context(grade: Option<i32>) -> SubmissionContext {
231 SubmissionContext {
232 registration_id: Uuid::new_v4(),
233 student_number: Some(DbSecret::new("012345678")),
234 sisu_person_id: Some(DbSecret::new("person-1")),
235 uh_course_code: Some(" TKT10002 ".to_string()),
236 ects_credits: Some(5.0),
237 completion: CompletionFacts {
238 passed: true,
239 grade,
240 completion_date: now(),
241 completion_language: "en".to_string(),
242 },
243 }
244 }
245
246 pub(super) fn resolvable(context: SubmissionContext) -> Resolvable {
248 let Ok(askable) = enrolment_lookup(&context) else {
249 panic!("fixture context must be askable");
250 };
251 Claimed {
252 claim: ClaimedRegistration::left_in_place(registration(
253 CreditRegistrationState::ResolvingEnrolment,
254 )),
255 extra: LookupBasis {
256 submission: context,
257 criteria: askable.criteria,
258 },
259 }
260 }
261
262 pub(super) fn enrolment(state: &str) -> RegistryEnrolment {
263 RegistryEnrolment {
264 id: "enrolment-1".to_string(),
265 state: Some(state.to_string()),
266 kind: None,
267 course_unit_realisation_id: None,
268 course_unit_realisation_name: None,
269 activity_period: None,
270 grade_scale_id: Some("sis-0-5".to_string()),
271 credits: Some(CreditRange {
272 min: Some(5.0),
273 max: Some(5.0),
274 }),
275 study_right_validity_period: None,
276 enrolment_date_time: None,
277 }
278 }
279
280 pub(super) fn refused(code: CreditRegistrationErrorCode) -> EnrolmentLookupResult<'static> {
281 EnrolmentLookupResult::Refused {
282 code,
283 error_message: Some("item error"),
284 }
285 }
286}