Skip to main content

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}