Skip to main content

headless_lms_models/library/credit_registration/
timeline.rs

1//! Where a registration stands on the admin timeline. Derived from the ledger state and the
2//! preconditions rather than stored, so a new ledger state has to be placed on the timeline before
3//! it compiles, and every admin surface (lists, counts, the registration page) places it alike.
4
5use utoipa::ToSchema;
6
7use crate::credit_registrations::CreditRegistrationState;
8use crate::prelude::*;
9
10use super::pending_reason::{CreditRegistrationPendingReason, PendingPreconditions};
11
12/// A column of the admin timeline. Starting registration is not one: it is [`Engagement`] on the
13/// steps that wait on the student. Declared, and so ordered, in timeline order.
14#[derive(
15    Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, ToSchema,
16)]
17#[serde(rename_all = "snake_case")]
18pub enum TimelinePhase {
19    Course,
20    StudentNumber,
21    Registering,
22    Confirmation,
23    /// Endings that are not a registration of ours: already in Sisu, better grade there, cancelled,
24    /// no longer registrable.
25    Ended,
26}
27
28impl TimelinePhase {
29    /// The phase a row enters with `state`, or `None` for `pending`, whose phase depends on which
30    /// precondition it waits on. What `phase_started_at` is stamped by.
31    pub fn entered_with(state: CreditRegistrationState) -> Option<Self> {
32        (state != CreditRegistrationState::Pending)
33            .then(|| TimelineStep::of(state, PendingPreconditions::ALL_MET, false).phase())
34    }
35}
36
37/// One step of the admin timeline. The Registrations list, the overview counts and the status card
38/// all name a row by this. Declared, and so ordered, in timeline order.
39#[derive(
40    Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Type, ToSchema,
41)]
42#[sqlx(
43    type_name = "credit_registration_timeline_step",
44    rename_all = "snake_case"
45)]
46#[serde(rename_all = "snake_case")]
47pub enum TimelineStep {
48    /// A prerequisite module, or a suspected-cheating review.
49    CourseNotRegistrableYet,
50    WaitingForStudentNumber,
51    /// Sisu does not accept the module's course code yet. Not [`Self::LookingForEnrolment`]: this
52    /// waits on staff, with no known end.
53    HeldForCourseCode,
54    LookingForEnrolment,
55    WaitingForEnrolment,
56    Sending,
57    /// `submission_uncertain`: whether Sisu received it is unknown, and only a person may resend.
58    AnswerUnclear,
59    WaitingForAssessmentItem,
60    WaitingForCourseUnit,
61    Registered,
62    AlreadyInSisu,
63    BetterGradeInSisu,
64    RecordedWrongly,
65    /// `failed_permanent`, counted at the Registering step it stopped at.
66    NeedsAPerson,
67    NotRegistering,
68    NoLongerRegistrable,
69}
70
71impl TimelineStep {
72    /// Every step, in timeline order.
73    pub const ALL: [Self; 16] = [
74        Self::CourseNotRegistrableYet,
75        Self::WaitingForStudentNumber,
76        Self::HeldForCourseCode,
77        Self::LookingForEnrolment,
78        Self::WaitingForEnrolment,
79        Self::Sending,
80        Self::AnswerUnclear,
81        Self::WaitingForAssessmentItem,
82        Self::WaitingForCourseUnit,
83        Self::Registered,
84        Self::AlreadyInSisu,
85        Self::BetterGradeInSisu,
86        Self::RecordedWrongly,
87        Self::NeedsAPerson,
88        Self::NotRegistering,
89        Self::NoLongerRegistrable,
90    ];
91
92    /// The steps that wait on the student, and so the only ones [`Engagement`] is reported for.
93    pub const ENGAGEMENT_STEPS: [Self; 2] =
94        [Self::WaitingForStudentNumber, Self::WaitingForEnrolment];
95
96    /// `preconditions` is only read for `pending`; pass [`PendingPreconditions::ALL_MET`] only where
97    /// the row is known not to be pending. `enrolment_resolved` is whether the row has settled on
98    /// an enrolment, which only `failed_retryable` reads.
99    ///
100    /// Same inputs as
101    /// [`StudentFacingCreditRegistrationStatus::of`](super::StudentFacingCreditRegistrationStatus::of),
102    /// finer grained: that one is what the student is told.
103    pub fn of(
104        state: CreditRegistrationState,
105        preconditions: PendingPreconditions,
106        enrolment_resolved: bool,
107    ) -> Self {
108        use CreditRegistrationPendingReason as Reason;
109        use CreditRegistrationState as State;
110        match state {
111            State::Pending => match preconditions.reason() {
112                Some(Reason::Completion) => Self::CourseNotRegistrableYet,
113                Some(Reason::StudentNumber) => Self::WaitingForStudentNumber,
114                Some(Reason::CourseCode) => Self::HeldForCourseCode,
115                // Nothing is outstanding, so the next precondition tick moves the row on.
116                None => Self::LookingForEnrolment,
117            },
118            State::ReadyToSubmit | State::ResolvingEnrolment => Self::LookingForEnrolment,
119            State::FailedRetryable if !enrolment_resolved => Self::LookingForEnrolment,
120            State::NoUsableEnrolment => Self::WaitingForEnrolment,
121            State::CheckingEnrolment | State::Submitting | State::FailedRetryable => Self::Sending,
122            State::SubmissionUncertain => Self::AnswerUnclear,
123            State::AwaitingVerification => Self::WaitingForAssessmentItem,
124            State::PartiallyRegistered => Self::WaitingForCourseUnit,
125            State::Registered => Self::Registered,
126            State::Duplicate => Self::AlreadyInSisu,
127            State::NotImproved => Self::BetterGradeInSisu,
128            State::Misregistered => Self::RecordedWrongly,
129            State::FailedPermanent => Self::NeedsAPerson,
130            State::Cancelled => Self::NotRegistering,
131            State::Blocked => Self::NoLongerRegistrable,
132        }
133    }
134
135    /// The timeline column the step is counted under.
136    pub fn phase(self) -> TimelinePhase {
137        match self {
138            Self::CourseNotRegistrableYet => TimelinePhase::Course,
139            Self::WaitingForStudentNumber => TimelinePhase::StudentNumber,
140            Self::HeldForCourseCode
141            | Self::LookingForEnrolment
142            | Self::WaitingForEnrolment
143            | Self::Sending
144            | Self::AnswerUnclear
145            | Self::NeedsAPerson => TimelinePhase::Registering,
146            Self::WaitingForAssessmentItem
147            | Self::WaitingForCourseUnit
148            | Self::Registered
149            | Self::RecordedWrongly => TimelinePhase::Confirmation,
150            Self::AlreadyInSisu
151            | Self::BetterGradeInSisu
152            | Self::NotRegistering
153            | Self::NoLongerRegistrable => TimelinePhase::Ended,
154        }
155    }
156
157    /// Whether the step waits on the student, and so reports an [`Engagement`].
158    pub fn has_engagement(self) -> bool {
159        Self::ENGAGEMENT_STEPS.contains(&self)
160    }
161}
162
163/// A row's step, with its student's [`Engagement`] where the step waits on the student.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub struct TimelinePosition {
166    pub step: TimelineStep,
167    pub engagement: Option<Engagement>,
168}
169
170impl TimelinePosition {
171    /// See [`TimelineStep::of`] for the first three arguments.
172    pub fn of(
173        state: CreditRegistrationState,
174        preconditions: PendingPreconditions,
175        enrolment_resolved: bool,
176        engagement: Engagement,
177    ) -> Self {
178        let step = TimelineStep::of(state, preconditions, enrolment_resolved);
179        Self {
180            step,
181            engagement: step.has_engagement().then_some(engagement),
182        }
183    }
184}
185
186/// The `(state, completion_eligible, has_verified_student_number, course_code_allowed,
187/// enrolment_resolved)` combinations a set of steps covers, each with its step, as parallel arrays
188/// for a query to `UNNEST` and join against, as a filter or via [`Self::all`].
189///
190/// Enumerated from [`TimelineStep::of`] rather than restated as a SQL predicate, so a list filtered
191/// by a step returns exactly the rows that show that step.
192#[derive(Debug, Clone, Default, PartialEq)]
193pub struct StepMatch {
194    pub states: Vec<CreditRegistrationState>,
195    pub completion_eligible: Vec<bool>,
196    pub has_verified_student_number: Vec<bool>,
197    pub course_code_allowed: Vec<bool>,
198    pub enrolment_resolved: Vec<bool>,
199    pub steps: Vec<TimelineStep>,
200}
201
202impl StepMatch {
203    /// Empty for an empty `steps`, which every query reads as "do not narrow".
204    pub fn of(steps: &[TimelineStep]) -> Self {
205        let mut matched = Self::default();
206        let flags = [false, true];
207        for (
208            state,
209            completion_eligible,
210            has_verified_student_number,
211            course_code_allowed,
212            enrolment_resolved,
213        ) in itertools::iproduct!(CreditRegistrationState::ALL, flags, flags, flags, flags)
214        {
215            let preconditions = PendingPreconditions {
216                completion_eligible,
217                has_verified_student_number,
218                course_code_allowed,
219            };
220            let step = TimelineStep::of(state, preconditions, enrolment_resolved);
221            if steps.contains(&step) {
222                matched.states.push(state);
223                matched.completion_eligible.push(completion_eligible);
224                matched
225                    .has_verified_student_number
226                    .push(has_verified_student_number);
227                matched.course_code_allowed.push(course_code_allowed);
228                matched.enrolment_resolved.push(enrolment_resolved);
229                matched.steps.push(step);
230            }
231        }
232        matched
233    }
234
235    /// Every combination, for reading the step of any row.
236    pub fn all() -> Self {
237        Self::of(&TimelineStep::ALL)
238    }
239}
240
241/// What a student waiting on their own step has done on the registration page. Per completion, so
242/// every attempt of one completion shares it. Ordered from most to least engaged.
243#[derive(
244    Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Type, ToSchema,
245)]
246#[sqlx(
247    type_name = "credit_registration_engagement",
248    rename_all = "snake_case"
249)]
250#[serde(rename_all = "snake_case")]
251pub enum Engagement {
252    /// Their latest "I have enrolled" press stands. Only the student's own press: a teacher's or an
253    /// admin's check request is not this.
254    Pressed,
255    Visited,
256    NotStarted,
257}
258
259impl Engagement {
260    /// The engagement shown by the student's latest "I have enrolled" press and latest visit to the
261    /// registration page.
262    pub fn of(pressed_at: Option<DateTime<Utc>>, last_visited_at: Option<DateTime<Utc>>) -> Self {
263        if pressed_at.is_some() {
264            Self::Pressed
265        } else if last_visited_at.is_some() {
266            Self::Visited
267        } else {
268            Self::NotStarted
269        }
270    }
271}
272
273/// Who a registration waits on. The first line of the status card and of a Needs attention row.
274#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
275#[serde(rename_all = "snake_case")]
276pub enum WaitsOn {
277    Student,
278    Sisu,
279    Support,
280    CourseSetup,
281    /// Finished, or nothing anyone needs to do.
282    Nobody,
283}
284
285impl WaitsOn {
286    /// `needs_attention` is whether the row is on the Needs attention queue (dismissed rows are
287    /// not); `needs_course_setup` whether what stopped it is the module's configuration, which
288    /// takes precedence over support.
289    pub fn of(
290        step: TimelineStep,
291        engagement: Option<Engagement>,
292        needs_attention: bool,
293        needs_course_setup: bool,
294    ) -> Self {
295        if needs_course_setup {
296            return Self::CourseSetup;
297        }
298        if needs_attention {
299            return Self::Support;
300        }
301        match step {
302            TimelineStep::CourseNotRegistrableYet | TimelineStep::WaitingForEnrolment => {
303                Self::Student
304            }
305            TimelineStep::WaitingForStudentNumber => match engagement {
306                // The press made the code's enrolment list due, and the linking email follows it.
307                Some(Engagement::Pressed) => Self::Sisu,
308                Some(Engagement::Visited | Engagement::NotStarted) | None => Self::Student,
309            },
310            TimelineStep::HeldForCourseCode => Self::CourseSetup,
311            TimelineStep::LookingForEnrolment
312            | TimelineStep::Sending
313            | TimelineStep::WaitingForAssessmentItem
314            | TimelineStep::WaitingForCourseUnit => Self::Sisu,
315            TimelineStep::AnswerUnclear
316            | TimelineStep::RecordedWrongly
317            | TimelineStep::NeedsAPerson => Self::Support,
318            TimelineStep::Registered
319            | TimelineStep::AlreadyInSisu
320            | TimelineStep::BetterGradeInSisu
321            | TimelineStep::NotRegistering
322            | TimelineStep::NoLongerRegistrable => Self::Nobody,
323        }
324    }
325}