Skip to main content

headless_lms_models/credit_registrations/
student_view.rs

1//! The ledger as the student surfaces show it.
2
3use super::registration::is_waiting_for_enrolment;
4use super::state::{CreditRegistrationErrorCode, CreditRegistrationState};
5use crate::library::credit_registration::PendingPreconditions;
6use crate::library::credit_registration::enrolment_check_schedule::EnrolmentCheckSource;
7use crate::prelude::*;
8
9/// One ledger row with the course, module and enrolment facts every student view needs, so a status
10/// page is one query rather than a fan-out per row.
11#[derive(Debug, Clone)]
12pub struct StudentCreditRegistration {
13    pub id: Uuid,
14    pub course_id: Uuid,
15    pub course_name: String,
16    pub course_slug: String,
17    pub course_module_id: Uuid,
18    pub course_module_name: Option<String>,
19    pub uh_course_code: Option<String>,
20    pub ects_credits: Option<f32>,
21    pub course_module_completion_id: Uuid,
22    pub created_at: DateTime<Utc>,
23    pub completion_date: DateTime<Utc>,
24    pub state: CreditRegistrationState,
25    pub error_code: Option<CreditRegistrationErrorCode>,
26    pub next_attempt_at: DateTime<Utc>,
27    pub registered_at: Option<DateTime<Utc>>,
28    pub sisu_attainment_id: Option<String>,
29    /// The number frozen on the row before it was sent, which is the one a student would check a
30    /// registration against; `None` until the row leaves `checking_enrolment`.
31    pub student_number: Option<DbSecret>,
32    pub credits: Option<f32>,
33    pub grade_id: Option<String>,
34    /// Needed to read `grade_id`: "1" is a pass on the pass/fail scale and a one out of five on the
35    /// numeric one.
36    pub grade_scale_id: Option<String>,
37    pub attempt_number: i32,
38    pub superseded_by_id: Option<Uuid>,
39    pub superseded_at: Option<DateTime<Utc>>,
40    pub enrolment_checked_at: Option<DateTime<Utc>>,
41    /// When a check was last asked for; with `enrolment_checked_at`, what the limit on asking again
42    /// counts from.
43    pub enrolment_check_requested_at: Option<DateTime<Utc>>,
44    pub no_usable_enrolment_since: Option<DateTime<Utc>>,
45    pub enrolment_check_anchor_at: Option<DateTime<Utc>>,
46    pub enrolment_check_source: EnrolmentCheckSource,
47    /// Whether an enrolment has been settled on. True without `enrolment_realisation_name` where
48    /// Suotar gave the realisation no name, so the step list ticks from this rather than the name.
49    pub enrolment_resolved: bool,
50    /// The name of the realisation we submitted against, not a Sisu id.
51    pub enrolment_realisation_name: Option<String>,
52    pub submitted_at: Option<DateTime<Utc>>,
53    /// Whether Sisu answered the latest send with an attainment, which verify then looks for.
54    pub has_submitted_attainment: bool,
55    /// When verify first saw Sisu holding the assessment item attainment without the course unit
56    /// attainment; `None` if it never has, or the row has been sent again since.
57    pub partially_registered_at: Option<DateTime<Utc>>,
58    /// The module's open university enrolment page.
59    pub enrolment_link: Option<String>,
60    pub completion_eligible: bool,
61    pub has_verified_student_number: bool,
62    pub course_code_allowed: bool,
63}
64
65impl StudentCreditRegistration {
66    /// What a `pending` row is waiting on, which is what its student-facing status is derived from.
67    pub fn preconditions(&self) -> PendingPreconditions {
68        PendingPreconditions {
69            completion_eligible: self.completion_eligible,
70            has_verified_student_number: self.has_verified_student_number,
71            course_code_allowed: self.course_code_allowed,
72        }
73    }
74
75    /// Whether the row has settled on an enrolment it can actually be registered against, which is
76    /// the point past which the student's own answers about enrolling change nothing.
77    ///
78    /// Not `enrolment_resolved` on its own: an import the registry refused leaves the row in
79    /// `no_usable_enrolment` still holding the enrolment it tried, and nothing ever clears
80    /// `selected_enrolment_id`, so a student who has to go and enrol reads as resolved.
81    pub fn has_usable_enrolment(&self) -> bool {
82        self.enrolment_resolved && self.state != CreditRegistrationState::NoUsableEnrolment
83    }
84
85    /// See [`is_waiting_for_enrolment`].
86    pub fn is_waiting_for_enrolment(&self) -> bool {
87        is_waiting_for_enrolment(
88            self.state,
89            self.enrolment_check_anchor_at,
90            self.no_usable_enrolment_since,
91        )
92    }
93
94    /// Whether the row is parked with a prompted check still unanswered. The row stays in
95    /// `no_usable_enrolment` for its checks, so this is what keeps a student whose enrolment the
96    /// roster just listed from being told to enrol.
97    pub fn is_prompted_check_unanswered(&self) -> bool {
98        self.state == CreditRegistrationState::NoUsableEnrolment
99            && self.enrolment_check_source.is_prompted()
100    }
101}
102
103/// Narrows [`get_student_facing_by_user_id`]; the default returns every row of the user's.
104#[derive(Debug, Default, Clone, Copy)]
105pub struct StudentRegistrationFilter {
106    pub course_module_id: Option<Uuid>,
107    pub course_id: Option<Uuid>,
108    /// Only rows the in-course re-enrol banner is owed: parked on a missing enrolment and not yet
109    /// dismissed.
110    pub enrolment_banner_due: bool,
111}
112
113/// The user's registrations as the student surfaces show them, newest completion first. Superseded
114/// attempts are included: the student is entitled to see an earlier attempt Sisu may still hold.
115pub async fn get_student_facing_by_user_id(
116    conn: &mut PgConnection,
117    user_id: Uuid,
118    filter: StudentRegistrationFilter,
119) -> ModelResult<Vec<StudentCreditRegistration>> {
120    let res = sqlx::query_as!(
121        StudentCreditRegistration,
122        r#"
123SELECT cr.id,
124  cr.course_id,
125  c.name AS course_name,
126  c.slug AS course_slug,
127  cr.course_module_id,
128  cm.name AS course_module_name,
129  cm.uh_course_code,
130  cm.ects_credits,
131  cr.course_module_completion_id,
132  cr.created_at,
133  cmc.completion_date,
134  cr.state,
135  cr.error_code AS "error_code?",
136  cr.next_attempt_at,
137  cr.registered_at,
138  cr.sisu_attainment_id,
139  cr.student_number,
140  cr.credits,
141  cr.grade_id,
142  cr.grade_scale_id,
143  cr.attempt_number,
144  cr.superseded_by_id,
145  cr.superseded_at,
146  cr.enrolment_checked_at,
147  cr.enrolment_check_requested_at,
148  cr.no_usable_enrolment_since,
149  cr.enrolment_check_anchor_at,
150  cr.enrolment_check_source,
151  cr.selected_enrolment_id IS NOT NULL AS "enrolment_resolved!",
152  COALESCE(
153    cr.selected_enrolment_realisation_name->>'fi',
154    cr.selected_enrolment_realisation_name->>'en',
155    cr.selected_enrolment_realisation_name->>'sv'
156  ) AS "enrolment_realisation_name?",
157  cr.submitted_at,
158  cr.submitted_attainment_id IS NOT NULL AS "has_submitted_attainment!",
159  cr.partially_registered_at,
160  NULLIF(TRIM(cm.completion_registration_link_override), '') AS "enrolment_link?",
161  p.completion_eligible AS "completion_eligible!",
162  p.has_verified_student_number AS "has_verified_student_number!",
163  p.course_code_allowed AS "course_code_allowed!"
164FROM credit_registrations cr
165  JOIN courses c ON c.id = cr.course_id AND c.deleted_at IS NULL
166  JOIN course_modules cm ON cm.id = cr.course_module_id AND cm.deleted_at IS NULL
167  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id AND cmc.deleted_at IS NULL
168  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
169WHERE cr.user_id = $1
170  AND cr.deleted_at IS NULL
171  AND ($2::uuid IS NULL OR cr.course_module_id = $2)
172  AND ($3::uuid IS NULL OR cr.course_id = $3)
173  AND (
174    NOT $4::boolean
175    OR (
176      cr.state = 'no_usable_enrolment'
177      AND cr.enrolment_banner_dismissed_at IS NULL
178    )
179  )
180ORDER BY cmc.completion_date DESC,
181  cr.attempt_number DESC
182        "#,
183        user_id,
184        filter.course_module_id,
185        filter.course_id,
186        filter.enrolment_banner_due,
187    )
188    .fetch_all(conn)
189    .await?;
190    Ok(res)
191}
192
193/// The student dismissed the in-course-material re-enrol banner for this registration.
194pub async fn dismiss_enrolment_banner(
195    conn: &mut PgConnection,
196    id: Uuid,
197    user_id: Uuid,
198) -> ModelResult<()> {
199    sqlx::query!(
200        r#"
201UPDATE credit_registrations
202SET enrolment_banner_dismissed_at = now()
203WHERE id = $1
204  AND user_id = $2
205  AND deleted_at IS NULL
206        "#,
207        id,
208        user_id,
209    )
210    .execute(conn)
211    .await?;
212    Ok(())
213}