headless_lms_models/credit_registrations/state.rs
1//! The ledger's states and error codes, the edges between states, and which rows a human may
2//! move by hand.
3
4use crate::prelude::*;
5use chrono::TimeDelta;
6use utoipa::ToSchema;
7
8/// What the pipeline does next with a ledger row.
9#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
10#[sqlx(type_name = "credit_registration_state", rename_all = "snake_case")]
11#[serde(rename_all = "snake_case")]
12pub enum CreditRegistrationState {
13 /// Waiting on a precondition: the completion or a linked student number. Which one is derived
14 /// at read time, never stored.
15 Pending,
16 ReadyToSubmit,
17 ResolvingEnrolment,
18 CheckingEnrolment,
19 NoUsableEnrolment,
20 Submitting,
21 SubmissionUncertain,
22 AwaitingVerification,
23 /// Sisu holds the assessment item attainment, and verify keeps polling for the course unit
24 /// attainment that makes the credit count.
25 PartiallyRegistered,
26 Registered,
27 Duplicate,
28 NotImproved,
29 Misregistered,
30 FailedRetryable,
31 FailedPermanent,
32 Blocked,
33 Cancelled,
34}
35
36impl CreditRegistrationState {
37 /// Every state, so a classification can be proven exhaustive at runtime too.
38 pub const ALL: [Self; 17] = [
39 Self::Pending,
40 Self::ReadyToSubmit,
41 Self::ResolvingEnrolment,
42 Self::CheckingEnrolment,
43 Self::NoUsableEnrolment,
44 Self::Submitting,
45 Self::SubmissionUncertain,
46 Self::AwaitingVerification,
47 Self::PartiallyRegistered,
48 Self::Registered,
49 Self::Duplicate,
50 Self::NotImproved,
51 Self::Misregistered,
52 Self::FailedRetryable,
53 Self::FailedPermanent,
54 Self::Blocked,
55 Self::Cancelled,
56 ];
57
58 /// States the pipeline never leaves on its own. `terminal_at` tracks membership, cleared on
59 /// exit so an admin retry becomes visible to the stuck queries again.
60 pub fn is_terminal(self) -> bool {
61 matches!(
62 self,
63 Self::Registered
64 | Self::Duplicate
65 | Self::NotImproved
66 | Self::FailedPermanent
67 | Self::Cancelled
68 )
69 }
70
71 /// Entry to one of these anchors the retry window in `first_failed_at`. Not the same question
72 /// as whether a move carries an error code.
73 pub fn is_failed_state(self) -> bool {
74 matches!(self, Self::FailedRetryable | Self::FailedPermanent)
75 }
76
77 /// Used for reporting and for the double-registration guard.
78 pub fn is_success(self) -> bool {
79 matches!(self, Self::Registered | Self::Duplicate | Self::NotImproved)
80 }
81
82 /// [`Self::is_success`]'s states, for binding as `= ANY($n::credit_registration_state[])` in
83 /// queries that would otherwise hand-retype the same set as a SQL literal. Order-independent;
84 /// kept in `is_success`'s own order for readability.
85 pub const SUCCESS_STATES: [Self; 3] = [Self::Registered, Self::Duplicate, Self::NotImproved];
86
87 /// [`Self::SUCCESS_STATES`] minus `Registered`: the credit exists but we did not put it there.
88 pub const OTHER_SUCCESS_STATES: [Self; 2] = [Self::Duplicate, Self::NotImproved];
89
90 /// The states of a row whose submission may be in Sisu with its outcome not yet known: a
91 /// request may be out, or its answer is still to be verified.
92 pub const IN_FLIGHT_STATES: [Self; 4] = [
93 Self::Submitting,
94 Self::SubmissionUncertain,
95 Self::AwaitingVerification,
96 Self::PartiallyRegistered,
97 ];
98
99 /// The two states a "failed" count means across the admin reports: a permanent submit failure
100 /// and a reversal the study registry made after the fact.
101 pub const HARD_FAILURE_STATES: [Self; 2] = [Self::FailedPermanent, Self::Misregistered];
102
103 /// The states the pipeline itself may move a row from `self` to, staying put excluded.
104 ///
105 /// The one place the shape of the machine is written down: every edge here is one a phase, the
106 /// precondition recompute or the grade-improvement materialiser actually takes, and
107 /// [`transition`](super::transition::transition) refuses anything else. Admin-only edges live in
108 /// [`ADMIN_ONLY_TARGETS`] instead, kept out of reach of a phase that could take one by mistake.
109 pub fn allowed_targets(self) -> &'static [Self] {
110 use CreditRegistrationState as S;
111 match self {
112 // The way out of the wait is every precondition being met, into the first enrolment
113 // check or straight to resolving; the other two edges are eligibility or the
114 // completion going away.
115 S::Pending => &[
116 S::ReadyToSubmit,
117 S::NoUsableEnrolment,
118 S::Blocked,
119 S::Cancelled,
120 ],
121 // `resolving_enrolment` is resolve-enrolments claiming the row and `failed_retryable`
122 // is it finding nothing to ask about; the rest is that phase's preflight and the
123 // preconditions.
124 S::ReadyToSubmit => &[
125 S::Pending,
126 S::ResolvingEnrolment,
127 S::FailedRetryable,
128 S::FailedPermanent,
129 S::Blocked,
130 S::Cancelled,
131 ],
132 // `ready_to_submit` only once the recovery grace has passed: while a resolve call is
133 // out, only that phase's own commit may move the row, or import could claim it before
134 // the enrolment is resolved.
135 S::ResolvingEnrolment => &[
136 S::Pending,
137 S::ReadyToSubmit,
138 S::CheckingEnrolment,
139 S::NoUsableEnrolment,
140 S::Duplicate,
141 S::FailedRetryable,
142 S::FailedPermanent,
143 S::Blocked,
144 S::Cancelled,
145 ],
146 // `submitting` is import's, and the only edge into it.
147 S::CheckingEnrolment => &[
148 S::Pending,
149 S::ReadyToSubmit,
150 S::Submitting,
151 S::Duplicate,
152 S::FailedPermanent,
153 S::Blocked,
154 S::Cancelled,
155 ],
156 // Checked where it stands, so resolve-enrolments' answers leave from here too.
157 S::NoUsableEnrolment => &[
158 S::Pending,
159 S::CheckingEnrolment,
160 S::Duplicate,
161 S::FailedRetryable,
162 S::FailedPermanent,
163 S::Blocked,
164 S::Cancelled,
165 ],
166 // A request is in flight: every edge out is an answer to it. Nothing leads back to a
167 // state import claims.
168 S::Submitting => &[
169 S::Pending,
170 S::NoUsableEnrolment,
171 S::AwaitingVerification,
172 S::SubmissionUncertain,
173 S::Registered,
174 S::Duplicate,
175 S::NotImproved,
176 S::FailedRetryable,
177 S::FailedPermanent,
178 ],
179 // The poller states: verify is the only path to `registered`. `failed_retryable` leads
180 // back to import, and only Suotar's own `notRegistered` may take it.
181 S::AwaitingVerification => &[
182 S::PartiallyRegistered,
183 S::Registered,
184 S::Duplicate,
185 S::Misregistered,
186 S::FailedRetryable,
187 ],
188 S::PartiallyRegistered => &[
189 S::Registered,
190 S::Duplicate,
191 S::Misregistered,
192 S::FailedRetryable,
193 ],
194 // `awaiting_verification` or `partially_registered` once verify finds evidence that the
195 // submission landed.
196 S::SubmissionUncertain => &[
197 S::AwaitingVerification,
198 S::PartiallyRegistered,
199 S::Registered,
200 S::Duplicate,
201 S::Misregistered,
202 S::FailedRetryable,
203 ],
204 // The backoff elapsing resumes the row at whichever state matches how far it had got.
205 S::FailedRetryable => &[
206 S::Pending,
207 S::ReadyToSubmit,
208 S::CheckingEnrolment,
209 S::AwaitingVerification,
210 S::FailedPermanent,
211 S::Blocked,
212 S::Cancelled,
213 ],
214 S::Blocked => &[
215 S::Pending,
216 S::ReadyToSubmit,
217 S::NoUsableEnrolment,
218 S::Cancelled,
219 ],
220 // Terminal, and `misregistered` waits for a human: the pipeline leaves all of these
221 // where they are.
222 S::Registered
223 | S::Duplicate
224 | S::NotImproved
225 | S::Misregistered
226 | S::FailedPermanent
227 | S::Cancelled => &[],
228 }
229 }
230
231 /// Whether a row that has been sent may be sent again from this state. The outcomes lead a
232 /// possibly landed import back towards `import` only on Suotar's `notRegistered`, so the
233 /// pipeline states here mean nothing landed.
234 fn may_resend_after_sending(self, strictness: ResubmissionStrictness) -> bool {
235 match self {
236 Self::Pending
237 | Self::ReadyToSubmit
238 | Self::ResolvingEnrolment
239 | Self::CheckingEnrolment
240 | Self::NoUsableEnrolment
241 | Self::FailedRetryable
242 | Self::FailedPermanent
243 | Self::Blocked => true,
244 // Sisu reversed the attainment, so a resend cannot count twice; Suotar asks for one.
245 Self::Misregistered => true,
246 // Whether the attainment landed is for someone looking at this one row to have checked.
247 Self::SubmissionUncertain => strictness == ResubmissionStrictness::Any,
248 // `cancelled` may be a hand cancellation of a row still awaiting verification.
249 Self::Submitting
250 | Self::AwaitingVerification
251 | Self::PartiallyRegistered
252 | Self::Registered
253 | Self::Duplicate
254 | Self::NotImproved
255 | Self::Cancelled => false,
256 }
257 }
258
259 /// What an attempt entering `self` does to the rows it was sent to replace; see
260 /// [`mark_pending_superseded`](super::mark_pending_superseded).
261 pub(super) fn pending_supersession_effect(self) -> PendingSupersessionEffect {
262 use PendingSupersessionEffect as Effect;
263 match self {
264 Self::Registered | Self::Duplicate => Effect::Complete,
265 // Frozen and still headed for Sisu, or already in the person-module slot. A
266 // `not_improved` row keeps the slot, so the row it was meant to replace stays out of
267 // it.
268 Self::CheckingEnrolment
269 | Self::Submitting
270 | Self::SubmissionUncertain
271 | Self::AwaitingVerification
272 | Self::PartiallyRegistered
273 | Self::FailedRetryable
274 | Self::NotImproved => Effect::Keep,
275 // Nothing of this attempt is in Sisu, and it goes through resolve-enrolments again,
276 // which weighs it afresh, before anything more is sent.
277 Self::Pending
278 | Self::ReadyToSubmit
279 | Self::ResolvingEnrolment
280 | Self::NoUsableEnrolment
281 | Self::Misregistered
282 | Self::FailedPermanent
283 | Self::Blocked
284 | Self::Cancelled => Effect::Abandon,
285 }
286 }
287
288 /// The states of the wait for an enrolment: `no_usable_enrolment`, and those a first check or a
289 /// retried lookup passes through on its way there. Entering any other clears the check
290 /// schedule.
291 pub fn keeps_enrolment_check_schedule(self) -> bool {
292 matches!(
293 self,
294 Self::NoUsableEnrolment
295 | Self::ReadyToSubmit
296 | Self::ResolvingEnrolment
297 | Self::FailedRetryable
298 )
299 }
300
301 /// How long a row entering this state waits before the pipeline may claim it again, when the
302 /// caller of [`transition`](super::transition::transition) names no time of its own. Zero leaves it
303 /// claimable at once.
304 ///
305 /// Only the states a claim query reads, or a precondition arm holds a row in, need a nonzero
306 /// one: a phase that forgot to defer would otherwise spin on the row, since every claim orders
307 /// by `next_attempt_at`. A caller with a real backoff to apply passes it and overrides this.
308 pub(super) fn default_attempt_delay(self) -> TimeDelta {
309 use crate::library::credit_registration::backoff::{
310 SUBMIT_BASE_BACKOFF, UNCERTAIN_RECHECK, VERIFY_WINDOW_INTERVAL,
311 };
312 use crate::library::credit_registration::enrolment_check_schedule::REGISTRY_LAG;
313 match self {
314 Self::AwaitingVerification | Self::PartiallyRegistered => VERIFY_WINDOW_INTERVAL,
315 Self::SubmissionUncertain => UNCERTAIN_RECHECK,
316 Self::NoUsableEnrolment => REGISTRY_LAG,
317 Self::FailedRetryable => SUBMIT_BASE_BACKOFF,
318 _ => TimeDelta::zero(),
319 }
320 }
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub(super) enum PendingSupersessionEffect {
325 Keep,
326 /// The replaced rows become superseded by this one.
327 Complete,
328 /// The replaced rows are the live credit again.
329 Abandon,
330}
331
332/// What decides whether a row may be moved by hand, read off whichever row type the caller has.
333#[derive(Debug, Clone, Copy, PartialEq)]
334pub struct ResubmissionFacts {
335 pub state: CreditRegistrationState,
336 /// A later attempt replaced this one.
337 pub is_superseded: bool,
338 pub error_code: Option<CreditRegistrationErrorCode>,
339 pub resubmit_not_before: Option<DateTime<Utc>>,
340 pub submitted_at: Option<DateTime<Utc>>,
341}
342
343impl ResubmissionFacts {
344 /// Whether the row may move back to `ready_to_submit`, and why not if it may not.
345 ///
346 /// One precedence shared by the teacher-facing retry and the admin ledger's hand transitions;
347 /// `strictness` is how far outside a failure a caller may move a row from. A row that has been
348 /// sent is refused at every strictness unless its state says the submission cannot count twice,
349 /// so `awaiting_verification` -> `cancelled` -> `ready_to_submit` cannot launder a resend.
350 pub fn resubmission_refusal(
351 &self,
352 strictness: ResubmissionStrictness,
353 ) -> Option<ResubmissionRefusal> {
354 use CreditRegistrationState as State;
355 let state = self.state;
356 let now = Utc::now();
357 if self.is_superseded {
358 return Some(ResubmissionRefusal::Superseded);
359 }
360 if state.is_success() {
361 return Some(ResubmissionRefusal::AlreadySucceeded);
362 }
363 if matches!(
364 state,
365 State::Submitting | State::AwaitingVerification | State::PartiallyRegistered
366 ) {
367 return Some(ResubmissionRefusal::AlreadySubmitted);
368 }
369 if strictness != ResubmissionStrictness::Any && state == State::SubmissionUncertain {
370 return Some(ResubmissionRefusal::SubmissionUncertain);
371 }
372 if self.submitted_at.is_some() && !state.may_resend_after_sending(strictness) {
373 return Some(ResubmissionRefusal::AlreadySubmitted);
374 }
375 if strictness == ResubmissionStrictness::OnlyFailedPermanent
376 && state != State::FailedPermanent
377 {
378 return Some(ResubmissionRefusal::NotFailedPermanent);
379 }
380 if matches!(
381 state,
382 State::Pending
383 | State::Blocked
384 | State::ReadyToSubmit
385 | State::ResolvingEnrolment
386 | State::CheckingEnrolment
387 | State::NoUsableEnrolment
388 ) {
389 return Some(ResubmissionRefusal::StillInPipeline);
390 }
391 if self
392 .uncertain_pending_window_end()
393 .is_some_and(|window_end| now < window_end)
394 {
395 return Some(ResubmissionRefusal::SubmissionUncertainTooRecent);
396 }
397 if self
398 .resubmit_not_before
399 .is_some_and(|not_before| now < not_before)
400 {
401 return Some(ResubmissionRefusal::SubmissionPending);
402 }
403 None
404 }
405
406 /// Until when Suotar may still hold an uncertain submission as pending; `None` for a row in any
407 /// other state.
408 fn uncertain_pending_window_end(&self) -> Option<DateTime<Utc>> {
409 use crate::library::credit_registration::backoff::SUOTAR_PENDING_WINDOW;
410 if self.state != CreditRegistrationState::SubmissionUncertain {
411 return None;
412 }
413 self.submitted_at
414 .map(|submitted_at| submitted_at + SUOTAR_PENDING_WINDOW)
415 }
416
417 /// Whether the row may be sent again, how that send may go wrong, and for a refusal that only
418 /// waits on time, when it lifts.
419 pub fn resubmission_availability(
420 &self,
421 strictness: ResubmissionStrictness,
422 ) -> ResubmissionAvailability {
423 use crate::library::credit_registration::classification::is_repeatable_rejection;
424 use CreditRegistrationState as State;
425 match self.resubmission_refusal(strictness) {
426 Some(
427 refusal @ (ResubmissionRefusal::SubmissionUncertainTooRecent
428 | ResubmissionRefusal::SubmissionPending),
429 ) => ResubmissionAvailability::Refused {
430 refusal,
431 available_at: self
432 .uncertain_pending_window_end()
433 .max(self.resubmit_not_before),
434 },
435 Some(refusal) => ResubmissionAvailability::Refused {
436 refusal,
437 available_at: None,
438 },
439 None => ResubmissionAvailability::Allowed {
440 risk: match self.state {
441 State::SubmissionUncertain => ResubmissionRisk::PossibleDuplicate,
442 State::Misregistered => ResubmissionRisk::ReplacesReversedAttainment,
443 State::FailedPermanent
444 if self.error_code.is_some_and(is_repeatable_rejection) =>
445 {
446 ResubmissionRisk::LikelyRejectedAgain
447 }
448 _ => ResubmissionRisk::Normal,
449 },
450 },
451 }
452 }
453
454 /// Why cancelling the row by hand is refused, or `None` if it may go ahead.
455 ///
456 /// Refused while Sisu may be recording the submission: its request is in flight, or verify is
457 /// waiting for the attainment, so a cancellation would tell the student "not registering" about
458 /// credits that are arriving.
459 pub fn cancel_refusal(
460 &self,
461 strictness: ResubmissionStrictness,
462 ) -> Option<ResubmissionRefusal> {
463 use CreditRegistrationState as State;
464 if self.is_superseded {
465 return Some(ResubmissionRefusal::Superseded);
466 }
467 if self.state.is_success() {
468 return Some(ResubmissionRefusal::AlreadySucceeded);
469 }
470 match self.state {
471 State::Cancelled => Some(ResubmissionRefusal::AlreadyCancelled),
472 State::Submitting => Some(ResubmissionRefusal::AlreadySubmitted),
473 State::AwaitingVerification | State::PartiallyRegistered => {
474 Some(ResubmissionRefusal::AwaitingConfirmation)
475 }
476 State::SubmissionUncertain if strictness != ResubmissionStrictness::Any => {
477 Some(ResubmissionRefusal::SubmissionUncertain)
478 }
479 _ => None,
480 }
481 }
482
483 /// What making the row due now brings forward, or `None` where no phase acts on it sooner for
484 /// being due: a final state, a precondition wait, or a call already in flight.
485 pub fn check_now_target(&self) -> Option<CheckNowTarget> {
486 use CreditRegistrationState as State;
487 if self.is_superseded {
488 return None;
489 }
490 match self.state {
491 State::AwaitingVerification
492 | State::PartiallyRegistered
493 | State::SubmissionUncertain => Some(CheckNowTarget::Attainment),
494 State::NoUsableEnrolment => Some(CheckNowTarget::Enrolment),
495 State::FailedRetryable => Some(CheckNowTarget::NextAttempt),
496 _ => None,
497 }
498 }
499
500 /// Why checking the row now is refused, or `None` if it may go ahead.
501 pub fn check_now_refusal(&self) -> Option<ResubmissionRefusal> {
502 if self.is_superseded {
503 return Some(ResubmissionRefusal::Superseded);
504 }
505 match self.check_now_target() {
506 Some(_) => None,
507 None => Some(ResubmissionRefusal::NothingToCheck),
508 }
509 }
510
511 /// Why a hand transition of the row to `target` is refused, or `None` if it may go ahead.
512 ///
513 /// The safety half of the admin path, next to the structural half in [`ADMIN_ONLY_TARGETS`]: the
514 /// edge table says the move exists, this decides whether this row may take it.
515 pub fn admin_transition_refusal(
516 &self,
517 target: CreditRegistrationState,
518 strictness: ResubmissionStrictness,
519 ) -> Option<ResubmissionRefusal> {
520 match target {
521 CreditRegistrationState::ReadyToSubmit => self.resubmission_refusal(strictness),
522 CreditRegistrationState::Cancelled => self.cancel_refusal(strictness),
523 _ if self.is_superseded => Some(ResubmissionRefusal::Superseded),
524 _ if self.state.is_success() => Some(ResubmissionRefusal::AlreadySucceeded),
525 _ => None,
526 }
527 }
528
529 /// Every hand action's availability at once, for a surface that offers them.
530 pub fn hand_actions(&self, strictness: ResubmissionStrictness) -> HandActionAvailability {
531 HandActionAvailability {
532 resubmission: self.resubmission_availability(strictness),
533 cancel_refusal: self.cancel_refusal(strictness),
534 check_now: self.check_now_target(),
535 }
536 }
537}
538
539/// The edges only a hand transition may take, from any state
540/// [`ResubmissionFacts::admin_transition_refusal`] does not refuse: putting a row back on the
541/// pipeline, and writing one off.
542///
543/// Kept out of [`CreditRegistrationState::allowed_targets`] so no phase can take one by mistake.
544pub const ADMIN_ONLY_TARGETS: [CreditRegistrationState; 2] = [
545 CreditRegistrationState::ReadyToSubmit,
546 CreditRegistrationState::Cancelled,
547];
548
549/// How far outside a failure [`ResubmissionFacts::resubmission_refusal`] will still allow a row to
550/// move back to `ready_to_submit`.
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552pub enum ResubmissionStrictness {
553 /// The automatic teacher retry: only a row that failed for good may go back on the pipeline,
554 /// because a row this always refuses would otherwise occupy a slot of the bulk cap forever.
555 OnlyFailedPermanent,
556 /// An admin's bulk hand transition: [`Self::Any`] minus `submission_uncertain`, which
557 /// re-importing could put a second attainment on a real transcript over, so it needs a human
558 /// looking at that one row rather than a checkbox in a list.
559 AnyExceptSubmissionUncertain,
560 /// An admin's single-row hand transition: a human is already looking at this one row, so even
561 /// `submission_uncertain` may be resubmitted once Suotar no longer holds it as pending.
562 Any,
563}
564
565/// Why [`ResubmissionFacts`] refuses a hand action on a row.
566///
567/// Rendered by the teacher and admin surfaces, which decide from it which buttons a row gets, so it
568/// travels to them as it is rather than being re-mapped per surface.
569#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
570#[serde(rename_all = "snake_case")]
571pub enum ResubmissionRefusal {
572 /// A later attempt replaced this one; act on that.
573 Superseded,
574 /// The study registry already holds an outcome for this attempt, so there is nothing to submit
575 /// again.
576 AlreadySucceeded,
577 /// The submission may have landed, so only a human looking at this one row may move it.
578 SubmissionUncertain,
579 /// The submission may have landed, and Suotar may still hold it as pending, so a resend could
580 /// slip past its duplicate check. Lifts at [`SUOTAR_PENDING_WINDOW`] after sending.
581 ///
582 /// [`SUOTAR_PENDING_WINDOW`]: crate::library::credit_registration::backoff::SUOTAR_PENDING_WINDOW
583 SubmissionUncertainTooRecent,
584 /// Not a failure at all: [`ResubmissionStrictness::OnlyFailedPermanent`] only.
585 NotFailedPermanent,
586 /// The pipeline moves the row on by itself once what it waits for is met, so sending it again
587 /// changes nothing.
588 StillInPipeline,
589 /// Suotar still holds the earlier submission open, and may yet turn it into an attainment.
590 SubmissionPending,
591 /// Already sent to Suotar with no final answer on this row: acting again risks a second Sisu
592 /// attainment before the first is resolved.
593 AlreadySubmitted,
594 /// Cancelling only: verify is waiting for Sisu to record the attainment.
595 AwaitingConfirmation,
596 /// Cancelling only: the row is cancelled already.
597 AlreadyCancelled,
598 /// Checking now only: no phase looks the row up sooner for being due.
599 NothingToCheck,
600}
601
602/// How a resend [`ResubmissionFacts::resubmission_refusal`] allows may go wrong, which the admin
603/// surfaces warn about before it is confirmed.
604#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
605#[serde(rename_all = "snake_case")]
606pub enum ResubmissionRisk {
607 Normal,
608 /// The last send was rejected for a reason an unchanged send meets again.
609 LikelyRejectedAgain,
610 /// Sisu reversed the attainment we registered; the resend registers a new one.
611 ReplacesReversedAttainment,
612 /// Sisu may already hold this attainment, so a resend may register the credits twice.
613 PossibleDuplicate,
614}
615
616/// Whether a row may be sent again by hand.
617#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
618#[serde(rename_all = "snake_case", tag = "kind")]
619pub enum ResubmissionAvailability {
620 Allowed {
621 risk: ResubmissionRisk,
622 },
623 Refused {
624 refusal: ResubmissionRefusal,
625 /// When the refusal lifts by itself; `None` if waiting does not lift it.
626 available_at: Option<DateTime<Utc>>,
627 },
628}
629
630/// What checking a row now brings forward.
631#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
632#[serde(rename_all = "snake_case")]
633pub enum CheckNowTarget {
634 /// Asking Sisu whether it has recorded the submitted attainment. Never sends anything.
635 Attainment,
636 /// Looking up a usable enrolment; the attainment is sent if one is found.
637 Enrolment,
638 /// The retry a backoff is waiting out, which resumes the row where it stopped.
639 NextAttempt,
640}
641
642/// Which hand actions a row is offered, decided once on the server for every admin surface.
643/// Clearing the attention flag is refused only on a superseded row, so it is not in here.
644#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
645pub struct HandActionAvailability {
646 pub resubmission: ResubmissionAvailability,
647 /// Why cancelling is refused, or `None` if it may go ahead.
648 pub cancel_refusal: Option<ResubmissionRefusal>,
649 /// What checking now looks up, or `None` where it would do nothing.
650 pub check_now: Option<CheckNowTarget>,
651}
652
653/// Why a ledger row is where it is; `state` says what happens to it next.
654#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
655#[sqlx(
656 type_name = "credit_registration_error_code",
657 rename_all = "snake_case"
658)]
659#[serde(rename_all = "snake_case")]
660pub enum CreditRegistrationErrorCode {
661 PersonNotFound,
662 CourseCodeNotFound,
663 EnrolmentNotFound,
664 EnrolmentNotAccepted,
665 InvalidGradeForGradeScale,
666 GradeScaleMismatch,
667 CourseNotAllowed,
668 InvalidCredits,
669 StudyRightNotValid,
670 SisuValidationFailed,
671 SisuTimeout,
672 ServiceTemporarilyUnavailable,
673 Misregistered,
674 NotRegistered,
675 Unauthorized,
676 MalformedRequest,
677 TransportError,
678 UnexpectedResponse,
679 NoGradeScaleMapping,
680 MissingUhCourseCode,
681 MissingEctsCredits,
682 RetryWindowExpired,
683 Unknown,
684}
685
686impl CreditRegistrationErrorCode {
687 /// Every code, so the retryability classification can be proven total at runtime too.
688 pub const ALL: [Self; 23] = [
689 Self::PersonNotFound,
690 Self::CourseCodeNotFound,
691 Self::EnrolmentNotFound,
692 Self::EnrolmentNotAccepted,
693 Self::InvalidGradeForGradeScale,
694 Self::GradeScaleMismatch,
695 Self::CourseNotAllowed,
696 Self::InvalidCredits,
697 Self::StudyRightNotValid,
698 Self::SisuValidationFailed,
699 Self::SisuTimeout,
700 Self::ServiceTemporarilyUnavailable,
701 Self::Misregistered,
702 Self::NotRegistered,
703 Self::Unauthorized,
704 Self::MalformedRequest,
705 Self::TransportError,
706 Self::UnexpectedResponse,
707 Self::NoGradeScaleMapping,
708 Self::MissingUhCourseCode,
709 Self::MissingEctsCredits,
710 Self::RetryWindowExpired,
711 Self::Unknown,
712 ];
713}