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    /// Sisu holds the assessment item attainment but not yet the course unit attainment.
54    pub is_partially_registered: bool,
55    /// Where a student with no usable enrolment is sent to enrol.
56    pub enrolment_link: Option<String>,
57    pub completion_eligible: bool,
58    pub has_verified_student_number: bool,
59    pub course_code_allowed: bool,
60}
61
62impl StudentCreditRegistration {
63    /// What a `pending` row is waiting on, which is what its student-facing status is derived from.
64    pub fn preconditions(&self) -> PendingPreconditions {
65        PendingPreconditions {
66            completion_eligible: self.completion_eligible,
67            has_verified_student_number: self.has_verified_student_number,
68            course_code_allowed: self.course_code_allowed,
69        }
70    }
71
72    /// Whether the row has settled on an enrolment it can actually be registered against, which is
73    /// the point past which the student's own answers about enrolling change nothing.
74    ///
75    /// Not `enrolment_resolved` on its own: an import the registry refused leaves the row in
76    /// `no_usable_enrolment` still holding the enrolment it tried, and nothing ever clears
77    /// `selected_enrolment_id`, so a student who has to go and enrol reads as resolved.
78    pub fn has_usable_enrolment(&self) -> bool {
79        self.enrolment_resolved && self.state != CreditRegistrationState::NoUsableEnrolment
80    }
81
82    /// See [`is_waiting_for_enrolment`].
83    pub fn is_waiting_for_enrolment(&self) -> bool {
84        is_waiting_for_enrolment(
85            self.state,
86            self.enrolment_check_anchor_at,
87            self.no_usable_enrolment_since,
88        )
89    }
90
91    /// Whether the row is parked with a check someone asked for still unanswered. The row stays in
92    /// `no_usable_enrolment` for its checks, so this is what tells the asker one is under way.
93    pub fn is_requested_check_unanswered(&self) -> bool {
94        self.state == CreditRegistrationState::NoUsableEnrolment
95            && self.enrolment_check_source.is_request()
96    }
97}
98
99/// Narrows [`get_student_facing_by_user_id`]; the default returns every row of the user's.
100#[derive(Debug, Default, Clone, Copy)]
101pub struct StudentRegistrationFilter {
102    pub course_module_id: Option<Uuid>,
103    pub course_id: Option<Uuid>,
104    /// Only rows the in-course re-enrol banner is owed: parked on a missing enrolment and not yet
105    /// dismissed.
106    pub enrolment_banner_due: bool,
107}
108
109/// The user's registrations as the student surfaces show them, newest completion first. Superseded
110/// attempts are included: the student is entitled to see an earlier attempt Sisu may still hold.
111pub async fn get_student_facing_by_user_id(
112    conn: &mut PgConnection,
113    user_id: Uuid,
114    filter: StudentRegistrationFilter,
115) -> ModelResult<Vec<StudentCreditRegistration>> {
116    let res = sqlx::query_as!(
117        StudentCreditRegistration,
118        r#"
119SELECT cr.id,
120  cr.course_id,
121  c.name AS course_name,
122  c.slug AS course_slug,
123  cr.course_module_id,
124  cm.name AS course_module_name,
125  cm.uh_course_code,
126  cm.ects_credits,
127  cr.course_module_completion_id,
128  cr.created_at,
129  cmc.completion_date,
130  cr.state,
131  cr.error_code AS "error_code?",
132  cr.next_attempt_at,
133  cr.registered_at,
134  cr.sisu_attainment_id,
135  cr.student_number,
136  cr.credits,
137  cr.grade_id,
138  cr.grade_scale_id,
139  cr.attempt_number,
140  cr.superseded_by_id,
141  cr.superseded_at,
142  cr.enrolment_checked_at,
143  cr.enrolment_check_requested_at,
144  cr.no_usable_enrolment_since,
145  cr.enrolment_check_anchor_at,
146  cr.enrolment_check_source,
147  cr.selected_enrolment_id IS NOT NULL AS "enrolment_resolved!",
148  COALESCE(
149    cr.selected_enrolment_realisation_name->>'fi',
150    cr.selected_enrolment_realisation_name->>'en',
151    cr.selected_enrolment_realisation_name->>'sv'
152  ) AS "enrolment_realisation_name?",
153  cr.submitted_at,
154  cr.partially_registered_at IS NOT NULL AS "is_partially_registered!",
155  NULLIF(TRIM(cm.completion_registration_link_override), '') AS "enrolment_link?",
156  p.completion_eligible AS "completion_eligible!",
157  p.has_verified_student_number AS "has_verified_student_number!",
158  p.course_code_allowed AS "course_code_allowed!"
159FROM credit_registrations cr
160  JOIN courses c ON c.id = cr.course_id AND c.deleted_at IS NULL
161  JOIN course_modules cm ON cm.id = cr.course_module_id AND cm.deleted_at IS NULL
162  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id AND cmc.deleted_at IS NULL
163  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
164WHERE cr.user_id = $1
165  AND cr.deleted_at IS NULL
166  AND ($2::uuid IS NULL OR cr.course_module_id = $2)
167  AND ($3::uuid IS NULL OR cr.course_id = $3)
168  AND (
169    NOT $4::boolean
170    OR (
171      cr.state = 'no_usable_enrolment'
172      AND cr.enrolment_banner_dismissed_at IS NULL
173    )
174  )
175ORDER BY cmc.completion_date DESC,
176  cr.attempt_number DESC
177        "#,
178        user_id,
179        filter.course_module_id,
180        filter.course_id,
181        filter.enrolment_banner_due,
182    )
183    .fetch_all(conn)
184    .await?;
185    Ok(res)
186}
187
188/// The student dismissed the in-course-material re-enrol banner for this registration.
189pub async fn dismiss_enrolment_banner(
190    conn: &mut PgConnection,
191    id: Uuid,
192    user_id: Uuid,
193) -> ModelResult<()> {
194    sqlx::query!(
195        r#"
196UPDATE credit_registrations
197SET enrolment_banner_dismissed_at = now()
198WHERE id = $1
199  AND user_id = $2
200  AND deleted_at IS NULL
201        "#,
202        id,
203        user_id,
204    )
205    .execute(conn)
206    .await?;
207    Ok(())
208}