Skip to main content

headless_lms_models/library/credit_registration/
student_facing_status.rs

1//! What a student is told about one credit registration. Computed here rather than in the frontend
2//! so a new ledger state has to be classified before it compiles.
3
4use utoipa::ToSchema;
5
6use crate::credit_registrations::CreditRegistrationState;
7use crate::prelude::*;
8
9use super::pending_reason::{CreditRegistrationPendingReason, PendingPreconditions};
10
11/// The stage a student sees.
12#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum StudentFacingCreditRegistrationStatus {
15    WaitingForCompletion,
16    NeedsStudentNumber,
17    /// We are still working out which enrolment to register against. Not [`Self::NeedsEnrolment`],
18    /// which is the answer that there is none.
19    LookingForEnrolment,
20    NeedsEnrolment,
21    /// Held until staff fix the course's registration settings, with no known end: unlike
22    /// [`Self::LookingForEnrolment`], not a short wait.
23    WaitingForCourseSetup,
24    /// An enrolment is settled and the attainment is on its way to the study registry.
25    Sending,
26    WaitingForSisu,
27    Registered,
28    Failed,
29    /// Nothing is happening and nothing will, until the student changes something.
30    NotRegistering,
31}
32
33impl StudentFacingCreditRegistrationStatus {
34    /// `preconditions` is only read for `pending`, whose whole point is that the ledger does not
35    /// record which of them the row is waiting on. Pass [`PendingPreconditions::ALL_MET`] only where
36    /// the row is known not to be pending.
37    ///
38    /// `enrolment_resolved` is whether the row has settled on an enrolment, which only
39    /// `failed_retryable` reads: that state does not record which half of the work failed, and a
40    /// retry while we are still looking for an enrolment is not a retry of the sending.
41    pub fn of(
42        state: CreditRegistrationState,
43        preconditions: PendingPreconditions,
44        enrolment_resolved: bool,
45    ) -> Self {
46        use CreditRegistrationPendingReason as Reason;
47        use CreditRegistrationState as State;
48        match state {
49            State::Pending => match preconditions.reason() {
50                Some(Reason::Completion) => Self::WaitingForCompletion,
51                Some(Reason::StudentNumber) => Self::NeedsStudentNumber,
52                Some(Reason::CourseCode) => Self::WaitingForCourseSetup,
53                // Nothing is outstanding, so the next precondition tick moves the row on.
54                None => Self::LookingForEnrolment,
55            },
56            State::ReadyToSubmit | State::ResolvingEnrolment | State::CheckingEnrolment => {
57                Self::LookingForEnrolment
58            }
59            State::Submitting => Self::Sending,
60            State::FailedRetryable => {
61                if enrolment_resolved {
62                    Self::Sending
63                } else {
64                    Self::LookingForEnrolment
65                }
66            }
67            State::NoUsableEnrolment => Self::NeedsEnrolment,
68            State::SubmissionUncertain
69            | State::AwaitingVerification
70            | State::PartiallyRegistered => Self::WaitingForSisu,
71            // not_improved means Sisu holds an equal or better attainment, so the credit exists.
72            State::Registered | State::Duplicate | State::NotImproved => Self::Registered,
73            State::Misregistered | State::FailedPermanent => Self::Failed,
74            State::Blocked | State::Cancelled => Self::NotRegistering,
75        }
76    }
77
78    /// Whether the pipeline still moves this row on its own; the status page polls while it does.
79    pub fn is_moving(self) -> bool {
80        matches!(
81            self,
82            Self::LookingForEnrolment | Self::Sending | Self::WaitingForSisu
83        )
84    }
85}
86
87/// The `(state, completion_eligible, has_verified_student_number, course_code_allowed,
88/// enrolment_resolved)` combinations a set of stages covers, as parallel arrays for a query to
89/// `UNNEST` and join against.
90///
91/// Enumerated from [`StudentFacingCreditRegistrationStatus::of`] rather than restated as a SQL
92/// predicate: a roster filtered to "failed" must return exactly the rows whose own badge says
93/// failed, and a new ledger state must not be able to fall out of one side of that.
94#[derive(Debug, Clone, Default, PartialEq)]
95pub struct StageMatch {
96    pub states: Vec<CreditRegistrationState>,
97    pub completion_eligible: Vec<bool>,
98    pub has_verified_student_number: Vec<bool>,
99    pub course_code_allowed: Vec<bool>,
100    pub enrolment_resolved: Vec<bool>,
101}
102
103impl StageMatch {
104    /// Empty for an empty `stages`, which every query reads as "do not narrow".
105    pub fn of(stages: &[StudentFacingCreditRegistrationStatus]) -> Self {
106        let mut matched = Self::default();
107        if stages.is_empty() {
108            return matched;
109        }
110        let flags = [false, true];
111        for (
112            state,
113            completion_eligible,
114            has_verified_student_number,
115            course_code_allowed,
116            enrolment_resolved,
117        ) in itertools::iproduct!(CreditRegistrationState::ALL, flags, flags, flags, flags)
118        {
119            let preconditions = PendingPreconditions {
120                completion_eligible,
121                has_verified_student_number,
122                course_code_allowed,
123            };
124            if stages.contains(&StudentFacingCreditRegistrationStatus::of(
125                state,
126                preconditions,
127                enrolment_resolved,
128            )) {
129                matched.states.push(state);
130                matched.completion_eligible.push(completion_eligible);
131                matched
132                    .has_verified_student_number
133                    .push(has_verified_student_number);
134                matched.course_code_allowed.push(course_code_allowed);
135                matched.enrolment_resolved.push(enrolment_resolved);
136            }
137        }
138        matched
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use CreditRegistrationState as State;
146    use StudentFacingCreditRegistrationStatus as Status;
147
148    /// A row waiting on the student must not keep the page polling; nothing changes until they act.
149    #[test]
150    fn only_the_states_the_pipeline_still_owns_keep_the_page_polling() {
151        for reason in [
152            PendingPreconditions {
153                completion_eligible: false,
154                ..PendingPreconditions::ALL_MET
155            },
156            PendingPreconditions {
157                has_verified_student_number: false,
158                ..PendingPreconditions::ALL_MET
159            },
160            PendingPreconditions {
161                course_code_allowed: false,
162                ..PendingPreconditions::ALL_MET
163            },
164        ] {
165            assert!(
166                !Status::of(State::Pending, reason, false).is_moving(),
167                "{reason:?}"
168            );
169        }
170        let moving = [
171            // Nothing outstanding, so the recompute moves it on without the student doing anything.
172            State::Pending,
173            State::ReadyToSubmit,
174            State::ResolvingEnrolment,
175            State::CheckingEnrolment,
176            State::Submitting,
177            State::FailedRetryable,
178            State::SubmissionUncertain,
179            State::AwaitingVerification,
180            State::PartiallyRegistered,
181        ];
182        for state in CreditRegistrationState::ALL {
183            for enrolment_resolved in [false, true] {
184                assert_eq!(
185                    Status::of(state, PendingPreconditions::ALL_MET, enrolment_resolved)
186                        .is_moving(),
187                    moving.contains(&state),
188                    "{state:?} {enrolment_resolved}"
189                );
190            }
191        }
192    }
193
194    /// The student's question is "do I have the credits", so every success terminal answers yes.
195    #[test]
196    fn the_success_set_is_one_stage() {
197        for state in CreditRegistrationState::ALL {
198            if state.is_success() {
199                assert_eq!(
200                    Status::of(state, PendingPreconditions::ALL_MET, true),
201                    Status::Registered,
202                    "{state:?}"
203                );
204            }
205        }
206    }
207
208    #[test]
209    fn the_wire_spelling_is_snake_case() {
210        assert_eq!(
211            serde_json::to_value(Status::NeedsStudentNumber).unwrap(),
212            serde_json::json!("needs_student_number")
213        );
214    }
215}