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