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 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 pub course_code_resolves: Option<bool>,
22 pub product_token_found: Option<bool>,
23 pub config_check_message: Option<String>,
24}
25
26pub 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
63pub 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
82pub 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#[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 pub old_flow_also_enabled: bool,
129 pub product_token_found: bool,
131 pub active_realisation_count: i64,
132 pub listed_successfully: bool,
134 pub course_code_not_found: bool,
135 pub has_passed_completions_without_a_grade: bool,
137}
138
139pub 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#[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 pub eligible_completion_count: i64,
227}
228
229pub 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
287pub 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#[derive(Debug, Clone, PartialEq, Default)]
313pub struct SuotarConfigCheck {
314 pub course_code_resolves: Option<bool>,
315 pub product_token_found: Option<bool>,
316 pub message: Option<String>,
318}
319
320pub 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#[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
366pub 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}