headless_lms_credit_registration/workflow/
decision.rs1use chrono::{DateTime, Utc};
4use headless_lms_models::credit_registration_events::CreditRegistrationEventKind;
5use headless_lms_models::credit_registrations::{
6 self, CreditRegistrationState, PayloadSnapshot, Transition, Transitioned,
7 set_resubmit_not_before, set_sisu_attainment_if_unclaimed, set_submitted_attainment,
8};
9use headless_lms_models::library::credit_registration::enrolment_checks::{
10 self, EnrolmentCheckAnswer, record_enrolment_check,
11};
12use headless_lms_models::library::credit_registration::grade_mapping::MappedGrade;
13use headless_lms_models::library::credit_registration::outcomes::{
14 NextAttempt, Outcome, UnaskedMove,
15};
16use headless_lms_models::library::credit_registration::scrub::{
17 scrub_text, suotar_exchange_details,
18};
19use headless_lms_models::library::credit_registration::study_registry::RegistryAttainment;
20use headless_lms_models::verified_student_numbers;
21use secrecy::ExposeSecret;
22use sqlx::{Connection, PgConnection, Postgres, Transaction};
23use uuid::Uuid;
24
25use super::claim::ClaimedRegistration;
26use crate::error::CreditRegistrationResult;
27use crate::registry::{ExchangeAudit, StudentNumber, SubmittedAttainmentRef};
28
29pub(crate) struct Decision<'a> {
33 pub outcome: Outcome,
34 pub message: Option<String>,
36 pub row_error: Option<&'a str>,
38 pub pre_transition: PreTransitionChanges<'a>,
39 pub atomic: AtomicChanges<'a>,
40}
41
42impl<'a> Decision<'a> {
43 pub(crate) fn new(outcome: Outcome) -> Self {
44 Self {
45 outcome,
46 message: None,
47 row_error: None,
48 pre_transition: PreTransitionChanges::default(),
49 atomic: AtomicChanges::default(),
50 }
51 }
52
53 pub(crate) fn with_message(self, message: impl Into<String>) -> Self {
54 Self {
55 message: Some(message.into()),
56 ..self
57 }
58 }
59
60 pub(crate) fn with_row_error(self, row_error: Option<&'a str>) -> Self {
61 Self { row_error, ..self }
62 }
63
64 pub(crate) fn with_submitted_attainment(
66 mut self,
67 submission: Option<&'a SubmittedAttainmentRef>,
68 ) -> Self {
69 self.pre_transition.submitted_attainment = submission;
70 self
71 }
72
73 pub(crate) fn with_sisu_attainment(
75 mut self,
76 attainment: Option<&'a RegistryAttainment>,
77 ) -> Self {
78 self.pre_transition.sisu_attainment = attainment;
79 self
80 }
81
82 pub(crate) fn with_resubmit_not_before(
84 mut self,
85 resubmit_not_before: Option<DateTime<Utc>>,
86 ) -> Self {
87 self.pre_transition.resubmit_not_before = resubmit_not_before;
88 self
89 }
90
91 pub(crate) fn with_payload(mut self, payload: PayloadChange) -> Self {
92 self.atomic.payload = Some(payload);
93 self
94 }
95
96 pub(crate) fn with_enrolment_check(mut self, check: Option<EnrolmentCheckAnswer<'a>>) -> Self {
97 self.atomic.enrolment_check = check;
98 self
99 }
100}
101
102#[derive(Default)]
105pub(crate) struct PreTransitionChanges<'a> {
106 pub submitted_attainment: Option<&'a SubmittedAttainmentRef>,
108 pub sisu_attainment: Option<&'a RegistryAttainment>,
111 pub resubmit_not_before: Option<DateTime<Utc>>,
112}
113
114#[derive(Default)]
117pub(crate) struct AtomicChanges<'a> {
118 pub payload: Option<PayloadChange>,
119 pub enrolment_check: Option<EnrolmentCheckAnswer<'a>>,
121}
122
123#[derive(Debug)]
125pub(crate) enum PayloadChange {
126 Frozen {
129 snapshot: Box<PayloadSnapshot>,
130 supersedes: Vec<Uuid>,
131 },
132 Unsent { weighed_grade: Option<MappedGrade> },
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub(crate) enum Applied {
140 Written { is_failure: bool, is_waiting: bool },
143 MovedOn { found: CreditRegistrationState },
147}
148
149pub(crate) async fn write_decision(
152 conn: &mut PgConnection,
153 claim: &ClaimedRegistration,
154 decision: Decision<'_>,
155 audit: &ExchangeAudit,
156) -> CreditRegistrationResult<Applied> {
157 let transition = Transition {
158 error_message: decision.row_error.map(scrub_text),
159 event_kind: CreditRegistrationEventKind::SuotarResponse,
160 event_message: decision.message.clone(),
161 suotar_api_call_id: audit.call_id,
162 suotar_endpoint: Some(audit.endpoint),
163 suotar_requested_at: Some(audit.requested_at),
164 suotar_answered_at: Some(audit.answered_at),
165 suotar_answer: Some(audit.answer),
166 event_details: Some(suotar_exchange_details(
167 Some(&audit.request),
168 audit.response.as_ref(),
169 )),
170 request_item_id: Some(audit.request_item_id.clone()),
171 ..decision
172 .outcome
173 .transition(Some(claim.expected_state()), Utc::now())
174 };
175 write_outcome(
176 conn,
177 claim,
178 &decision,
179 audit.sent_student_number.as_ref(),
180 &transition,
181 )
182 .await
183}
184
185pub(crate) async fn write_decision_committing_if_written(
188 mut tx: Transaction<'_, Postgres>,
189 claim: &ClaimedRegistration,
190 decision: Decision<'_>,
191 audit: &ExchangeAudit,
192) -> CreditRegistrationResult<Applied> {
193 let applied = write_decision(&mut tx, claim, decision, audit).await?;
194 if matches!(applied, Applied::Written { .. }) {
195 tx.commit().await?;
196 }
197 Ok(applied)
198}
199
200pub(crate) async fn write_unasked_move(
203 conn: &mut PgConnection,
204 claim: &ClaimedRegistration,
205 unasked: UnaskedMove,
206) -> CreditRegistrationResult<Applied> {
207 let transition = unasked.transition(Some(claim.expected_state()), Utc::now());
208 write_outcome(
209 conn,
210 claim,
211 &Decision::new(unasked.outcome),
212 None,
213 &transition,
214 )
215 .await
216}
217
218async fn write_outcome(
220 conn: &mut PgConnection,
221 claim: &ClaimedRegistration,
222 decision: &Decision<'_>,
223 sent_student_number: Option<&StudentNumber>,
224 transition: &Transition,
225) -> CreditRegistrationResult<Applied> {
226 let registration = claim.registration();
227 let outcome = &decision.outcome;
228 let atomic = &decision.atomic;
229 write_before_transition(conn, registration.id, &decision.pre_transition).await?;
230 if outcome.drop_verified_student_number
233 && let Some(linked) =
234 verified_student_numbers::get_by_user_id(conn, registration.user_id).await?
235 && sent_student_number.map(StudentNumber::expose)
236 == Some(linked.student_number.expose_secret())
237 {
238 verified_student_numbers::soft_delete(conn, linked.id).await?;
239 }
240 let mut tx = conn.begin().await?;
241 if outcome.increment_submit_retry_count {
242 credit_registrations::increment_submit_retry_count(&mut tx, registration.id).await?;
243 }
244 write_payload_change(&mut tx, registration.id, atomic.payload.as_ref()).await?;
245 let written =
246 credit_registrations::transition_unless_moved_on(&mut tx, registration.id, transition)
247 .await?;
248 let after = match written {
249 Transitioned::Written(after) => *after,
250 Transitioned::MovedOn { found } => return Ok(Applied::MovedOn { found }),
251 };
252 debug!(
253 credit_registration_id = %after.id,
254 from_state = ?registration.state,
255 to_state = ?after.state,
256 next_attempt_at = %after.next_attempt_at,
257 "Credit registration transitioned"
258 );
259 if outcome.next == NextAttempt::NextEnrolmentRung {
260 enrolment_checks::schedule_next_check(&mut tx, registration.id).await?;
261 }
262 record_enrolment_check(&mut tx, atomic.enrolment_check.as_ref(), &after).await?;
263 tx.commit().await?;
264 if outcome.to_state == CreditRegistrationState::SubmissionUncertain
265 && registration.state != CreditRegistrationState::SubmissionUncertain
266 {
267 warn!(
268 credit_registration_id = %registration.id,
269 "Credit registration entered submission_uncertain; Sisu's outcome could not be confirmed"
270 );
271 }
272 Ok(Applied::Written {
273 is_failure: outcome.is_failure(),
274 is_waiting: outcome.is_waiting(),
275 })
276}
277
278async fn write_before_transition(
279 conn: &mut PgConnection,
280 id: Uuid,
281 changes: &PreTransitionChanges<'_>,
282) -> CreditRegistrationResult<()> {
283 if let Some(submission) = changes.submitted_attainment {
284 set_submitted_attainment(
285 conn,
286 id,
287 submission.id.as_str(),
288 submission.attainment_type.as_deref(),
289 )
290 .await?;
291 }
292 if let Some(attainment) = changes.sisu_attainment {
293 set_sisu_attainment_if_unclaimed(
294 conn,
295 id,
296 attainment.id.as_str(),
297 Some(attainment.attainment_type.as_str()),
298 )
299 .await?;
300 }
301 if let Some(resubmit_not_before) = changes.resubmit_not_before {
302 set_resubmit_not_before(conn, id, resubmit_not_before).await?;
303 }
304 Ok(())
305}
306
307async fn write_payload_change(
308 conn: &mut PgConnection,
309 id: Uuid,
310 payload: Option<&PayloadChange>,
311) -> CreditRegistrationResult<()> {
312 match payload {
313 None => {}
314 Some(PayloadChange::Frozen {
315 snapshot,
316 supersedes,
317 }) => {
318 for &replaced in supersedes {
319 credit_registrations::mark_pending_superseded(conn, replaced, id).await?;
320 }
321 credit_registrations::set_payload_snapshot(conn, id, snapshot).await?;
322 }
323 Some(PayloadChange::Unsent { weighed_grade }) => {
324 credit_registrations::prepare_unsent_duplicate(conn, id, weighed_grade.as_ref())
325 .await?;
326 }
327 }
328 Ok(())
329}