Skip to main content

headless_lms_models/library/credit_registration/
pending_reason.rs

1//! Why a `pending` ledger row is still pending.
2//!
3//! The ledger records only that a row is waiting, never what for: the answer changes the moment the
4//! student links a number, and a stored copy would be a cache to keep true. Every
5//! surface that names the blocker derives it here, from the same facts
6//! `preconditions::pending_moves` decides the row's next state from.
7
8use utoipa::ToSchema;
9
10use crate::prelude::*;
11
12/// What a `pending` row is waiting for.
13#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
14#[serde(rename_all = "snake_case")]
15pub enum CreditRegistrationPendingReason {
16    /// The completion is not registrable yet: a prerequisite module, or a suspected-cheating review.
17    Completion,
18    StudentNumber,
19    /// Suotar does not accept the module's course code; the row moves on once it does.
20    CourseCode,
21}
22
23/// The preconditions a submission waits on, as they stand for one ledger row.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct PendingPreconditions {
26    pub completion_eligible: bool,
27    pub has_verified_student_number: bool,
28    pub course_code_allowed: bool,
29}
30
31impl PendingPreconditions {
32    /// Nothing outstanding, which is what a row about to leave `pending` looks like.
33    pub const ALL_MET: Self = Self {
34        completion_eligible: true,
35        has_verified_student_number: true,
36        course_code_allowed: true,
37    };
38
39    /// The first unmet precondition, or `None` once all of them are met and the next precondition
40    /// tick moves the row on.
41    ///
42    /// The order is the recompute's own: it is what decides which single thing a student is asked
43    /// for, and asking for a student number before the completion is even registrable would be
44    /// asking early.
45    pub fn reason(self) -> Option<CreditRegistrationPendingReason> {
46        if !self.completion_eligible {
47            Some(CreditRegistrationPendingReason::Completion)
48        } else if !self.has_verified_student_number {
49            Some(CreditRegistrationPendingReason::StudentNumber)
50        } else if !self.course_code_allowed {
51            Some(CreditRegistrationPendingReason::CourseCode)
52        } else {
53            None
54        }
55    }
56}
57
58/// Live `pending` rows per blocker, for the admin dashboard and the account-linking page.
59#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Default, ToSchema)]
60pub struct PendingReasonCounts {
61    pub completion_count: i64,
62    pub student_number_count: i64,
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use CreditRegistrationPendingReason as Reason;
69
70    /// A student is asked for one thing at a time, in the order the pipeline needs them.
71    #[test]
72    fn the_first_unmet_precondition_is_the_one_reported() {
73        assert_eq!(PendingPreconditions::ALL_MET.reason(), None);
74        assert_eq!(
75            PendingPreconditions {
76                completion_eligible: false,
77                has_verified_student_number: false,
78                course_code_allowed: false,
79            }
80            .reason(),
81            Some(Reason::Completion)
82        );
83        assert_eq!(
84            PendingPreconditions {
85                has_verified_student_number: false,
86                ..PendingPreconditions::ALL_MET
87            }
88            .reason(),
89            Some(Reason::StudentNumber)
90        );
91        assert_eq!(
92            PendingPreconditions {
93                course_code_allowed: false,
94                ..PendingPreconditions::ALL_MET
95            }
96            .reason(),
97            Some(Reason::CourseCode)
98        );
99    }
100}