Skip to main content

headless_lms_models/course_module_suotar_configurations/
config_check.rs

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