Skip to main content

headless_lms_models/
course_modules.rs

1use std::collections::HashMap;
2
3use utoipa::ToSchema;
4
5use crate::{chapters, prelude::*};
6
7/// The subset of `course_modules` columns [`CourseModule`] is built from; the credit-registration
8/// ones are read through [`CourseModuleCreditRegistrationConfig`], hence no `SELECT *` here.
9struct CourseModulesSchema {
10    id: Uuid,
11    created_at: DateTime<Utc>,
12    updated_at: DateTime<Utc>,
13    deleted_at: Option<DateTime<Utc>>,
14    name: Option<String>,
15    course_id: Uuid,
16    order_number: i32,
17    copied_from: Option<Uuid>,
18    uh_course_code: Option<String>,
19    automatic_completion: bool,
20    automatic_completion_number_of_exercises_attempted_treshold: Option<i32>,
21    automatic_completion_number_of_points_treshold: Option<i32>,
22    automatic_completion_requires_exam: bool,
23    completion_registration_link_override: Option<String>,
24    ects_credits: Option<f32>,
25    enable_registering_completion_to_uh_open_university: bool,
26    certification_enabled: bool,
27}
28/**
29 * Based on [CourseModulesSchema] but completion_policy parsed and addded (and some not needeed fields removed).
30 */
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
32
33pub struct CourseModule {
34    pub id: Uuid,
35    pub created_at: DateTime<Utc>,
36    pub updated_at: DateTime<Utc>,
37    pub deleted_at: Option<DateTime<Utc>>,
38    pub name: Option<String>,
39    pub course_id: Uuid,
40    pub order_number: i32,
41    pub copied_from: Option<Uuid>,
42    pub uh_course_code: Option<String>,
43    pub completion_policy: CompletionPolicy,
44    /// If set, use this link rather than the default one when registering course completions.
45    pub completion_registration_link_override: Option<String>,
46    pub ects_credits: Option<f32>,
47    pub enable_registering_completion_to_uh_open_university: bool,
48    pub certification_enabled: bool,
49}
50
51impl CourseModule {
52    pub fn new(id: Uuid, course_id: Uuid) -> Self {
53        Self {
54            id,
55            created_at: Utc::now(),
56            updated_at: Utc::now(),
57            deleted_at: None,
58            name: None,
59            course_id,
60            order_number: 0,
61            copied_from: None,
62            uh_course_code: None,
63            completion_policy: CompletionPolicy::Manual,
64            completion_registration_link_override: None,
65            ects_credits: None,
66            enable_registering_completion_to_uh_open_university: false,
67            certification_enabled: false,
68        }
69    }
70    pub fn set_timestamps(
71        mut self,
72        created_at: DateTime<Utc>,
73        updated_at: DateTime<Utc>,
74        deleted_at: Option<DateTime<Utc>>,
75    ) -> Self {
76        self.created_at = created_at;
77        self.updated_at = updated_at;
78        self.deleted_at = deleted_at;
79        self
80    }
81
82    /// order_number == 0 in and only if name == None
83    pub fn set_name_and_order_number(mut self, name: Option<String>, order_number: i32) -> Self {
84        self.name = name;
85        self.order_number = order_number;
86        self
87    }
88
89    pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
90        self.completion_policy = completion_policy;
91        self
92    }
93
94    pub fn set_registration_info(
95        mut self,
96        uh_course_code: Option<String>,
97        ects_credits: Option<f32>,
98        completion_registration_link_override: Option<String>,
99        enable_registering_completion_to_uh_open_university: bool,
100    ) -> Self {
101        self.uh_course_code = uh_course_code;
102        self.ects_credits = ects_credits;
103        self.completion_registration_link_override = completion_registration_link_override;
104        self.enable_registering_completion_to_uh_open_university =
105            enable_registering_completion_to_uh_open_university;
106        self
107    }
108
109    pub fn set_certification_enabled(mut self, certification_enabled: bool) -> Self {
110        self.certification_enabled = certification_enabled;
111        self
112    }
113
114    pub fn is_default_module(&self) -> bool {
115        self.name.is_none()
116    }
117}
118
119impl From<CourseModulesSchema> for CourseModule {
120    fn from(schema: CourseModulesSchema) -> Self {
121        let completion_policy = if schema.automatic_completion {
122            CompletionPolicy::Automatic(AutomaticCompletionRequirements {
123                course_module_id: schema.id,
124                number_of_exercises_attempted_treshold: schema
125                    .automatic_completion_number_of_exercises_attempted_treshold,
126                number_of_points_treshold: schema.automatic_completion_number_of_points_treshold,
127                requires_exam: schema.automatic_completion_requires_exam,
128            })
129        } else {
130            CompletionPolicy::Manual
131        };
132        Self {
133            id: schema.id,
134            created_at: schema.created_at,
135            updated_at: schema.updated_at,
136            deleted_at: schema.deleted_at,
137            name: schema.name,
138            course_id: schema.course_id,
139            order_number: schema.order_number,
140            copied_from: schema.copied_from,
141            uh_course_code: schema.uh_course_code,
142            completion_policy,
143            completion_registration_link_override: schema.completion_registration_link_override,
144            ects_credits: schema.ects_credits,
145            enable_registering_completion_to_uh_open_university: schema
146                .enable_registering_completion_to_uh_open_university,
147            certification_enabled: schema.certification_enabled,
148        }
149    }
150}
151
152#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
153
154pub struct NewCourseModule {
155    completion_policy: CompletionPolicy,
156    completion_registration_link_override: Option<String>,
157    course_id: Uuid,
158    ects_credits: Option<f32>,
159    name: Option<String>,
160    order_number: i32,
161    uh_course_code: Option<String>,
162    enable_registering_completion_to_uh_open_university: bool,
163}
164
165impl NewCourseModule {
166    pub fn new(course_id: Uuid, name: Option<String>, order_number: i32) -> Self {
167        Self {
168            completion_policy: CompletionPolicy::Manual,
169            completion_registration_link_override: None,
170            course_id,
171            ects_credits: None,
172            name,
173            order_number,
174            uh_course_code: None,
175            enable_registering_completion_to_uh_open_university: false,
176        }
177    }
178
179    pub fn new_course_default(course_id: Uuid) -> Self {
180        Self::new(course_id, None, 0)
181    }
182
183    pub fn set_uh_course_code(mut self, uh_course_code: Option<String>) -> Self {
184        self.uh_course_code = uh_course_code;
185        self
186    }
187
188    pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
189        self.completion_policy = completion_policy;
190        self
191    }
192
193    pub fn set_completion_registration_link_override(
194        mut self,
195        completion_registration_link_override: Option<String>,
196    ) -> Self {
197        self.completion_registration_link_override = completion_registration_link_override;
198        self
199    }
200
201    pub fn set_ects_credits(mut self, ects_credits: Option<f32>) -> Self {
202        self.ects_credits = ects_credits;
203        self
204    }
205
206    pub fn set_enable_registering_completion_to_uh_open_university(
207        mut self,
208        enable_registering_completion_to_uh_open_university: bool,
209    ) -> Self {
210        self.enable_registering_completion_to_uh_open_university =
211            enable_registering_completion_to_uh_open_university;
212        self
213    }
214}
215
216pub async fn insert(
217    conn: &mut PgConnection,
218    pkey_policy: PKeyPolicy<Uuid>,
219    new_course_module: &NewCourseModule,
220) -> ModelResult<CourseModule> {
221    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
222        new_course_module.completion_policy.to_database_fields();
223    let res = sqlx::query_as!(
224        CourseModulesSchema,
225        "
226INSERT INTO course_modules (
227    id,
228    course_id,
229    name,
230    order_number,
231    automatic_completion,
232    automatic_completion_number_of_exercises_attempted_treshold,
233    automatic_completion_number_of_points_treshold,
234    automatic_completion_requires_exam,
235    ects_credits,
236    enable_registering_completion_to_uh_open_university,
237    uh_course_code
238  )
239VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
240RETURNING id,
241  created_at,
242  updated_at,
243  deleted_at,
244  name,
245  course_id,
246  order_number,
247  copied_from,
248  uh_course_code,
249  automatic_completion,
250  automatic_completion_number_of_exercises_attempted_treshold,
251  automatic_completion_number_of_points_treshold,
252  automatic_completion_requires_exam,
253  completion_registration_link_override,
254  ects_credits,
255  enable_registering_completion_to_uh_open_university,
256  certification_enabled
257        ",
258        pkey_policy.into_uuid(),
259        new_course_module.course_id,
260        new_course_module.name,
261        new_course_module.order_number,
262        automatic_completion,
263        exercises_treshold,
264        points_treshold,
265        requires_exam,
266        new_course_module.ects_credits,
267        new_course_module.enable_registering_completion_to_uh_open_university,
268        new_course_module.uh_course_code
269    )
270    .fetch_one(conn)
271    .await?;
272    Ok(res.into())
273}
274
275pub async fn rename(conn: &mut PgConnection, id: Uuid, name: &str) -> ModelResult<()> {
276    sqlx::query!(
277        "
278UPDATE course_modules
279SET name = $1
280WHERE id = $2
281",
282        name,
283        id
284    )
285    .execute(conn)
286    .await?;
287    Ok(())
288}
289
290pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
291    let associated_chapters = chapters::get_for_module(conn, id).await?;
292    if !associated_chapters.is_empty() {
293        return Err(ModelError::new(
294            ModelErrorType::InvalidRequest,
295            format!(
296                "Cannot remove module {id} because it has {} chapters associated with it",
297                associated_chapters.len()
298            ),
299            None,
300        ));
301    }
302    sqlx::query!(
303        "
304UPDATE course_modules
305SET deleted_at = now()
306WHERE id = $1
307AND deleted_at IS NULL
308",
309        id
310    )
311    .execute(conn)
312    .await?;
313    Ok(())
314}
315
316pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModule> {
317    let res = sqlx::query_as!(
318        CourseModulesSchema,
319        "
320SELECT id,
321  created_at,
322  updated_at,
323  deleted_at,
324  name,
325  course_id,
326  order_number,
327  copied_from,
328  uh_course_code,
329  automatic_completion,
330  automatic_completion_number_of_exercises_attempted_treshold,
331  automatic_completion_number_of_points_treshold,
332  automatic_completion_requires_exam,
333  completion_registration_link_override,
334  ects_credits,
335  enable_registering_completion_to_uh_open_university,
336  certification_enabled
337FROM course_modules
338WHERE id = $1
339  AND deleted_at IS NULL
340        ",
341        id,
342    )
343    .fetch_one(conn)
344    .await?;
345    Ok(res.into())
346}
347
348pub async fn get_by_ids(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<Vec<CourseModule>> {
349    let res = sqlx::query_as!(
350        CourseModulesSchema,
351        "
352SELECT id,
353  created_at,
354  updated_at,
355  deleted_at,
356  name,
357  course_id,
358  order_number,
359  copied_from,
360  uh_course_code,
361  automatic_completion,
362  automatic_completion_number_of_exercises_attempted_treshold,
363  automatic_completion_number_of_points_treshold,
364  automatic_completion_requires_exam,
365  completion_registration_link_override,
366  ects_credits,
367  enable_registering_completion_to_uh_open_university,
368  certification_enabled
369FROM course_modules
370WHERE id = ANY($1)
371  AND deleted_at IS NULL
372        ",
373        ids,
374    )
375    .map(|x| x.into())
376    .fetch_all(conn)
377    .await?;
378    Ok(res)
379}
380
381pub async fn get_by_course_id(
382    conn: &mut PgConnection,
383    course_id: Uuid,
384) -> ModelResult<Vec<CourseModule>> {
385    let res = sqlx::query_as!(
386        CourseModulesSchema,
387        "
388SELECT id,
389  created_at,
390  updated_at,
391  deleted_at,
392  name,
393  course_id,
394  order_number,
395  copied_from,
396  uh_course_code,
397  automatic_completion,
398  automatic_completion_number_of_exercises_attempted_treshold,
399  automatic_completion_number_of_points_treshold,
400  automatic_completion_requires_exam,
401  completion_registration_link_override,
402  ects_credits,
403  enable_registering_completion_to_uh_open_university,
404  certification_enabled
405FROM course_modules
406WHERE course_id = $1
407AND deleted_at IS NULL
408",
409        course_id
410    )
411    .map(|x| x.into())
412    .fetch_all(conn)
413    .await?;
414    Ok(res)
415}
416
417/// Batched [`get_by_course_id`]: modules for many courses in one query, to group in memory instead
418/// of one query per course.
419pub async fn get_by_course_ids(
420    conn: &mut PgConnection,
421    course_ids: &[Uuid],
422) -> ModelResult<Vec<CourseModule>> {
423    let res = sqlx::query_as!(
424        CourseModulesSchema,
425        "
426SELECT id,
427  created_at,
428  updated_at,
429  deleted_at,
430  name,
431  course_id,
432  order_number,
433  copied_from,
434  uh_course_code,
435  automatic_completion,
436  automatic_completion_number_of_exercises_attempted_treshold,
437  automatic_completion_number_of_points_treshold,
438  automatic_completion_requires_exam,
439  completion_registration_link_override,
440  ects_credits,
441  enable_registering_completion_to_uh_open_university,
442  certification_enabled
443FROM course_modules
444WHERE course_id = ANY($1)
445AND deleted_at IS NULL
446",
447        course_ids
448    )
449    .map(|x| x.into())
450    .fetch_all(conn)
451    .await?;
452    Ok(res)
453}
454
455pub async fn get_by_course_id_only_with_open_chapters(
456    conn: &mut PgConnection,
457    course_id: Uuid,
458) -> ModelResult<Vec<CourseModule>> {
459    let res = sqlx::query_as!(
460        CourseModulesSchema,
461        "
462SELECT cm.id,
463  cm.created_at,
464  cm.updated_at,
465  cm.deleted_at,
466  cm.name,
467  cm.course_id,
468  cm.order_number,
469  cm.copied_from,
470  cm.uh_course_code,
471  cm.automatic_completion,
472  cm.automatic_completion_number_of_exercises_attempted_treshold,
473  cm.automatic_completion_number_of_points_treshold,
474  cm.automatic_completion_requires_exam,
475  cm.completion_registration_link_override,
476  cm.ects_credits,
477  cm.enable_registering_completion_to_uh_open_university,
478  cm.certification_enabled
479FROM course_modules as cm
480WHERE EXISTS (
481  SELECT 1
482  FROM chapters as ch
483  WHERE ch.course_module_id = cm.id
484    AND ((ch.opens_at < now()) OR ch.opens_at IS NULL)
485    AND ch.deleted_at IS NULL
486)
487  AND cm.course_id = $1
488  AND cm.deleted_at IS NULL
489",
490        course_id
491    )
492    .map(|x| x.into())
493    .fetch_all(conn)
494    .await?;
495    Ok(res)
496}
497
498/// Gets course module where the given exercise belongs to. This will result in an error in the case
499/// of an exam exercise.
500pub async fn get_by_exercise_id(
501    conn: &mut PgConnection,
502    exercise_id: Uuid,
503) -> ModelResult<CourseModule> {
504    let res = sqlx::query_as!(
505        CourseModulesSchema,
506        r#"
507SELECT
508    course_modules.id AS "id!",
509    course_modules.created_at AS "created_at!",
510    course_modules.updated_at AS "updated_at!",
511    course_modules.deleted_at,
512    course_modules.name,
513    course_modules.course_id AS "course_id!",
514    course_modules.order_number AS "order_number!",
515    course_modules.copied_from,
516    course_modules.uh_course_code,
517    course_modules.automatic_completion AS "automatic_completion!",
518    course_modules.automatic_completion_number_of_exercises_attempted_treshold,
519    course_modules.automatic_completion_number_of_points_treshold,
520    course_modules.automatic_completion_requires_exam AS "automatic_completion_requires_exam!",
521    course_modules.completion_registration_link_override,
522    course_modules.ects_credits,
523    course_modules.enable_registering_completion_to_uh_open_university AS "enable_registering_completion_to_uh_open_university!",
524    course_modules.certification_enabled AS "certification_enabled!"
525FROM exercises
526  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
527  LEFT JOIN course_modules ON (chapters.course_module_id = course_modules.id)
528WHERE exercises.id = $1
529AND chapters.deleted_at IS NULL
530AND course_modules.deleted_at IS NULL
531        "#,
532        exercise_id,
533    )
534    .fetch_one(conn)
535    .await?;
536    Ok(res.into())
537}
538
539pub async fn get_course_module_id_by_chapter(
540    conn: &mut PgConnection,
541    chapter_id: Uuid,
542) -> ModelResult<Uuid> {
543    let res: Uuid = sqlx::query!(
544        r#"
545SELECT c.course_module_id
546from chapters c
547where c.id = $1
548  AND deleted_at IS NULL
549  "#,
550        chapter_id
551    )
552    .map(|record| record.course_module_id)
553    .fetch_one(conn)
554    .await?;
555    Ok(res)
556}
557
558/// How many chapters and exercises a course module contains. Used for deciding whether the module
559/// is small enough to be exempt from the minimum cheater threshold.
560pub struct ModuleSizeCounts {
561    pub chapters: i64,
562    pub exercises: i64,
563}
564
565pub async fn get_chapter_and_exercise_counts(
566    conn: &mut PgConnection,
567    course_module_id: Uuid,
568) -> ModelResult<ModuleSizeCounts> {
569    let res = sqlx::query_as!(
570        ModuleSizeCounts,
571        r#"
572SELECT COUNT(DISTINCT c.id) AS "chapters!",
573  COUNT(e.id) AS "exercises!"
574FROM chapters c
575  LEFT JOIN exercises e ON e.chapter_id = c.id
576  AND e.deleted_at IS NULL
577WHERE c.course_module_id = $1
578  AND c.deleted_at IS NULL
579        "#,
580        course_module_id
581    )
582    .fetch_one(conn)
583    .await?;
584    Ok(res)
585}
586
587pub async fn get_default_by_course_id(
588    conn: &mut PgConnection,
589    course_id: Uuid,
590) -> ModelResult<CourseModule> {
591    let res = sqlx::query_as!(
592        CourseModulesSchema,
593        "
594SELECT id,
595  created_at,
596  updated_at,
597  deleted_at,
598  name,
599  course_id,
600  order_number,
601  copied_from,
602  uh_course_code,
603  automatic_completion,
604  automatic_completion_number_of_exercises_attempted_treshold,
605  automatic_completion_number_of_points_treshold,
606  automatic_completion_requires_exam,
607  completion_registration_link_override,
608  ects_credits,
609  enable_registering_completion_to_uh_open_university,
610  certification_enabled
611FROM course_modules
612WHERE course_id = $1
613  AND name IS NULL
614  AND deleted_at IS NULL
615        ",
616        course_id,
617    )
618    .fetch_one(conn)
619    .await?;
620    Ok(res.into())
621}
622
623/// Gets all course modules with a matching `uh_course_code` or course `slug`.
624///
625/// In the latter case only one record at most is returned, but there is no way to distinguish between
626/// these two scenarios in advance.
627pub async fn get_ids_by_course_slug_or_uh_course_code(
628    conn: &mut PgConnection,
629    course_slug_or_code: &str,
630) -> ModelResult<Vec<Uuid>> {
631    let res = sqlx::query!(
632        "
633SELECT course_modules.id
634FROM course_modules
635  LEFT JOIN courses ON (course_modules.course_id = courses.id)
636WHERE (
637    course_modules.uh_course_code = $1
638    OR courses.slug = $1
639  )
640  AND course_modules.deleted_at IS NULL
641        ",
642        course_slug_or_code,
643    )
644    .map(|record| record.id)
645    .fetch_all(conn)
646    .await?;
647    Ok(res)
648}
649
650/// Gets course modules for the given course as a map, indexed by the `id` field.
651pub async fn get_by_course_id_as_map(
652    conn: &mut PgConnection,
653    course_id: Uuid,
654) -> ModelResult<HashMap<Uuid, CourseModule>> {
655    let res = get_by_course_id(conn, course_id)
656        .await?
657        .into_iter()
658        .map(|course_module| (course_module.id, course_module))
659        .collect();
660    Ok(res)
661}
662
663pub async fn get_all_uh_course_codes_for_open_university(
664    conn: &mut PgConnection,
665) -> ModelResult<Vec<String>> {
666    let res = sqlx::query!(
667        "
668SELECT DISTINCT uh_course_code
669FROM course_modules
670WHERE uh_course_code IS NOT NULL
671  AND enable_registering_completion_to_uh_open_university = true
672  AND deleted_at IS NULL
673"
674    )
675    .fetch_all(conn)
676    .await?
677    .into_iter()
678    .filter_map(|x| x.uh_course_code)
679    .collect();
680    Ok(res)
681}
682
683/// Per-module credit-registration configuration: the rollout switch and the module's own fields
684/// merged with its `course_module_suotar_configurations` row. Every field of that row is optional
685/// here because a module with no configuration row is a valid, unconfigured module.
686#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
687pub struct CourseModuleCreditRegistrationConfig {
688    pub course_module_id: Uuid,
689    pub course_id: Uuid,
690    pub enable_credit_registration_via_suotar: bool,
691    pub uh_course_code: Option<String>,
692    pub ects_credits: Option<f32>,
693    pub open_university_product_id: Option<String>,
694    /// `None` means derive the grade scale from the completion.
695    pub credit_registration_grade_scale_id: Option<String>,
696    pub credit_registration_paused_at: Option<DateTime<Utc>>,
697    pub credit_registration_paused_by_user_id: Option<Uuid>,
698    pub credit_registration_pause_reason: Option<String>,
699    pub credit_registration_config_checked_at: Option<DateTime<Utc>>,
700    /// `None` means never checked, which is not the same as a failed check.
701    pub credit_registration_course_code_resolves: Option<bool>,
702    pub credit_registration_product_token_found: Option<bool>,
703    pub credit_registration_config_check_message: Option<String>,
704}
705
706pub async fn get_credit_registration_config(
707    conn: &mut PgConnection,
708    course_module_id: Uuid,
709) -> ModelResult<CourseModuleCreditRegistrationConfig> {
710    let res = sqlx::query_as!(
711        CourseModuleCreditRegistrationConfig,
712        r#"
713SELECT cm.id AS course_module_id,
714  cm.course_id,
715  cm.enable_credit_registration_via_suotar,
716  cm.uh_course_code,
717  cm.ects_credits,
718  c.open_university_product_id AS "open_university_product_id?",
719  c.grade_scale_id AS "credit_registration_grade_scale_id?",
720  c.paused_at AS "credit_registration_paused_at?",
721  c.paused_by_user_id AS "credit_registration_paused_by_user_id?",
722  c.pause_reason AS "credit_registration_pause_reason?",
723  c.config_checked_at AS "credit_registration_config_checked_at?",
724  c.course_code_resolves AS "credit_registration_course_code_resolves?",
725  c.product_token_found AS "credit_registration_product_token_found?",
726  c.config_check_message AS "credit_registration_config_check_message?"
727FROM course_modules cm
728  LEFT JOIN course_module_suotar_configurations c ON c.course_module_id = cm.id
729  AND c.deleted_at IS NULL
730WHERE cm.id = $1
731  AND cm.deleted_at IS NULL
732        "#,
733        course_module_id
734    )
735    .fetch_one(conn)
736    .await?;
737    Ok(res)
738}
739
740/// Every module opted in to credit registration via Suotar, paused ones included.
741pub async fn get_all_suotar_enabled(
742    conn: &mut PgConnection,
743) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
744    let res = sqlx::query_as!(
745        CourseModuleCreditRegistrationConfig,
746        r#"
747SELECT cm.id AS course_module_id,
748  cm.course_id,
749  cm.enable_credit_registration_via_suotar,
750  cm.uh_course_code,
751  cm.ects_credits,
752  c.open_university_product_id AS "open_university_product_id?",
753  c.grade_scale_id AS "credit_registration_grade_scale_id?",
754  c.paused_at AS "credit_registration_paused_at?",
755  c.paused_by_user_id AS "credit_registration_paused_by_user_id?",
756  c.pause_reason AS "credit_registration_pause_reason?",
757  c.config_checked_at AS "credit_registration_config_checked_at?",
758  c.course_code_resolves AS "credit_registration_course_code_resolves?",
759  c.product_token_found AS "credit_registration_product_token_found?",
760  c.config_check_message AS "credit_registration_config_check_message?"
761FROM course_modules cm
762  LEFT JOIN course_module_suotar_configurations c ON c.course_module_id = cm.id
763  AND c.deleted_at IS NULL
764WHERE cm.enable_credit_registration_via_suotar
765  AND cm.deleted_at IS NULL
766ORDER BY cm.course_id,
767  cm.order_number
768        "#,
769    )
770    .fetch_all(conn)
771    .await?;
772    Ok(res)
773}
774
775#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
776
777pub struct AutomaticCompletionRequirements {
778    /// Course module associated with these requirements.
779    pub course_module_id: Uuid,
780    pub number_of_exercises_attempted_treshold: Option<i32>,
781    pub number_of_points_treshold: Option<i32>,
782    pub requires_exam: bool,
783}
784
785impl AutomaticCompletionRequirements {
786    /// Shorthand for checking whether the given exercise related values pass their respective
787    /// tresholds.
788    pub fn passes_exercise_tresholds(
789        &self,
790        exercises_attempted: i32,
791        exercise_points: i32,
792    ) -> bool {
793        self.passes_number_of_exercises_attempted_treshold(exercises_attempted)
794            && self.passes_number_of_exercise_points_treshold(exercise_points)
795    }
796
797    /// Whether the given number is higher than the exercises attempted treshold. Always returns
798    /// true if there is no treshold.
799    pub fn passes_number_of_exercises_attempted_treshold(&self, exercises_attempted: i32) -> bool {
800        self.number_of_exercises_attempted_treshold
801            .map(|x| x <= exercises_attempted)
802            .unwrap_or(true)
803    }
804
805    /// Whether the given number is higher than the exercise points treshold. Always returns true
806    /// if there is no treshold.
807    pub fn passes_number_of_exercise_points_treshold(&self, exercise_points: i32) -> bool {
808        self.number_of_points_treshold
809            .map(|x| x <= exercise_points)
810            .unwrap_or(true)
811    }
812}
813
814#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)]
815#[serde(tag = "policy", rename_all = "kebab-case")]
816pub enum CompletionPolicy {
817    Automatic(AutomaticCompletionRequirements),
818    Manual,
819}
820
821impl CompletionPolicy {
822    /// Returns associated data for `Automatic` variant, if matches.
823    pub fn automatic(&self) -> Option<&AutomaticCompletionRequirements> {
824        match self {
825            CompletionPolicy::Automatic(requirements) => Some(requirements),
826            CompletionPolicy::Manual => None,
827        }
828    }
829
830    fn to_database_fields(&self) -> (bool, Option<i32>, Option<i32>, bool) {
831        match self {
832            CompletionPolicy::Automatic(requirements) => (
833                true,
834                requirements.number_of_exercises_attempted_treshold,
835                requirements.number_of_points_treshold,
836                requirements.requires_exam,
837            ),
838            CompletionPolicy::Manual => (false, None, None, false),
839        }
840    }
841}
842
843pub async fn update_automatic_completion_status(
844    conn: &mut PgConnection,
845    id: Uuid,
846    automatic_completion_policy: &CompletionPolicy,
847) -> ModelResult<CourseModule> {
848    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
849        automatic_completion_policy.to_database_fields();
850    let res = sqlx::query_as!(
851        CourseModulesSchema,
852        "
853UPDATE course_modules
854SET automatic_completion = $1,
855  automatic_completion_number_of_exercises_attempted_treshold = $2,
856  automatic_completion_number_of_points_treshold = $3,
857  automatic_completion_requires_exam = $4
858WHERE id = $5
859  AND deleted_at IS NULL
860RETURNING id,
861  created_at,
862  updated_at,
863  deleted_at,
864  name,
865  course_id,
866  order_number,
867  copied_from,
868  uh_course_code,
869  automatic_completion,
870  automatic_completion_number_of_exercises_attempted_treshold,
871  automatic_completion_number_of_points_treshold,
872  automatic_completion_requires_exam,
873  completion_registration_link_override,
874  ects_credits,
875  enable_registering_completion_to_uh_open_university,
876  certification_enabled
877        ",
878        automatic_completion,
879        exercises_treshold,
880        points_treshold,
881        requires_exam,
882        id,
883    )
884    .fetch_one(conn)
885    .await?;
886    Ok(res.into())
887}
888
889pub async fn update_uh_course_code(
890    conn: &mut PgConnection,
891    id: Uuid,
892    uh_course_code: Option<String>,
893) -> ModelResult<CourseModule> {
894    let res = sqlx::query_as!(
895        CourseModulesSchema,
896        "
897UPDATE course_modules
898SET uh_course_code = $1
899WHERE id = $2
900  AND deleted_at IS NULL
901RETURNING id,
902  created_at,
903  updated_at,
904  deleted_at,
905  name,
906  course_id,
907  order_number,
908  copied_from,
909  uh_course_code,
910  automatic_completion,
911  automatic_completion_number_of_exercises_attempted_treshold,
912  automatic_completion_number_of_points_treshold,
913  automatic_completion_requires_exam,
914  completion_registration_link_override,
915  ects_credits,
916  enable_registering_completion_to_uh_open_university,
917  certification_enabled
918        ",
919        uh_course_code,
920        id,
921    )
922    .fetch_one(conn)
923    .await?;
924    Ok(res.into())
925}
926
927pub async fn update_enable_registering_completion_to_uh_open_university(
928    conn: &mut PgConnection,
929    id: Uuid,
930    enable_registering_completion_to_uh_open_university: bool,
931) -> ModelResult<CourseModule> {
932    let res = sqlx::query_as!(
933        CourseModulesSchema,
934        "
935UPDATE course_modules
936SET enable_registering_completion_to_uh_open_university = $1
937WHERE id = $2
938  AND deleted_at IS NULL
939RETURNING id,
940  created_at,
941  updated_at,
942  deleted_at,
943  name,
944  course_id,
945  order_number,
946  copied_from,
947  uh_course_code,
948  automatic_completion,
949  automatic_completion_number_of_exercises_attempted_treshold,
950  automatic_completion_number_of_points_treshold,
951  automatic_completion_requires_exam,
952  completion_registration_link_override,
953  ects_credits,
954  enable_registering_completion_to_uh_open_university,
955  certification_enabled
956        ",
957        enable_registering_completion_to_uh_open_university,
958        id,
959    )
960    .fetch_one(conn)
961    .await?;
962    Ok(res.into())
963}
964
965#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
966
967pub struct NewModule {
968    name: String,
969    order_number: i32,
970    chapters: Vec<Uuid>,
971    uh_course_code: Option<String>,
972    ects_credits: Option<f32>,
973    completion_policy: CompletionPolicy,
974    completion_registration_link_override: Option<String>,
975    enable_registering_completion_to_uh_open_university: bool,
976}
977
978#[derive(Debug, Deserialize, ToSchema)]
979
980pub struct ModifiedModule {
981    id: Uuid,
982    name: Option<String>,
983    order_number: i32,
984    uh_course_code: Option<String>,
985    ects_credits: Option<f32>,
986    completion_policy: CompletionPolicy,
987    completion_registration_link_override: Option<String>,
988    enable_registering_completion_to_uh_open_university: bool,
989}
990
991#[derive(Debug, Deserialize, ToSchema)]
992
993pub struct ModuleUpdates {
994    new_modules: Vec<NewModule>,
995    deleted_modules: Vec<Uuid>,
996    modified_modules: Vec<ModifiedModule>,
997    moved_chapters: Vec<(Uuid, Uuid)>,
998}
999
1000pub async fn update_with_order_number(
1001    conn: &mut PgConnection,
1002    id: Uuid,
1003    name: Option<&str>,
1004    order_number: i32,
1005) -> ModelResult<()> {
1006    sqlx::query!(
1007        "
1008UPDATE course_modules
1009SET name = COALESCE($1, name),
1010  order_number = $2
1011WHERE id = $3
1012",
1013        name,
1014        order_number,
1015        id,
1016    )
1017    .execute(conn)
1018    .await?;
1019    Ok(())
1020}
1021
1022pub async fn update(
1023    conn: &mut PgConnection,
1024    id: Uuid,
1025    updated_course_module: &NewCourseModule,
1026) -> ModelResult<()> {
1027    // destructure so new fields cause a compilation error here
1028    let NewCourseModule {
1029        completion_policy: _,
1030        course_id: _,
1031        ects_credits,
1032        order_number,
1033        name,
1034        uh_course_code,
1035        completion_registration_link_override,
1036        enable_registering_completion_to_uh_open_university,
1037    } = updated_course_module;
1038    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
1039        updated_course_module.completion_policy.to_database_fields();
1040    sqlx::query!(
1041        "
1042UPDATE course_modules
1043SET name = COALESCE($2, name),
1044  order_number = $3,
1045  uh_course_code = $4,
1046  ects_credits = $5,
1047  automatic_completion = $6,
1048  automatic_completion_number_of_exercises_attempted_treshold = $7,
1049  automatic_completion_number_of_points_treshold = $8,
1050  automatic_completion_requires_exam = $9,
1051  completion_registration_link_override = $10,
1052  enable_registering_completion_to_uh_open_university = $11
1053WHERE id = $1
1054        ",
1055        id,
1056        name.as_ref(),
1057        order_number,
1058        uh_course_code.as_ref(),
1059        ects_credits.as_ref(),
1060        automatic_completion,
1061        exercises_treshold,
1062        points_treshold,
1063        requires_exam,
1064        completion_registration_link_override.as_ref(),
1065        enable_registering_completion_to_uh_open_university
1066    )
1067    .execute(conn)
1068    .await?;
1069    Ok(())
1070}
1071
1072pub async fn update_modules(
1073    conn: &mut PgConnection,
1074    course_id: Uuid,
1075    updates: ModuleUpdates,
1076) -> ModelResult<()> {
1077    let mut tx = conn.begin().await?;
1078
1079    // scramble order of modified and deleted modules
1080    for module_id in updates
1081        .modified_modules
1082        .iter()
1083        // do not scramble the default module, it should always be first
1084        .filter(|m| m.order_number != 0)
1085        .map(|m| m.id)
1086        .chain(updates.deleted_modules.iter().copied())
1087    {
1088        update_with_order_number(&mut tx, module_id, None, rand::random()).await?;
1089    }
1090    let mut modified_and_new_modules = updates.modified_modules;
1091    for new in updates.new_modules {
1092        // destructure so new fields cause a compilation error here
1093        let NewModule {
1094            name,
1095            order_number,
1096            chapters,
1097            uh_course_code,
1098            ects_credits,
1099            completion_policy,
1100            completion_registration_link_override,
1101            enable_registering_completion_to_uh_open_university,
1102        } = new;
1103        // insert with a random order number to avoid conflicts
1104        let new_course_module = NewCourseModule::new(course_id, Some(name.clone()), rand::random())
1105            .set_completion_policy(completion_policy.clone())
1106            .set_completion_registration_link_override(completion_registration_link_override)
1107            .set_ects_credits(ects_credits)
1108            .set_uh_course_code(uh_course_code)
1109            .set_enable_registering_completion_to_uh_open_university(
1110                enable_registering_completion_to_uh_open_university,
1111            );
1112        let module = insert(&mut tx, PKeyPolicy::Generate, &new_course_module).await?;
1113        for chapter in chapters {
1114            chapters::set_module(&mut tx, chapter, module.id).await?;
1115        }
1116        //modify the order number with the rest
1117        modified_and_new_modules.push(ModifiedModule {
1118            id: module.id,
1119            name: None,
1120            order_number,
1121            uh_course_code: module.uh_course_code,
1122            ects_credits,
1123            completion_policy,
1124            completion_registration_link_override: module.completion_registration_link_override,
1125            enable_registering_completion_to_uh_open_university: module
1126                .enable_registering_completion_to_uh_open_university,
1127        })
1128    }
1129    // update modified and new modules
1130    for module in modified_and_new_modules {
1131        // destructure so new fields cause a compilation error here
1132        let ModifiedModule {
1133            id,
1134            name,
1135            order_number,
1136            uh_course_code,
1137            ects_credits,
1138            completion_policy,
1139            completion_registration_link_override,
1140            enable_registering_completion_to_uh_open_university,
1141        } = module;
1142        update(
1143            &mut tx,
1144            id,
1145            &NewCourseModule::new(course_id, name.clone(), order_number)
1146                .set_completion_policy(completion_policy)
1147                .set_completion_registration_link_override(completion_registration_link_override)
1148                .set_ects_credits(ects_credits)
1149                .set_uh_course_code(uh_course_code)
1150                .set_enable_registering_completion_to_uh_open_university(
1151                    enable_registering_completion_to_uh_open_university,
1152                ),
1153        )
1154        .await?;
1155    }
1156    for (chapter, module) in updates.moved_chapters {
1157        chapters::set_module(&mut tx, chapter, module).await?;
1158    }
1159    for deleted in updates.deleted_modules {
1160        delete(&mut tx, deleted).await?;
1161    }
1162
1163    tx.commit().await?;
1164    Ok(())
1165}
1166
1167pub async fn update_certification_enabled(
1168    conn: &mut PgConnection,
1169    id: Uuid,
1170    enabled: bool,
1171) -> ModelResult<()> {
1172    sqlx::query!(
1173        "
1174UPDATE course_modules
1175SET certification_enabled = $1
1176WHERE id = $2
1177",
1178        enabled,
1179        id
1180    )
1181    .execute(conn)
1182    .await?;
1183    Ok(())
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188
1189    mod automatic_completion_requirements {
1190        use uuid::Uuid;
1191
1192        use super::super::AutomaticCompletionRequirements;
1193
1194        #[test]
1195        fn passes_exercise_tresholds() {
1196            let requirements1 = AutomaticCompletionRequirements {
1197                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1198                number_of_exercises_attempted_treshold: Some(10),
1199                number_of_points_treshold: Some(50),
1200                requires_exam: false,
1201            };
1202            let requirements2 = AutomaticCompletionRequirements {
1203                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1204                number_of_exercises_attempted_treshold: Some(50),
1205                number_of_points_treshold: Some(10),
1206                requires_exam: false,
1207            };
1208
1209            let requirements3 = AutomaticCompletionRequirements {
1210                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1211                number_of_exercises_attempted_treshold: Some(0),
1212                number_of_points_treshold: Some(0),
1213                requires_exam: false,
1214            };
1215
1216            let requirements4 = AutomaticCompletionRequirements {
1217                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1218                number_of_exercises_attempted_treshold: Some(10),
1219                number_of_points_treshold: None,
1220                requires_exam: false,
1221            };
1222
1223            let requirements5 = AutomaticCompletionRequirements {
1224                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1225                number_of_exercises_attempted_treshold: None,
1226                number_of_points_treshold: Some(10),
1227                requires_exam: false,
1228            };
1229            assert!(requirements1.passes_exercise_tresholds(10, 50));
1230            assert!(requirements2.passes_exercise_tresholds(50, 10));
1231
1232            assert!(!requirements1.passes_exercise_tresholds(50, 10));
1233            assert!(!requirements2.passes_exercise_tresholds(10, 50));
1234
1235            assert!(!requirements1.passes_exercise_tresholds(100, 0));
1236            assert!(!requirements2.passes_exercise_tresholds(100, 0));
1237
1238            assert!(requirements3.passes_exercise_tresholds(1, 1));
1239            assert!(requirements3.passes_exercise_tresholds(0, 0));
1240
1241            assert!(requirements4.passes_exercise_tresholds(10, 1));
1242            assert!(!requirements4.passes_exercise_tresholds(1, 10));
1243
1244            assert!(requirements5.passes_exercise_tresholds(0, 10));
1245            assert!(!requirements5.passes_exercise_tresholds(10, 0));
1246        }
1247    }
1248}