Skip to main content

headless_lms_models/course_module_suotar_configurations/
config_check.rs

1//! The configuration check: what it reads about each module with a course code, and what it
2//! concluded.
3
4use crate::prelude::*;
5
6/// Everything the per-module configuration check reads, gathered in one query so validating every
7/// module costs one pass rather than a fan-out per module.
8#[derive(Debug, Clone, PartialEq)]
9pub struct SuotarModuleConfigFacts {
10    pub course_module_id: Uuid,
11    pub course_id: Uuid,
12    pub uh_course_code: Option<String>,
13    pub ects_credits: Option<f32>,
14    /// Suotar's verdict on the current course code from the last check; `None` if there is none or
15    /// the code has changed since.
16    pub stored_course_code_allowed: Option<bool>,
17    pub stored_course_code_rejection: Option<String>,
18}
19
20/// The configuration facts of every module with a course code, optionally narrowed to one course. Paused
21/// modules are included: a paused module's configuration is exactly what an operator is about to
22/// fix.
23pub async fn get_config_facts_for_modules_with_course_code(
24    conn: &mut PgConnection,
25    course_id: Option<Uuid>,
26) -> ModelResult<Vec<SuotarModuleConfigFacts>> {
27    let res = sqlx::query_as!(
28        SuotarModuleConfigFacts,
29        r#"
30SELECT cm.id AS "course_module_id!",
31  cm.course_id AS "course_id!",
32  cm.uh_course_code,
33  cm.ects_credits,
34  CASE
35    WHEN c.checked_course_code = TRIM(cm.uh_course_code) THEN c.course_code_allowed
36  END AS "stored_course_code_allowed?",
37  CASE
38    WHEN c.checked_course_code = TRIM(cm.uh_course_code) THEN c.course_code_rejection
39  END AS "stored_course_code_rejection?"
40FROM course_modules cm
41  LEFT JOIN course_module_suotar_configurations c ON c.course_module_id = cm.id
42  AND c.deleted_at IS NULL
43WHERE cm.deleted_at IS NULL
44  AND TRIM(cm.uh_course_code) <> ''
45  AND cm.completions_can_be_registered_to_sisu
46  AND ($1::uuid IS NULL OR cm.course_id = $1)
47ORDER BY cm.course_id,
48  cm.order_number
49        "#,
50        course_id,
51    )
52    .fetch_all(conn)
53    .await?;
54    Ok(res)
55}
56
57/// Modules with a course code the last check found broken. A module nothing has checked yet is not counted:
58/// unknown is not a failure.
59///
60/// Counts the stored message, not the boolean, so the tab badge matches the Courses tab's own
61/// "misconfigured" tile. The boolean covers only the course code, while [`check_module_config`]
62/// also reports missing credits, a missing enrolment link and a double-enabled registration path.
63///
64/// [`check_module_config`]:
65/// crate::library::credit_registration::config_validation::check_module_config
66pub async fn count_modules_failing_config_check(conn: &mut PgConnection) -> ModelResult<i64> {
67    Ok(get_module_ids_failing_config_check(conn).await?.len() as i64)
68}
69
70/// The modules [`count_modules_failing_config_check`] counts.
71pub async fn get_module_ids_failing_config_check(
72    conn: &mut PgConnection,
73) -> ModelResult<Vec<Uuid>> {
74    let ids = sqlx::query_scalar!(
75        r#"
76SELECT conf.course_module_id
77FROM course_module_suotar_configurations conf
78  JOIN course_modules cm ON cm.id = conf.course_module_id AND cm.deleted_at IS NULL
79WHERE TRIM(cm.uh_course_code) <> ''
80  AND cm.completions_can_be_registered_to_sisu
81  AND conf.deleted_at IS NULL
82  AND conf.config_checked_at IS NOT NULL
83  AND conf.config_check_message IS NOT NULL
84        "#,
85    )
86    .fetch_all(conn)
87    .await?;
88    Ok(ids)
89}
90
91/// What one configuration check concluded. `None` means the check could not reach an answer, which
92/// the dashboard renders as "unknown" rather than as a failure.
93#[derive(Debug, Clone, PartialEq, Default)]
94pub struct SuotarConfigCheck {
95    pub course_code_allowed: Option<bool>,
96    /// The course code `course_code_allowed` is about; `None` along with it.
97    pub checked_course_code: Option<String>,
98    pub course_code_rejection: Option<String>,
99    /// Every problem found, in one line for the Courses tab. `None` means the module is fine.
100    pub message: Option<String>,
101}
102
103/// Stamps the check result on the module, creating the configuration row for a module that has
104/// none: a module with a course code and no configuration is itself one of the problems being reported.
105///
106/// Resurrects a soft-deleted row for the same reason [`ensure_exists`](super::ensure_exists) does:
107/// `ON CONFLICT` can only infer against `uq_course_module_suotar_configurations`, so an insert
108/// beside one is impossible.
109pub async fn record_config_check(
110    conn: &mut PgConnection,
111    course_module_id: Uuid,
112    check: &SuotarConfigCheck,
113) -> ModelResult<()> {
114    sqlx::query!(
115        r#"
116INSERT INTO course_module_suotar_configurations (
117    course_module_id,
118    config_checked_at,
119    course_code_allowed,
120    config_check_message,
121    checked_course_code,
122    course_code_rejection
123  )
124VALUES ($1, now(), $2, $3, $4, $5) ON CONFLICT (course_module_id) DO
125UPDATE
126SET config_checked_at = now(),
127  course_code_allowed = $2,
128  config_check_message = $3,
129  checked_course_code = $4,
130  course_code_rejection = $5,
131  deleted_at = NULL
132        "#,
133        course_module_id,
134        check.course_code_allowed,
135        check.message,
136        check.checked_course_code,
137        check.course_code_rejection,
138    )
139    .execute(conn)
140    .await?;
141    Ok(())
142}