Skip to main content

headless_lms_models/
course_module_suotar_configurations.rs

1use utoipa::ToSchema;
2
3use crate::credit_registrations::CreditRegistrationErrorCode;
4use crate::prelude::*;
5
6#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
7pub struct CourseModuleSuotarConfiguration {
8    pub id: Uuid,
9    pub created_at: DateTime<Utc>,
10    pub updated_at: DateTime<Utc>,
11    pub deleted_at: Option<DateTime<Utc>>,
12    pub course_module_id: Uuid,
13    pub open_university_product_id: Option<String>,
14    /// `None` means derive the grade scale from the completion.
15    pub grade_scale_id: Option<String>,
16    pub paused_at: Option<DateTime<Utc>>,
17    pub paused_by_user_id: Option<Uuid>,
18    pub pause_reason: Option<String>,
19    pub config_checked_at: Option<DateTime<Utc>>,
20    /// `None` means never checked, which is not the same as a failed check.
21    pub course_code_resolves: Option<bool>,
22    pub product_token_found: Option<bool>,
23    pub config_check_message: Option<String>,
24}
25
26/// The products whose access tokens are worth refreshing: those configured on an enabled, unpaused
27/// module, least recently attempted first. One product can back several modules, so each appears
28/// once.
29///
30/// Ordered by the last attempt rather than the last success, so a product whose refresh keeps
31/// failing rotates to the back instead of holding the head of the queue and starving everything
32/// behind it.
33pub async fn get_stalest_product_ids_for_enabled_modules(
34    conn: &mut PgConnection,
35    limit: i64,
36    course_id: Option<Uuid>,
37) -> ModelResult<Vec<String>> {
38    let res = sqlx::query_scalar!(
39        r#"
40SELECT c.open_university_product_id AS "open_university_product_id!"
41FROM course_module_suotar_configurations c
42  JOIN credit_registration_active_course_modules acm ON acm.course_module_id = c.course_module_id
43  LEFT JOIN open_university_product_access_tokens t ON t.open_university_product_id = c.open_university_product_id
44  AND t.deleted_at IS NULL
45WHERE c.open_university_product_id IS NOT NULL
46  AND c.deleted_at IS NULL
47  AND ($2::uuid IS NULL OR acm.course_id = $2)
48GROUP BY c.open_university_product_id,
49  t.last_refreshed_at,
50  t.last_refresh_failed_at
51ORDER BY GREATEST(t.last_refreshed_at, t.last_refresh_failed_at) ASC NULLS FIRST,
52  c.open_university_product_id
53LIMIT $1
54        "#,
55        limit,
56        course_id,
57    )
58    .fetch_all(conn)
59    .await?;
60    Ok(res)
61}
62
63/// Whether the module already has a live configuration row. Lets a caller tell "nothing to store"
64/// apart from "the teacher cleared what was stored", which look the same in an edit payload.
65pub async fn exists(conn: &mut PgConnection, course_module_id: Uuid) -> ModelResult<bool> {
66    let res = sqlx::query_scalar!(
67        r#"
68SELECT EXISTS (
69    SELECT 1
70    FROM course_module_suotar_configurations
71    WHERE course_module_id = $1
72      AND deleted_at IS NULL
73  ) AS "exists!"
74        "#,
75        course_module_id,
76    )
77    .fetch_one(conn)
78    .await?;
79    Ok(res)
80}
81
82/// Writes the module's Suotar configuration, creating the row if the module has none. The pause and
83/// config-check columns are left alone; their writers are separate.
84///
85/// Resurrects a soft-deleted row rather than inserting beside it: `ON CONFLICT` can only infer
86/// against `uq_course_module_suotar_configurations`, which is keyed on `course_module_id` alone.
87pub async fn upsert(
88    conn: &mut PgConnection,
89    course_module_id: Uuid,
90    open_university_product_id: Option<&str>,
91    grade_scale_id: Option<&str>,
92) -> ModelResult<CourseModuleSuotarConfiguration> {
93    let res = sqlx::query_as!(
94        CourseModuleSuotarConfiguration,
95        r#"
96INSERT INTO course_module_suotar_configurations (
97    course_module_id,
98    open_university_product_id,
99    grade_scale_id
100  )
101VALUES ($1, $2, $3) ON CONFLICT (course_module_id) DO
102UPDATE
103SET open_university_product_id = $2,
104  grade_scale_id = $3,
105  deleted_at = NULL
106RETURNING *
107        "#,
108        course_module_id,
109        open_university_product_id,
110        grade_scale_id,
111    )
112    .fetch_one(conn)
113    .await?;
114    Ok(res)
115}
116
117/// Everything the per-module configuration check reads, gathered in one query so validating every
118/// enabled module costs one pass rather than a fan-out per module.
119#[derive(Debug, Clone, PartialEq)]
120pub struct SuotarModuleConfigFacts {
121    pub course_module_id: Uuid,
122    pub course_id: Uuid,
123    pub uh_course_code: Option<String>,
124    pub ects_credits: Option<f32>,
125    pub open_university_product_id: Option<String>,
126    pub grade_scale_id: Option<String>,
127    /// The old pull path is on as well, which would register the same completion twice.
128    pub old_flow_also_enabled: bool,
129    /// A token we could actually build an enrolment link from, not merely a row.
130    pub product_token_found: bool,
131    pub active_realisation_count: i64,
132    /// At least one active realisation has been listed successfully, which proves the course code.
133    pub listed_successfully: bool,
134    pub course_code_not_found: bool,
135    /// A numeric grade scale override cannot map these, so the override and the module disagree.
136    pub has_passed_completions_without_a_grade: bool,
137}
138
139/// Every Suotar-enabled module's configuration facts, optionally narrowed to one course. Paused
140/// modules are included: a paused module's configuration is exactly what an operator is about to
141/// fix.
142pub async fn get_config_facts_for_enabled_modules(
143    conn: &mut PgConnection,
144    course_id: Option<Uuid>,
145) -> ModelResult<Vec<SuotarModuleConfigFacts>> {
146    let res = sqlx::query_as!(
147        SuotarModuleConfigFacts,
148        r#"
149SELECT cm.id AS "course_module_id!",
150  cm.course_id AS "course_id!",
151  cm.uh_course_code,
152  cm.ects_credits,
153  c.open_university_product_id AS "open_university_product_id?",
154  c.grade_scale_id AS "grade_scale_id?",
155  cm.enable_registering_completion_to_uh_open_university AS "old_flow_also_enabled!",
156  EXISTS (
157    SELECT 1
158    FROM open_university_product_access_tokens t
159    WHERE t.open_university_product_id = c.open_university_product_id
160      AND t.access_token IS NOT NULL
161      AND t.deleted_at IS NULL
162  ) AS "product_token_found!",
163  COALESCE(r.active_realisation_count, 0) AS "active_realisation_count!",
164  COALESCE(r.listed_successfully, FALSE) AS "listed_successfully!",
165  COALESCE(r.course_code_not_found, FALSE) AS "course_code_not_found!",
166  EXISTS (
167    SELECT 1
168    FROM course_module_completions cmc
169    WHERE cmc.course_module_id = cm.id
170      AND cmc.passed
171      AND cmc.grade IS NULL
172      AND cmc.deleted_at IS NULL
173  ) AS "has_passed_completions_without_a_grade!"
174FROM course_modules cm
175  LEFT JOIN course_module_suotar_configurations c ON c.course_module_id = cm.id
176  AND c.deleted_at IS NULL
177  LEFT JOIN LATERAL (
178    SELECT COUNT(*) AS active_realisation_count,
179      BOOL_OR(cmsr.last_listed_at IS NOT NULL) AS listed_successfully,
180      BOOL_OR(cmsr.last_listing_error = $2) AS course_code_not_found
181    FROM course_module_suotar_realisations cmsr
182    WHERE cmsr.course_module_id = cm.id
183      AND cmsr.active
184      AND cmsr.deleted_at IS NULL
185  ) r ON TRUE
186WHERE cm.enable_credit_registration_via_suotar
187  AND cm.deleted_at IS NULL
188  AND ($1::uuid IS NULL OR cm.course_id = $1)
189ORDER BY cm.course_id,
190  cm.order_number
191        "#,
192        course_id,
193        CreditRegistrationErrorCode::CourseCodeNotFound as CreditRegistrationErrorCode,
194    )
195    .fetch_all(conn)
196    .await?;
197    Ok(res)
198}
199
200/// A Suotar-enabled module as the Courses tab lists it: what it is configured with, what the last
201/// check concluded, and how much work it has produced.
202///
203/// The stored verdict may be older than the configuration; `config_checked_at` is `None` for a
204/// module nothing has checked yet, which is not the same as one checked and found broken.
205#[derive(Debug, Clone, PartialEq)]
206pub struct SuotarModuleOverview {
207    pub course_module_id: Uuid,
208    pub course_id: Uuid,
209    pub course_name: String,
210    pub course_module_name: Option<String>,
211    pub uh_course_code: Option<String>,
212    pub ects_credits: Option<f32>,
213    pub open_university_product_id: Option<String>,
214    pub grade_scale_id: Option<String>,
215    pub old_flow_also_enabled: bool,
216    pub paused_at: Option<DateTime<Utc>>,
217    pub pause_reason: Option<String>,
218    pub config_checked_at: Option<DateTime<Utc>>,
219    pub course_code_resolves: Option<bool>,
220    pub product_token_found: Option<bool>,
221    pub config_check_message: Option<String>,
222    pub active_realisation_count: i64,
223    pub last_listed_at: Option<DateTime<Utc>>,
224    /// Completions `materialize` would take. The ledger count beside it is what makes an unfinished
225    /// backfill visible.
226    pub eligible_completion_count: i64,
227}
228
229/// Every Suotar-enabled module, one row each, ordered by course then module order.
230pub async fn get_module_overviews(
231    conn: &mut PgConnection,
232    limit: i64,
233) -> ModelResult<Vec<SuotarModuleOverview>> {
234    let res = sqlx::query_as!(
235        SuotarModuleOverview,
236        r#"
237SELECT cm.id AS "course_module_id!",
238  cm.course_id AS "course_id!",
239  c.name AS "course_name!",
240  cm.name AS course_module_name,
241  cm.uh_course_code,
242  cm.ects_credits,
243  conf.open_university_product_id AS "open_university_product_id?",
244  conf.grade_scale_id AS "grade_scale_id?",
245  cm.enable_registering_completion_to_uh_open_university AS "old_flow_also_enabled!",
246  conf.paused_at AS "paused_at?",
247  conf.pause_reason AS "pause_reason?",
248  conf.config_checked_at AS "config_checked_at?",
249  conf.course_code_resolves AS "course_code_resolves?",
250  conf.product_token_found AS "product_token_found?",
251  conf.config_check_message AS "config_check_message?",
252  COALESCE(r.active_realisation_count, 0) AS "active_realisation_count!",
253  r.last_listed_at AS "last_listed_at?",
254  (
255    SELECT COUNT(*)
256    FROM course_module_completions cmc
257    WHERE cmc.course_module_id = cm.id
258      AND cmc.deleted_at IS NULL
259      AND cmc.passed
260      AND cmc.eligible_for_ects
261  ) AS "eligible_completion_count!"
262FROM course_modules cm
263  JOIN courses c ON c.id = cm.course_id
264  LEFT JOIN course_module_suotar_configurations conf ON conf.course_module_id = cm.id
265  AND conf.deleted_at IS NULL
266  LEFT JOIN LATERAL (
267    SELECT COUNT(*) AS active_realisation_count,
268      MAX(cmsr.last_listed_at) AS last_listed_at
269    FROM course_module_suotar_realisations cmsr
270    WHERE cmsr.course_module_id = cm.id
271      AND cmsr.active
272      AND cmsr.deleted_at IS NULL
273  ) r ON TRUE
274WHERE cm.enable_credit_registration_via_suotar
275  AND cm.deleted_at IS NULL
276ORDER BY c.name,
277  cm.order_number
278LIMIT $1
279        "#,
280        limit,
281    )
282    .fetch_all(conn)
283    .await?;
284    Ok(res)
285}
286
287/// Enabled modules the last check found broken. A module nothing has checked yet is not counted:
288/// unknown is not a failure.
289pub async fn count_modules_failing_config_check(conn: &mut PgConnection) -> ModelResult<i64> {
290    let count = sqlx::query_scalar!(
291        r#"
292SELECT COUNT(*) AS "count!"
293FROM course_module_suotar_configurations conf
294  JOIN course_modules cm ON cm.id = conf.course_module_id
295WHERE cm.enable_credit_registration_via_suotar
296  AND cm.deleted_at IS NULL
297  AND conf.deleted_at IS NULL
298  AND conf.config_checked_at IS NOT NULL
299  AND (
300    conf.course_code_resolves IS FALSE
301    OR conf.product_token_found IS FALSE
302  )
303        "#,
304    )
305    .fetch_one(conn)
306    .await?;
307    Ok(count)
308}
309
310/// What one configuration check concluded. `None` on either boolean means the check could not
311/// reach an answer, which the dashboard renders as "unknown" rather than as a failure.
312#[derive(Debug, Clone, PartialEq, Default)]
313pub struct SuotarConfigCheck {
314    pub course_code_resolves: Option<bool>,
315    pub product_token_found: Option<bool>,
316    /// Every problem found, in one line for the Courses tab. `None` means the module is fine.
317    pub message: Option<String>,
318}
319
320/// Stamps the check result on the module, creating the configuration row for a module that has
321/// none: an enabled module with no configuration is itself one of the problems being reported.
322///
323/// Resurrects a soft-deleted row for the same reason [`upsert`] does: `ON CONFLICT` can only infer
324/// against `uq_course_module_suotar_configurations`, so an insert beside one is impossible.
325pub async fn record_config_check(
326    conn: &mut PgConnection,
327    course_module_id: Uuid,
328    check: &SuotarConfigCheck,
329) -> ModelResult<()> {
330    sqlx::query!(
331        r#"
332INSERT INTO course_module_suotar_configurations (
333    course_module_id,
334    config_checked_at,
335    course_code_resolves,
336    product_token_found,
337    config_check_message
338  )
339VALUES ($1, now(), $2, $3, $4) ON CONFLICT (course_module_id) DO
340UPDATE
341SET config_checked_at = now(),
342  course_code_resolves = $2,
343  product_token_found = $3,
344  config_check_message = $4,
345  deleted_at = NULL
346        "#,
347        course_module_id,
348        check.course_code_resolves,
349        check.product_token_found,
350        check.message,
351    )
352    .execute(conn)
353    .await?;
354    Ok(())
355}
356
357/// Who paused a module's credit registration, and why. One value rather than three arguments
358/// because `course_module_suotar_configurations_pause_pair` rejects a timestamp without an actor.
359#[derive(Debug, Clone)]
360pub struct SuotarPause<'a> {
361    pub paused_at: DateTime<Utc>,
362    pub paused_by_user_id: Uuid,
363    pub reason: Option<&'a str>,
364}
365
366/// Pauses or resumes the module. Every phase's claim query skips a paused module, so pausing freezes
367/// its ledger rows where they stand instead of cancelling them. `None` resumes.
368pub async fn set_paused(
369    conn: &mut PgConnection,
370    course_module_id: Uuid,
371    pause: Option<SuotarPause<'_>>,
372) -> ModelResult<()> {
373    let pause = pause.as_ref();
374    sqlx::query!(
375        r#"
376UPDATE course_module_suotar_configurations
377SET paused_at = $2,
378  paused_by_user_id = $3,
379  pause_reason = $4
380WHERE course_module_id = $1
381  AND deleted_at IS NULL
382        "#,
383        course_module_id,
384        pause.map(|pause| pause.paused_at),
385        pause.map(|pause| pause.paused_by_user_id),
386        pause.and_then(|pause| pause.reason),
387    )
388    .execute(conn)
389    .await?;
390    Ok(())
391}