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    pub course_code_count: i64,
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use CreditRegistrationPendingReason as Reason;
70
71    /// A student is asked for one thing at a time, in the order the pipeline needs them.
72    #[test]
73    fn the_first_unmet_precondition_is_the_one_reported() {
74        assert_eq!(PendingPreconditions::ALL_MET.reason(), None);
75        assert_eq!(
76            PendingPreconditions {
77                completion_eligible: false,
78                has_verified_student_number: false,
79                course_code_allowed: false,
80            }
81            .reason(),
82            Some(Reason::Completion)
83        );
84        assert_eq!(
85            PendingPreconditions {
86                has_verified_student_number: false,
87                ..PendingPreconditions::ALL_MET
88            }
89            .reason(),
90            Some(Reason::StudentNumber)
91        );
92        assert_eq!(
93            PendingPreconditions {
94                course_code_allowed: false,
95                ..PendingPreconditions::ALL_MET
96            }
97            .reason(),
98            Some(Reason::CourseCode)
99        );
100    }
101}