Skip to main content

headless_lms_models/course_module_suotar_configurations/
configuration.rs

1//! The row itself, and the Courses tab's overview of every enabled module.
2
3use crate::credit_registrations::CreditRegistrationErrorCode;
4use crate::prelude::*;
5use utoipa::ToSchema;
6
7#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
8pub struct CourseModuleSuotarConfiguration {
9    pub id: Uuid,
10    pub created_at: DateTime<Utc>,
11    pub updated_at: DateTime<Utc>,
12    pub deleted_at: Option<DateTime<Utc>>,
13    pub course_module_id: Uuid,
14    pub paused_at: Option<DateTime<Utc>>,
15    pub paused_by_user_id: Option<Uuid>,
16    pub pause_reason: Option<String>,
17    pub config_checked_at: Option<DateTime<Utc>>,
18    /// `None` means never checked, which is not the same as a failed check.
19    pub course_code_allowed: Option<bool>,
20    pub config_check_message: Option<String>,
21    /// The course code `course_code_allowed` is a verdict on.
22    pub checked_course_code: Option<String>,
23    /// Suotar's reason for not accepting `checked_course_code`.
24    pub course_code_rejection: Option<String>,
25    pub last_listing_attempted_at: Option<DateTime<Utc>>,
26    pub last_listed_at: Option<DateTime<Utc>>,
27    pub last_listing_error: Option<CreditRegistrationErrorCode>,
28    pub consecutive_listing_failures: i32,
29    pub last_listed_person_count: Option<i32>,
30    pub last_already_linked_count: Option<i32>,
31    pub last_mailed_count: Option<i32>,
32    pub last_suppressed_by_dedup_count: Option<i32>,
33    pub last_suppressed_by_rate_cap_count: Option<i32>,
34    pub last_no_address_count: Option<i32>,
35}
36
37/// Whether the module already has a live configuration row. Lets a caller tell "nothing to store"
38/// apart from "the teacher cleared what was stored", which look the same in an edit payload.
39pub async fn exists(conn: &mut PgConnection, course_module_id: Uuid) -> ModelResult<bool> {
40    let res = sqlx::query_scalar!(
41        r#"
42SELECT EXISTS (
43    SELECT 1
44    FROM course_module_suotar_configurations
45    WHERE course_module_id = $1
46      AND deleted_at IS NULL
47  ) AS "exists!"
48        "#,
49        course_module_id,
50    )
51    .fetch_one(conn)
52    .await?;
53    Ok(res)
54}
55
56/// Gives the module a live configuration row, which the pause and config-check writers update.
57///
58/// Resurrects a soft-deleted row rather than inserting beside it: `ON CONFLICT` can only infer
59/// against `uq_course_module_suotar_configurations`, which is keyed on `course_module_id` alone.
60pub async fn ensure_exists(conn: &mut PgConnection, course_module_id: Uuid) -> ModelResult<()> {
61    sqlx::query!(
62        r#"
63INSERT INTO course_module_suotar_configurations (course_module_id)
64VALUES ($1) ON CONFLICT (course_module_id) DO
65UPDATE
66SET deleted_at = NULL
67        "#,
68        course_module_id,
69    )
70    .execute(conn)
71    .await?;
72    Ok(())
73}
74
75/// A Suotar-enabled module as the Courses tab lists it: what it is configured with, what the last
76/// check concluded, and how much work it has produced.
77///
78/// The stored verdict may be older than the configuration; `config_checked_at` is `None` for a
79/// module nothing has checked yet, which is not the same as one checked and found broken.
80#[derive(Debug, Clone, PartialEq)]
81pub struct SuotarModuleOverview {
82    pub course_module_id: Uuid,
83    pub course_id: Uuid,
84    pub course_name: String,
85    pub course_module_name: Option<String>,
86    pub uh_course_code: Option<String>,
87    pub ects_credits: Option<f32>,
88    /// Where a student with no usable enrolment is sent to enrol.
89    pub enrolment_link: Option<String>,
90    pub paused_at: Option<DateTime<Utc>>,
91    pub pause_reason: Option<String>,
92    pub config_checked_at: Option<DateTime<Utc>>,
93    pub course_code_allowed: Option<bool>,
94    pub config_check_message: Option<String>,
95    pub last_listed_at: Option<DateTime<Utc>>,
96    /// Every passed, ECTS-eligible completion on the module, whichever path owns it. Wider than
97    /// what `materialize` takes, which is only the ones carrying `register_credits_via_suotar`.
98    pub eligible_completion_count: i64,
99}
100
101/// Every Suotar-enabled module, one row each, ordered by course then module order.
102pub async fn get_module_overviews(
103    conn: &mut PgConnection,
104    limit: i64,
105) -> ModelResult<Vec<SuotarModuleOverview>> {
106    let res = sqlx::query_as!(
107        SuotarModuleOverview,
108        r#"
109SELECT cm.id AS "course_module_id!",
110  cm.course_id AS "course_id!",
111  c.name AS "course_name!",
112  cm.name AS course_module_name,
113  cm.uh_course_code,
114  cm.ects_credits,
115  NULLIF(TRIM(cm.completion_registration_link_override), '') AS "enrolment_link?",
116  conf.paused_at AS "paused_at?",
117  conf.pause_reason AS "pause_reason?",
118  conf.config_checked_at AS "config_checked_at?",
119  conf.course_code_allowed AS "course_code_allowed?",
120  conf.config_check_message AS "config_check_message?",
121  conf.last_listed_at AS "last_listed_at?",
122  (
123    SELECT COUNT(*)
124    FROM course_module_completions cmc
125    WHERE cmc.course_module_id = cm.id
126      AND cmc.deleted_at IS NULL
127      AND cmc.passed
128      AND cmc.eligible_for_ects
129  ) AS "eligible_completion_count!"
130FROM course_modules cm
131  JOIN courses c ON c.id = cm.course_id AND c.deleted_at IS NULL
132  LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cm.id
133  AND conf.deleted_at IS NULL
134WHERE cm.enable_credit_registration_via_suotar
135  AND cm.deleted_at IS NULL
136ORDER BY c.name,
137  cm.order_number
138LIMIT $1
139        "#,
140        limit,
141    )
142    .fetch_all(conn)
143    .await?;
144    Ok(res)
145}