Skip to main content

headless_lms_models/
course_modules.rs

1use std::collections::HashMap;
2
3use utoipa::ToSchema;
4
5use crate::{chapters, error::missing_model_error, 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    enable_credit_registration_via_suotar: bool,
28}
29/// Like [CourseModulesSchema], but the automatic-completion columns are collapsed into
30/// `completion_policy`.
31#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
32pub struct CourseModule {
33    pub id: Uuid,
34    pub created_at: DateTime<Utc>,
35    pub updated_at: DateTime<Utc>,
36    pub deleted_at: Option<DateTime<Utc>>,
37    pub name: Option<String>,
38    pub course_id: Uuid,
39    pub order_number: i32,
40    pub copied_from: Option<Uuid>,
41    pub uh_course_code: Option<String>,
42    pub completion_policy: CompletionPolicy,
43    /// If set, use this link rather than the default one when registering course completions.
44    pub completion_registration_link_override: Option<String>,
45    pub ects_credits: Option<f32>,
46    pub enable_registering_completion_to_uh_open_university: bool,
47    pub certification_enabled: bool,
48    pub enable_credit_registration_via_suotar: 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            enable_credit_registration_via_suotar: false,
69        }
70    }
71    pub fn set_timestamps(
72        mut self,
73        created_at: DateTime<Utc>,
74        updated_at: DateTime<Utc>,
75        deleted_at: Option<DateTime<Utc>>,
76    ) -> Self {
77        self.created_at = created_at;
78        self.updated_at = updated_at;
79        self.deleted_at = deleted_at;
80        self
81    }
82
83    /// order_number == 0 in and only if name == None
84    pub fn set_name_and_order_number(mut self, name: Option<String>, order_number: i32) -> Self {
85        self.name = name;
86        self.order_number = order_number;
87        self
88    }
89
90    pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
91        self.completion_policy = completion_policy;
92        self
93    }
94
95    pub fn set_registration_info(
96        mut self,
97        uh_course_code: Option<String>,
98        ects_credits: Option<f32>,
99        completion_registration_link_override: Option<String>,
100        enable_registering_completion_to_uh_open_university: bool,
101        enable_credit_registration_via_suotar: bool,
102    ) -> Self {
103        self.uh_course_code = uh_course_code;
104        self.ects_credits = ects_credits;
105        self.completion_registration_link_override = completion_registration_link_override;
106        self.enable_registering_completion_to_uh_open_university =
107            enable_registering_completion_to_uh_open_university;
108        self.enable_credit_registration_via_suotar = enable_credit_registration_via_suotar;
109        self
110    }
111
112    pub fn set_certification_enabled(mut self, certification_enabled: bool) -> Self {
113        self.certification_enabled = certification_enabled;
114        self
115    }
116
117    pub fn is_default_module(&self) -> bool {
118        self.name.is_none()
119    }
120}
121
122impl From<CourseModulesSchema> for CourseModule {
123    fn from(schema: CourseModulesSchema) -> Self {
124        let completion_policy = if schema.automatic_completion {
125            CompletionPolicy::Automatic(AutomaticCompletionRequirements {
126                course_module_id: schema.id,
127                number_of_exercises_attempted_treshold: schema
128                    .automatic_completion_number_of_exercises_attempted_treshold,
129                number_of_points_treshold: schema.automatic_completion_number_of_points_treshold,
130                requires_exam: schema.automatic_completion_requires_exam,
131            })
132        } else {
133            CompletionPolicy::Manual
134        };
135        Self {
136            id: schema.id,
137            created_at: schema.created_at,
138            updated_at: schema.updated_at,
139            deleted_at: schema.deleted_at,
140            name: schema.name,
141            course_id: schema.course_id,
142            order_number: schema.order_number,
143            copied_from: schema.copied_from,
144            uh_course_code: schema.uh_course_code,
145            completion_policy,
146            completion_registration_link_override: schema.completion_registration_link_override,
147            ects_credits: schema.ects_credits,
148            enable_registering_completion_to_uh_open_university: schema
149                .enable_registering_completion_to_uh_open_university,
150            certification_enabled: schema.certification_enabled,
151            enable_credit_registration_via_suotar: schema.enable_credit_registration_via_suotar,
152        }
153    }
154}
155
156#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
157pub struct NewCourseModule {
158    completion_policy: CompletionPolicy,
159    completion_registration_link_override: Option<String>,
160    course_id: Uuid,
161    ects_credits: Option<f32>,
162    name: Option<String>,
163    order_number: i32,
164    uh_course_code: Option<String>,
165    enable_registering_completion_to_uh_open_university: bool,
166    enable_credit_registration_via_suotar: bool,
167}
168
169impl NewCourseModule {
170    pub fn new(course_id: Uuid, name: Option<String>, order_number: i32) -> Self {
171        Self {
172            completion_policy: CompletionPolicy::Manual,
173            completion_registration_link_override: None,
174            course_id,
175            ects_credits: None,
176            name,
177            order_number,
178            uh_course_code: None,
179            enable_registering_completion_to_uh_open_university: false,
180            enable_credit_registration_via_suotar: false,
181        }
182    }
183
184    pub fn new_course_default(course_id: Uuid) -> Self {
185        Self::new(course_id, None, 0)
186    }
187
188    pub fn set_uh_course_code(mut self, uh_course_code: Option<String>) -> Self {
189        self.uh_course_code = uh_course_code;
190        self
191    }
192
193    pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
194        self.completion_policy = completion_policy;
195        self
196    }
197
198    pub fn set_completion_registration_link_override(
199        mut self,
200        completion_registration_link_override: Option<String>,
201    ) -> Self {
202        self.completion_registration_link_override = completion_registration_link_override;
203        self
204    }
205
206    pub fn set_ects_credits(mut self, ects_credits: Option<f32>) -> Self {
207        self.ects_credits = ects_credits;
208        self
209    }
210
211    pub fn set_enable_registering_completion_to_uh_open_university(
212        mut self,
213        enable_registering_completion_to_uh_open_university: bool,
214    ) -> Self {
215        self.enable_registering_completion_to_uh_open_university =
216            enable_registering_completion_to_uh_open_university;
217        self
218    }
219
220    pub fn set_enable_credit_registration_via_suotar(
221        mut self,
222        enable_credit_registration_via_suotar: bool,
223    ) -> Self {
224        self.enable_credit_registration_via_suotar = enable_credit_registration_via_suotar;
225        self
226    }
227}
228
229#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
230pub struct NewModule {
231    name: String,
232    order_number: i32,
233    chapters: Vec<Uuid>,
234    uh_course_code: Option<String>,
235    ects_credits: Option<f32>,
236    completion_policy: CompletionPolicy,
237    completion_registration_link_override: Option<String>,
238    enable_registering_completion_to_uh_open_university: bool,
239    enable_credit_registration_via_suotar: bool,
240}
241
242#[derive(Debug, Deserialize, ToSchema)]
243pub struct ModifiedModule {
244    id: Uuid,
245    name: Option<String>,
246    order_number: i32,
247    uh_course_code: Option<String>,
248    ects_credits: Option<f32>,
249    completion_policy: CompletionPolicy,
250    completion_registration_link_override: Option<String>,
251    enable_registering_completion_to_uh_open_university: bool,
252    enable_credit_registration_via_suotar: bool,
253}
254
255#[derive(Debug, Deserialize, ToSchema)]
256pub struct CourseAuditingModuleUpdate {
257    pub id: Uuid,
258    pub name: Option<String>,
259    pub order_number: i32,
260    pub uh_course_code: Option<String>,
261    pub ects_credits: Option<f32>,
262    pub completion_registration_link_override: Option<String>,
263    pub enable_registering_completion_to_uh_open_university: bool,
264}
265
266#[derive(Debug, Deserialize, ToSchema)]
267pub struct ModuleUpdates {
268    new_modules: Vec<NewModule>,
269    deleted_modules: Vec<Uuid>,
270    modified_modules: Vec<ModifiedModule>,
271    moved_chapters: Vec<(Uuid, Uuid)>,
272}
273
274/// How many chapters and exercises a course module contains. Used for deciding whether the module
275/// is small enough to be exempt from the minimum cheater threshold.
276pub struct ModuleSizeCounts {
277    pub chapters: i64,
278    pub exercises: i64,
279}
280
281/// Per-module credit-registration configuration: the rollout switch and the module's own fields
282/// merged with its `course_module_suotar_configurations` row. Every field of that row is optional
283/// here because a module with no configuration row is a valid, unconfigured module.
284#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
285pub struct CourseModuleCreditRegistrationConfig {
286    pub course_module_id: Uuid,
287    pub course_id: Uuid,
288    pub enable_credit_registration_via_suotar: bool,
289    pub uh_course_code: Option<String>,
290    pub ects_credits: Option<f32>,
291    pub credit_registration_paused_at: Option<DateTime<Utc>>,
292    pub credit_registration_paused_by_user_id: Option<Uuid>,
293    pub credit_registration_pause_reason: Option<String>,
294    pub credit_registration_config_checked_at: Option<DateTime<Utc>>,
295    /// `None` means never checked, which is not the same as a failed check.
296    pub credit_registration_course_code_allowed: Option<bool>,
297    pub credit_registration_config_check_message: Option<String>,
298    /// Whether `completion_registration_link_override` is set, which is also the enrolment link for
299    /// students without a usable enrolment.
300    pub credit_registration_has_enrolment_link: bool,
301}
302
303#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
304pub struct AutomaticCompletionRequirements {
305    /// Course module associated with these requirements.
306    pub course_module_id: Uuid,
307    pub number_of_exercises_attempted_treshold: Option<i32>,
308    pub number_of_points_treshold: Option<i32>,
309    pub requires_exam: bool,
310}
311
312impl AutomaticCompletionRequirements {
313    /// Shorthand for checking whether the given exercise related values pass their respective
314    /// tresholds.
315    pub fn passes_exercise_tresholds(
316        &self,
317        exercises_attempted: i32,
318        exercise_points: i32,
319    ) -> bool {
320        self.passes_number_of_exercises_attempted_treshold(exercises_attempted)
321            && self.passes_number_of_exercise_points_treshold(exercise_points)
322    }
323
324    /// Whether the given number is higher than the exercises attempted treshold. Always returns
325    /// true if there is no treshold.
326    pub fn passes_number_of_exercises_attempted_treshold(&self, exercises_attempted: i32) -> bool {
327        self.number_of_exercises_attempted_treshold
328            .map(|x| x <= exercises_attempted)
329            .unwrap_or(true)
330    }
331
332    /// Whether the given number is higher than the exercise points treshold. Always returns true
333    /// if there is no treshold.
334    pub fn passes_number_of_exercise_points_treshold(&self, exercise_points: i32) -> bool {
335        self.number_of_points_treshold
336            .map(|x| x <= exercise_points)
337            .unwrap_or(true)
338    }
339}
340
341#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)]
342#[serde(tag = "policy", rename_all = "kebab-case")]
343pub enum CompletionPolicy {
344    Automatic(AutomaticCompletionRequirements),
345    Manual,
346}
347
348impl CompletionPolicy {
349    /// Returns associated data for `Automatic` variant, if matches.
350    pub fn automatic(&self) -> Option<&AutomaticCompletionRequirements> {
351        match self {
352            CompletionPolicy::Automatic(requirements) => Some(requirements),
353            CompletionPolicy::Manual => None,
354        }
355    }
356
357    fn to_database_fields(&self) -> (bool, Option<i32>, Option<i32>, bool) {
358        match self {
359            CompletionPolicy::Automatic(requirements) => (
360                true,
361                requirements.number_of_exercises_attempted_treshold,
362                requirements.number_of_points_treshold,
363                requirements.requires_exam,
364            ),
365            CompletionPolicy::Manual => (false, None, None, false),
366        }
367    }
368}
369
370pub async fn insert(
371    conn: &mut PgConnection,
372    pkey_policy: PKeyPolicy<Uuid>,
373    new_course_module: &NewCourseModule,
374) -> ModelResult<CourseModule> {
375    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
376        new_course_module.completion_policy.to_database_fields();
377    let res = sqlx::query_as!(
378        CourseModulesSchema,
379        "
380INSERT INTO course_modules (
381    id,
382    course_id,
383    name,
384    order_number,
385    automatic_completion,
386    automatic_completion_number_of_exercises_attempted_treshold,
387    automatic_completion_number_of_points_treshold,
388    automatic_completion_requires_exam,
389    ects_credits,
390    enable_registering_completion_to_uh_open_university,
391    uh_course_code,
392    enable_credit_registration_via_suotar,
393    completion_registration_link_override
394  )
395VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
396RETURNING id,
397  created_at,
398  updated_at,
399  deleted_at,
400  name,
401  course_id,
402  order_number,
403  copied_from,
404  uh_course_code,
405  automatic_completion,
406  automatic_completion_number_of_exercises_attempted_treshold,
407  automatic_completion_number_of_points_treshold,
408  automatic_completion_requires_exam,
409  completion_registration_link_override,
410  ects_credits,
411  enable_registering_completion_to_uh_open_university,
412  certification_enabled,
413  enable_credit_registration_via_suotar
414        ",
415        pkey_policy.into_uuid(),
416        new_course_module.course_id,
417        new_course_module.name,
418        new_course_module.order_number,
419        automatic_completion,
420        exercises_treshold,
421        points_treshold,
422        requires_exam,
423        new_course_module.ects_credits,
424        new_course_module.enable_registering_completion_to_uh_open_university,
425        new_course_module.uh_course_code,
426        new_course_module.enable_credit_registration_via_suotar,
427        new_course_module.completion_registration_link_override,
428    )
429    .fetch_one(conn)
430    .await?;
431    Ok(res.into())
432}
433
434pub async fn rename(conn: &mut PgConnection, id: Uuid, name: &str) -> ModelResult<()> {
435    sqlx::query!(
436        "
437UPDATE course_modules
438SET name = $1
439WHERE id = $2
440",
441        name,
442        id
443    )
444    .execute(conn)
445    .await?;
446    Ok(())
447}
448
449pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
450    let associated_chapters = chapters::get_for_module(conn, id).await?;
451    if !associated_chapters.is_empty() {
452        return Err(ModelError::new(
453            ModelErrorType::InvalidRequest,
454            format!(
455                "Cannot remove module {id} because it has {} chapters associated with it",
456                associated_chapters.len()
457            ),
458            None,
459        ));
460    }
461    sqlx::query!(
462        "
463UPDATE course_modules
464SET deleted_at = now()
465WHERE id = $1
466AND deleted_at IS NULL
467",
468        id
469    )
470    .execute(conn)
471    .await?;
472    Ok(())
473}
474
475pub async fn get_all_modules(conn: &mut PgConnection) -> ModelResult<Vec<CourseModule>> {
476    let res = sqlx::query_as!(
477        CourseModulesSchema,
478        "
479SELECT id,
480  created_at,
481  updated_at,
482  deleted_at,
483  name,
484  course_id,
485  order_number,
486  copied_from,
487  uh_course_code,
488  automatic_completion,
489  automatic_completion_number_of_exercises_attempted_treshold,
490  automatic_completion_number_of_points_treshold,
491  automatic_completion_requires_exam,
492  completion_registration_link_override,
493  ects_credits,
494  enable_registering_completion_to_uh_open_university,
495  certification_enabled,
496  enable_credit_registration_via_suotar
497FROM course_modules
498WHERE deleted_at IS NULL
499        ",
500    )
501    .map(|x| x.into())
502    .fetch_all(conn)
503    .await?;
504    Ok(res)
505}
506
507pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModule> {
508    let res = sqlx::query_as!(
509        CourseModulesSchema,
510        "
511SELECT id,
512  created_at,
513  updated_at,
514  deleted_at,
515  name,
516  course_id,
517  order_number,
518  copied_from,
519  uh_course_code,
520  automatic_completion,
521  automatic_completion_number_of_exercises_attempted_treshold,
522  automatic_completion_number_of_points_treshold,
523  automatic_completion_requires_exam,
524  completion_registration_link_override,
525  ects_credits,
526  enable_registering_completion_to_uh_open_university,
527  certification_enabled,
528  enable_credit_registration_via_suotar
529FROM course_modules
530WHERE id = $1
531  AND deleted_at IS NULL
532        ",
533        id,
534    )
535    .fetch_one(conn)
536    .await?;
537    Ok(res.into())
538}
539
540pub async fn get_by_ids(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<Vec<CourseModule>> {
541    let res = sqlx::query_as!(
542        CourseModulesSchema,
543        "
544SELECT id,
545  created_at,
546  updated_at,
547  deleted_at,
548  name,
549  course_id,
550  order_number,
551  copied_from,
552  uh_course_code,
553  automatic_completion,
554  automatic_completion_number_of_exercises_attempted_treshold,
555  automatic_completion_number_of_points_treshold,
556  automatic_completion_requires_exam,
557  completion_registration_link_override,
558  ects_credits,
559  enable_registering_completion_to_uh_open_university,
560  certification_enabled,
561  enable_credit_registration_via_suotar
562FROM course_modules
563WHERE id = ANY($1)
564  AND deleted_at IS NULL
565        ",
566        ids,
567    )
568    .map(|x| x.into())
569    .fetch_all(conn)
570    .await?;
571    Ok(res)
572}
573
574pub async fn get_by_course_id(
575    conn: &mut PgConnection,
576    course_id: Uuid,
577) -> ModelResult<Vec<CourseModule>> {
578    let res = sqlx::query_as!(
579        CourseModulesSchema,
580        "
581SELECT id,
582  created_at,
583  updated_at,
584  deleted_at,
585  name,
586  course_id,
587  order_number,
588  copied_from,
589  uh_course_code,
590  automatic_completion,
591  automatic_completion_number_of_exercises_attempted_treshold,
592  automatic_completion_number_of_points_treshold,
593  automatic_completion_requires_exam,
594  completion_registration_link_override,
595  ects_credits,
596  enable_registering_completion_to_uh_open_university,
597  certification_enabled,
598  enable_credit_registration_via_suotar
599FROM course_modules
600WHERE course_id = $1
601AND deleted_at IS NULL
602",
603        course_id
604    )
605    .map(|x| x.into())
606    .fetch_all(conn)
607    .await?;
608    Ok(res)
609}
610
611/// Batched [`get_by_course_id`]: modules for many courses in one query, to group in memory instead
612/// of one query per course.
613pub async fn get_by_course_ids(
614    conn: &mut PgConnection,
615    course_ids: &[Uuid],
616) -> ModelResult<Vec<CourseModule>> {
617    let res = sqlx::query_as!(
618        CourseModulesSchema,
619        "
620SELECT id,
621  created_at,
622  updated_at,
623  deleted_at,
624  name,
625  course_id,
626  order_number,
627  copied_from,
628  uh_course_code,
629  automatic_completion,
630  automatic_completion_number_of_exercises_attempted_treshold,
631  automatic_completion_number_of_points_treshold,
632  automatic_completion_requires_exam,
633  completion_registration_link_override,
634  ects_credits,
635  enable_registering_completion_to_uh_open_university,
636  certification_enabled,
637  enable_credit_registration_via_suotar
638FROM course_modules
639WHERE course_id = ANY($1)
640AND deleted_at IS NULL
641",
642        course_ids
643    )
644    .map(|x| x.into())
645    .fetch_all(conn)
646    .await?;
647    Ok(res)
648}
649
650pub async fn get_by_course_id_only_with_open_chapters(
651    conn: &mut PgConnection,
652    course_id: Uuid,
653) -> ModelResult<Vec<CourseModule>> {
654    let res = sqlx::query_as!(
655        CourseModulesSchema,
656        "
657SELECT cm.id,
658  cm.created_at,
659  cm.updated_at,
660  cm.deleted_at,
661  cm.name,
662  cm.course_id,
663  cm.order_number,
664  cm.copied_from,
665  cm.uh_course_code,
666  cm.automatic_completion,
667  cm.automatic_completion_number_of_exercises_attempted_treshold,
668  cm.automatic_completion_number_of_points_treshold,
669  cm.automatic_completion_requires_exam,
670  cm.completion_registration_link_override,
671  cm.ects_credits,
672  cm.enable_registering_completion_to_uh_open_university,
673  cm.certification_enabled,
674  enable_credit_registration_via_suotar
675FROM course_modules as cm
676WHERE EXISTS (
677  SELECT 1
678  FROM chapters as ch
679  WHERE ch.course_module_id = cm.id
680    AND ((ch.opens_at < now()) OR ch.opens_at IS NULL)
681    AND ch.deleted_at IS NULL
682)
683  AND cm.course_id = $1
684  AND cm.deleted_at IS NULL
685",
686        course_id
687    )
688    .map(|x| x.into())
689    .fetch_all(conn)
690    .await?;
691    Ok(res)
692}
693
694/// Gets course module where the given exercise belongs to. This will result in an error in the case
695/// of an exam exercise.
696pub async fn get_by_exercise_id(
697    conn: &mut PgConnection,
698    exercise_id: Uuid,
699) -> ModelResult<CourseModule> {
700    let res = sqlx::query_as!(
701        CourseModulesSchema,
702        r#"
703SELECT course_modules.id AS "id!",
704  course_modules.created_at AS "created_at!",
705  course_modules.updated_at AS "updated_at!",
706  course_modules.deleted_at,
707  course_modules.name,
708  course_modules.course_id AS "course_id!",
709  course_modules.order_number AS "order_number!",
710  course_modules.copied_from,
711  course_modules.uh_course_code,
712  course_modules.automatic_completion AS "automatic_completion!",
713  course_modules.automatic_completion_number_of_exercises_attempted_treshold,
714  course_modules.automatic_completion_number_of_points_treshold,
715  course_modules.automatic_completion_requires_exam AS "automatic_completion_requires_exam!",
716  course_modules.completion_registration_link_override,
717  course_modules.ects_credits,
718  course_modules.enable_registering_completion_to_uh_open_university AS "enable_registering_completion_to_uh_open_university!",
719  course_modules.certification_enabled AS "certification_enabled!",
720  course_modules.enable_credit_registration_via_suotar AS "enable_credit_registration_via_suotar!"
721FROM exercises
722  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
723  LEFT JOIN course_modules ON (chapters.course_module_id = course_modules.id)
724WHERE exercises.id = $1
725  AND chapters.deleted_at IS NULL
726  AND course_modules.deleted_at IS NULL
727        "#,
728        exercise_id,
729    )
730    .fetch_one(conn)
731    .await?;
732    Ok(res.into())
733}
734
735pub async fn get_course_module_id_by_chapter(
736    conn: &mut PgConnection,
737    chapter_id: Uuid,
738) -> ModelResult<Uuid> {
739    let res: Uuid = sqlx::query!(
740        r#"
741SELECT c.course_module_id
742from chapters c
743where c.id = $1
744  AND deleted_at IS NULL
745  "#,
746        chapter_id
747    )
748    .map(|record| record.course_module_id)
749    .fetch_one(conn)
750    .await?;
751    Ok(res)
752}
753
754pub async fn get_chapter_and_exercise_counts(
755    conn: &mut PgConnection,
756    course_module_id: Uuid,
757) -> ModelResult<ModuleSizeCounts> {
758    let res = sqlx::query_as!(
759        ModuleSizeCounts,
760        r#"
761SELECT COUNT(DISTINCT c.id) AS "chapters!",
762  COUNT(e.id) AS "exercises!"
763FROM chapters c
764  LEFT JOIN exercises e ON e.chapter_id = c.id
765  AND e.deleted_at IS NULL
766WHERE c.course_module_id = $1
767  AND c.deleted_at IS NULL
768        "#,
769        course_module_id
770    )
771    .fetch_one(conn)
772    .await?;
773    Ok(res)
774}
775
776pub async fn get_default_by_course_id(
777    conn: &mut PgConnection,
778    course_id: Uuid,
779) -> ModelResult<CourseModule> {
780    let res = sqlx::query_as!(
781        CourseModulesSchema,
782        "
783SELECT id,
784  created_at,
785  updated_at,
786  deleted_at,
787  name,
788  course_id,
789  order_number,
790  copied_from,
791  uh_course_code,
792  automatic_completion,
793  automatic_completion_number_of_exercises_attempted_treshold,
794  automatic_completion_number_of_points_treshold,
795  automatic_completion_requires_exam,
796  completion_registration_link_override,
797  ects_credits,
798  enable_registering_completion_to_uh_open_university,
799  certification_enabled,
800  enable_credit_registration_via_suotar
801FROM course_modules
802WHERE course_id = $1
803  AND name IS NULL
804  AND deleted_at IS NULL
805        ",
806        course_id,
807    )
808    .fetch_one(conn)
809    .await?;
810    Ok(res.into())
811}
812
813/// Gets all course modules with a matching `uh_course_code` or course `slug`.
814///
815/// In the latter case only one record at most is returned, but there is no way to distinguish between
816/// these two scenarios in advance.
817pub async fn get_ids_by_course_slug_or_uh_course_code(
818    conn: &mut PgConnection,
819    course_slug_or_code: &str,
820) -> ModelResult<Vec<Uuid>> {
821    let res = sqlx::query!(
822        r#"
823SELECT course_modules.id AS "id!"
824FROM course_modules
825  LEFT JOIN courses ON (course_modules.course_id = courses.id)
826WHERE (
827    course_modules.uh_course_code = $1
828    OR courses.slug = $1
829  )
830  AND course_modules.deleted_at IS NULL
831        "#,
832        course_slug_or_code,
833    )
834    .map(|record| record.id)
835    .fetch_all(conn)
836    .await?;
837    Ok(res)
838}
839
840/// Gets course modules for the given course as a map, indexed by the `id` field.
841pub async fn get_by_course_id_as_map(
842    conn: &mut PgConnection,
843    course_id: Uuid,
844) -> ModelResult<HashMap<Uuid, CourseModule>> {
845    let res = get_by_course_id(conn, course_id)
846        .await?
847        .into_iter()
848        .map(|course_module| (course_module.id, course_module))
849        .collect();
850    Ok(res)
851}
852
853pub async fn get_all_uh_course_codes_for_open_university(
854    conn: &mut PgConnection,
855) -> ModelResult<Vec<String>> {
856    let res = sqlx::query!(
857        "
858SELECT DISTINCT uh_course_code
859FROM course_modules
860WHERE uh_course_code IS NOT NULL
861  AND enable_registering_completion_to_uh_open_university = true
862  AND deleted_at IS NULL
863"
864    )
865    .fetch_all(conn)
866    .await?
867    .into_iter()
868    .filter_map(|x| x.uh_course_code)
869    .collect();
870    Ok(res)
871}
872
873/// Ids of the course's modules opted in to credit registration; the flag is not on the
874/// [`CourseModule`] DTO.
875pub async fn get_credit_registration_enabled_ids_for_course(
876    conn: &mut PgConnection,
877    course_id: Uuid,
878) -> ModelResult<Vec<Uuid>> {
879    let res = sqlx::query_scalar!(
880        "
881SELECT id
882FROM course_modules
883WHERE course_id = $1
884  AND enable_credit_registration_via_suotar
885  AND deleted_at IS NULL
886ORDER BY order_number
887        ",
888        course_id
889    )
890    .fetch_all(conn)
891    .await?;
892    Ok(res)
893}
894
895/// Ids of the courses' modules whose new completions go through credit registration via Suotar when
896/// the student holds a linked student number.
897pub async fn get_ids_registering_eligible_new_completions_via_suotar(
898    conn: &mut PgConnection,
899    course_ids: &[Uuid],
900) -> ModelResult<Vec<Uuid>> {
901    let res = sqlx::query_scalar!(
902        "
903SELECT id
904FROM course_modules
905WHERE course_id = ANY($1)
906  AND enable_credit_registration_via_suotar
907  AND register_eligible_new_completions_via_suotar
908  AND deleted_at IS NULL
909        ",
910        course_ids
911    )
912    .fetch_all(conn)
913    .await?;
914    Ok(res)
915}
916
917/// The gate for admin actions that are not tied to one course (e.g. resolving or manually
918/// linking a student number), where `get_credit_registration_enabled_ids_for_course` has no
919/// course to check against.
920pub async fn any_credit_registration_enabled(conn: &mut PgConnection) -> ModelResult<bool> {
921    let res = sqlx::query_scalar!(
922        r#"
923SELECT EXISTS(
924  SELECT 1
925  FROM course_modules
926  WHERE enable_credit_registration_via_suotar
927    AND deleted_at IS NULL
928) AS "exists!"
929        "#
930    )
931    .fetch_one(conn)
932    .await?;
933    Ok(res)
934}
935
936/// Every course the user is enrolled on that has at least one module opted in to credit
937/// registration, so the profile page can list a course the student was never asked about.
938pub async fn get_credit_registration_course_ids_for_enrolled_user(
939    conn: &mut PgConnection,
940    user_id: Uuid,
941) -> ModelResult<Vec<Uuid>> {
942    let res = sqlx::query_scalar!(
943        "
944SELECT DISTINCT cm.course_id
945FROM course_modules cm
946  JOIN course_instance_enrollments cie ON cie.course_id = cm.course_id
947  JOIN courses c ON c.id = cm.course_id
948WHERE cie.user_id = $1
949  AND cie.deleted_at IS NULL
950  AND cm.enable_credit_registration_via_suotar
951  AND cm.deleted_at IS NULL
952  AND c.deleted_at IS NULL
953        ",
954        user_id
955    )
956    .fetch_all(conn)
957    .await?;
958    Ok(res)
959}
960
961/// Shared by every getter below, so the 13-column join lives in one place. `enabled_only` isn't a
962/// "this id or any" filter like the other two: the by-id and by-course lookups want every module,
963/// opted in or not, while the all-modules listing wants only the opted-in ones.
964async fn credit_registration_configs(
965    conn: &mut PgConnection,
966    course_module_id: Option<Uuid>,
967    course_id: Option<Uuid>,
968    enabled_only: bool,
969) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
970    let res = sqlx::query_as!(
971        CourseModuleCreditRegistrationConfig,
972        r#"
973SELECT cm.id AS course_module_id,
974  cm.course_id,
975  cm.enable_credit_registration_via_suotar,
976  cm.uh_course_code,
977  cm.ects_credits,
978  c.paused_at AS "credit_registration_paused_at?",
979  c.paused_by_user_id AS "credit_registration_paused_by_user_id?",
980  c.pause_reason AS "credit_registration_pause_reason?",
981  c.config_checked_at AS "credit_registration_config_checked_at?",
982  c.course_code_allowed AS "credit_registration_course_code_allowed?",
983  c.config_check_message AS "credit_registration_config_check_message?",
984  COALESCE(TRIM(cm.completion_registration_link_override) <> '', FALSE) AS "credit_registration_has_enrolment_link!"
985FROM course_modules cm
986  LEFT JOIN course_module_suotar_configurations c ON c.course_module_id = cm.id
987  AND c.deleted_at IS NULL
988WHERE ($1::uuid IS NULL OR cm.id = $1)
989  AND ($2::uuid IS NULL OR cm.course_id = $2)
990  AND (NOT $3::bool OR cm.enable_credit_registration_via_suotar)
991  AND cm.deleted_at IS NULL
992ORDER BY cm.course_id,
993  cm.order_number
994        "#,
995        course_module_id,
996        course_id,
997        enabled_only,
998    )
999    .fetch_all(conn)
1000    .await?;
1001    Ok(res)
1002}
1003
1004pub async fn get_credit_registration_config(
1005    conn: &mut PgConnection,
1006    course_module_id: Uuid,
1007) -> ModelResult<CourseModuleCreditRegistrationConfig> {
1008    credit_registration_configs(conn, Some(course_module_id), None, false)
1009        .await?
1010        .into_iter()
1011        .next()
1012        .ok_or_else(missing_model_error(
1013            ModelErrorType::RecordNotFound,
1014            "Course module not found".to_string(),
1015        ))
1016}
1017
1018/// Every module of one course with its Suotar configuration, opted in or not: the module editor has
1019/// to show an unconfigured module's empty fields.
1020pub async fn get_credit_registration_configs_by_course_id(
1021    conn: &mut PgConnection,
1022    course_id: Uuid,
1023) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
1024    credit_registration_configs(conn, None, Some(course_id), false).await
1025}
1026
1027/// Every module opted in to credit registration via Suotar, paused ones included.
1028pub async fn get_all_suotar_enabled(
1029    conn: &mut PgConnection,
1030) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
1031    credit_registration_configs(conn, None, None, true).await
1032}
1033
1034pub async fn update_automatic_completion_status(
1035    conn: &mut PgConnection,
1036    id: Uuid,
1037    automatic_completion_policy: &CompletionPolicy,
1038) -> ModelResult<CourseModule> {
1039    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
1040        automatic_completion_policy.to_database_fields();
1041    let res = sqlx::query_as!(
1042        CourseModulesSchema,
1043        "
1044UPDATE course_modules
1045SET automatic_completion = $1,
1046  automatic_completion_number_of_exercises_attempted_treshold = $2,
1047  automatic_completion_number_of_points_treshold = $3,
1048  automatic_completion_requires_exam = $4
1049WHERE id = $5
1050  AND deleted_at IS NULL
1051RETURNING id,
1052  created_at,
1053  updated_at,
1054  deleted_at,
1055  name,
1056  course_id,
1057  order_number,
1058  copied_from,
1059  uh_course_code,
1060  automatic_completion,
1061  automatic_completion_number_of_exercises_attempted_treshold,
1062  automatic_completion_number_of_points_treshold,
1063  automatic_completion_requires_exam,
1064  completion_registration_link_override,
1065  ects_credits,
1066  enable_registering_completion_to_uh_open_university,
1067  certification_enabled,
1068  enable_credit_registration_via_suotar
1069        ",
1070        automatic_completion,
1071        exercises_treshold,
1072        points_treshold,
1073        requires_exam,
1074        id,
1075    )
1076    .fetch_one(conn)
1077    .await?;
1078    Ok(res.into())
1079}
1080
1081pub async fn update_uh_course_code(
1082    conn: &mut PgConnection,
1083    id: Uuid,
1084    uh_course_code: Option<String>,
1085) -> ModelResult<CourseModule> {
1086    let res = sqlx::query_as!(
1087        CourseModulesSchema,
1088        "
1089UPDATE course_modules
1090SET uh_course_code = $1
1091WHERE id = $2
1092  AND deleted_at IS NULL
1093RETURNING id,
1094  created_at,
1095  updated_at,
1096  deleted_at,
1097  name,
1098  course_id,
1099  order_number,
1100  copied_from,
1101  uh_course_code,
1102  automatic_completion,
1103  automatic_completion_number_of_exercises_attempted_treshold,
1104  automatic_completion_number_of_points_treshold,
1105  automatic_completion_requires_exam,
1106  completion_registration_link_override,
1107  ects_credits,
1108  enable_registering_completion_to_uh_open_university,
1109  certification_enabled,
1110  enable_credit_registration_via_suotar
1111        ",
1112        uh_course_code,
1113        id,
1114    )
1115    .fetch_one(conn)
1116    .await?;
1117    Ok(res.into())
1118}
1119
1120pub async fn update_enable_registering_completion_to_uh_open_university(
1121    conn: &mut PgConnection,
1122    id: Uuid,
1123    enable_registering_completion_to_uh_open_university: bool,
1124) -> ModelResult<CourseModule> {
1125    let res = sqlx::query_as!(
1126        CourseModulesSchema,
1127        "
1128UPDATE course_modules
1129SET enable_registering_completion_to_uh_open_university = $1
1130WHERE id = $2
1131  AND deleted_at IS NULL
1132RETURNING id,
1133  created_at,
1134  updated_at,
1135  deleted_at,
1136  name,
1137  course_id,
1138  order_number,
1139  copied_from,
1140  uh_course_code,
1141  automatic_completion,
1142  automatic_completion_number_of_exercises_attempted_treshold,
1143  automatic_completion_number_of_points_treshold,
1144  automatic_completion_requires_exam,
1145  completion_registration_link_override,
1146  ects_credits,
1147  enable_registering_completion_to_uh_open_university,
1148  certification_enabled,
1149  enable_credit_registration_via_suotar
1150        ",
1151        enable_registering_completion_to_uh_open_university,
1152        id,
1153    )
1154    .fetch_one(conn)
1155    .await?;
1156    Ok(res.into())
1157}
1158
1159pub async fn update_with_order_number(
1160    conn: &mut PgConnection,
1161    id: Uuid,
1162    name: Option<&str>,
1163    order_number: i32,
1164) -> ModelResult<()> {
1165    sqlx::query!(
1166        "
1167UPDATE course_modules
1168SET name = COALESCE($1, name),
1169  order_number = $2
1170WHERE id = $3
1171",
1172        name,
1173        order_number,
1174        id,
1175    )
1176    .execute(conn)
1177    .await?;
1178    Ok(())
1179}
1180
1181pub async fn update(
1182    conn: &mut PgConnection,
1183    id: Uuid,
1184    updated_course_module: &NewCourseModule,
1185) -> ModelResult<()> {
1186    // destructure so new fields cause a compilation error here
1187    let NewCourseModule {
1188        completion_policy: _,
1189        course_id: _,
1190        ects_credits,
1191        order_number,
1192        name,
1193        uh_course_code,
1194        completion_registration_link_override,
1195        enable_registering_completion_to_uh_open_university,
1196        enable_credit_registration_via_suotar,
1197    } = updated_course_module;
1198    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
1199        updated_course_module.completion_policy.to_database_fields();
1200    sqlx::query!(
1201        "
1202UPDATE course_modules
1203SET name = COALESCE($2, name),
1204  order_number = $3,
1205  uh_course_code = $4,
1206  ects_credits = $5,
1207  automatic_completion = $6,
1208  automatic_completion_number_of_exercises_attempted_treshold = $7,
1209  automatic_completion_number_of_points_treshold = $8,
1210  automatic_completion_requires_exam = $9,
1211  completion_registration_link_override = $10,
1212  enable_registering_completion_to_uh_open_university = $11,
1213  enable_credit_registration_via_suotar = $12,
1214  register_eligible_new_completions_via_suotar = register_eligible_new_completions_via_suotar
1215  AND $12
1216WHERE id = $1
1217        ",
1218        id,
1219        name.as_ref(),
1220        order_number,
1221        uh_course_code.as_ref(),
1222        ects_credits.as_ref(),
1223        automatic_completion,
1224        exercises_treshold,
1225        points_treshold,
1226        requires_exam,
1227        completion_registration_link_override.as_ref(),
1228        enable_registering_completion_to_uh_open_university,
1229        enable_credit_registration_via_suotar
1230    )
1231    .execute(conn)
1232    .await?;
1233    Ok(())
1234}
1235
1236/// Sets `register_eligible_new_completions_via_suotar`, which production sets by hand only. Errors
1237/// if the module is not opted in to credit registration via Suotar.
1238pub async fn set_register_eligible_new_completions_via_suotar(
1239    conn: &mut PgConnection,
1240    id: Uuid,
1241    value: bool,
1242) -> ModelResult<()> {
1243    sqlx::query!(
1244        "
1245UPDATE course_modules
1246SET register_eligible_new_completions_via_suotar = $2
1247WHERE id = $1
1248        ",
1249        id,
1250        value,
1251    )
1252    .execute(conn)
1253    .await?;
1254    Ok(())
1255}
1256
1257/// Whether applying `updates` to `course_id` would flip `enable_credit_registration_via_suotar` on
1258/// any module.
1259///
1260/// Only support may put a module on the live study registry path, but the module editor round-trips
1261/// the flag on every save, so gate on an actual change rather than on the field being sent. A module
1262/// the update creates, and one not in the course, count as currently off; [`update_modules`] rejects
1263/// the latter.
1264pub async fn would_change_credit_registration_via_suotar(
1265    conn: &mut PgConnection,
1266    course_id: Uuid,
1267    updates: &ModuleUpdates,
1268) -> ModelResult<bool> {
1269    if updates
1270        .new_modules
1271        .iter()
1272        .any(|module| module.enable_credit_registration_via_suotar)
1273    {
1274        return Ok(true);
1275    }
1276    let stored: HashMap<Uuid, bool> = get_by_course_id(conn, course_id)
1277        .await?
1278        .into_iter()
1279        .map(|module| (module.id, module.enable_credit_registration_via_suotar))
1280        .collect();
1281    Ok(updates.modified_modules.iter().any(|module| {
1282        stored.get(&module.id).copied().unwrap_or(false)
1283            != module.enable_credit_registration_via_suotar
1284    }))
1285}
1286
1287/// Errors with `InvalidRequest` unless `link` is empty or an absolute `https://` URL. Students get
1288/// the link as an `href`, so no other scheme may be stored.
1289pub fn validate_completion_registration_link(link: Option<&str>) -> ModelResult<()> {
1290    let Some(link) = link.map(str::trim).filter(|link| !link.is_empty()) else {
1291        return Ok(());
1292    };
1293    match Url::parse(link) {
1294        Ok(url) if url.scheme() == "https" && url.host().is_some() => Ok(()),
1295        _ => Err(model_err!(
1296            InvalidRequest,
1297            "The completion registration link must be an absolute https:// URL.".to_string()
1298        )),
1299    }
1300}
1301
1302/// Errors with `InvalidRequest` unless every module and chapter id is a live row of `course_id`.
1303async fn ensure_belong_to_course(
1304    conn: &mut PgConnection,
1305    course_id: Uuid,
1306    module_ids: &[Uuid],
1307    chapter_ids: &[Uuid],
1308) -> ModelResult<()> {
1309    let res = sqlx::query!(
1310        r#"
1311SELECT NOT EXISTS (
1312    SELECT 1
1313    FROM UNNEST($2::uuid []) AS ids(id)
1314    WHERE NOT EXISTS (
1315        SELECT 1
1316        FROM course_modules cm
1317        WHERE cm.id = ids.id
1318          AND cm.course_id = $1
1319          AND cm.deleted_at IS NULL
1320      )
1321  )
1322  AND NOT EXISTS (
1323    SELECT 1
1324    FROM UNNEST($3::uuid []) AS ids(id)
1325    WHERE NOT EXISTS (
1326        SELECT 1
1327        FROM chapters c
1328        WHERE c.id = ids.id
1329          AND c.course_id = $1
1330          AND c.deleted_at IS NULL
1331      )
1332  ) AS "all_belong!"
1333"#,
1334        course_id,
1335        module_ids,
1336        chapter_ids,
1337    )
1338    .fetch_one(conn)
1339    .await?;
1340    if !res.all_belong {
1341        return Err(model_err!(
1342            InvalidRequest,
1343            "Every module and chapter in the update must belong to the course.".to_string()
1344        ));
1345    }
1346    Ok(())
1347}
1348
1349/// Applies the module editor's changes to `course_id` in one transaction.
1350///
1351/// Rejects the whole update if any referenced module or chapter is not in the course, or if a
1352/// completion registration link fails [`validate_completion_registration_link`].
1353pub async fn update_modules(
1354    conn: &mut PgConnection,
1355    course_id: Uuid,
1356    updates: ModuleUpdates,
1357) -> ModelResult<()> {
1358    for link in updates
1359        .new_modules
1360        .iter()
1361        .map(|m| &m.completion_registration_link_override)
1362        .chain(
1363            updates
1364                .modified_modules
1365                .iter()
1366                .map(|m| &m.completion_registration_link_override),
1367        )
1368    {
1369        validate_completion_registration_link(link.as_deref())?;
1370    }
1371
1372    let mut tx = conn.begin().await?;
1373
1374    let module_ids: Vec<Uuid> = updates
1375        .modified_modules
1376        .iter()
1377        .map(|m| m.id)
1378        .chain(updates.deleted_modules.iter().copied())
1379        .chain(updates.moved_chapters.iter().map(|(_, module)| *module))
1380        .collect();
1381    let chapter_ids: Vec<Uuid> = updates
1382        .new_modules
1383        .iter()
1384        .flat_map(|m| m.chapters.iter().copied())
1385        .chain(updates.moved_chapters.iter().map(|(chapter, _)| *chapter))
1386        .collect();
1387    ensure_belong_to_course(&mut tx, course_id, &module_ids, &chapter_ids).await?;
1388
1389    // scramble order of modified and deleted modules
1390    for module_id in updates
1391        .modified_modules
1392        .iter()
1393        // do not scramble the default module, it should always be first
1394        .filter(|m| m.order_number != 0)
1395        .map(|m| m.id)
1396        .chain(updates.deleted_modules.iter().copied())
1397    {
1398        update_with_order_number(&mut tx, module_id, None, rand::random()).await?;
1399    }
1400    let mut modified_and_new_modules = updates.modified_modules;
1401    for new in updates.new_modules {
1402        // destructure so new fields cause a compilation error here
1403        let NewModule {
1404            name,
1405            order_number,
1406            chapters,
1407            uh_course_code,
1408            ects_credits,
1409            completion_policy,
1410            completion_registration_link_override,
1411            enable_registering_completion_to_uh_open_university,
1412            enable_credit_registration_via_suotar,
1413        } = new;
1414        // insert with a random order number to avoid conflicts
1415        let new_course_module = NewCourseModule::new(course_id, Some(name.clone()), rand::random())
1416            .set_completion_policy(completion_policy.clone())
1417            .set_completion_registration_link_override(completion_registration_link_override)
1418            .set_ects_credits(ects_credits)
1419            .set_uh_course_code(uh_course_code)
1420            .set_enable_registering_completion_to_uh_open_university(
1421                enable_registering_completion_to_uh_open_university,
1422            )
1423            .set_enable_credit_registration_via_suotar(enable_credit_registration_via_suotar);
1424        let module = insert(&mut tx, PKeyPolicy::Generate, &new_course_module).await?;
1425        for chapter in chapters {
1426            chapters::set_module(&mut tx, chapter, module.id).await?;
1427        }
1428        //modify the order number with the rest
1429        modified_and_new_modules.push(ModifiedModule {
1430            id: module.id,
1431            name: None,
1432            order_number,
1433            uh_course_code: module.uh_course_code,
1434            ects_credits,
1435            completion_policy,
1436            completion_registration_link_override: module.completion_registration_link_override,
1437            enable_registering_completion_to_uh_open_university: module
1438                .enable_registering_completion_to_uh_open_university,
1439            enable_credit_registration_via_suotar,
1440        })
1441    }
1442    // update modified and new modules
1443    for module in modified_and_new_modules {
1444        // destructure so new fields cause a compilation error here
1445        let ModifiedModule {
1446            id,
1447            name,
1448            order_number,
1449            uh_course_code,
1450            ects_credits,
1451            completion_policy,
1452            completion_registration_link_override,
1453            enable_registering_completion_to_uh_open_university,
1454            enable_credit_registration_via_suotar,
1455        } = module;
1456        update(
1457            &mut tx,
1458            id,
1459            &NewCourseModule::new(course_id, name.clone(), order_number)
1460                .set_completion_policy(completion_policy)
1461                .set_completion_registration_link_override(completion_registration_link_override)
1462                .set_ects_credits(ects_credits)
1463                .set_uh_course_code(uh_course_code)
1464                .set_enable_registering_completion_to_uh_open_university(
1465                    enable_registering_completion_to_uh_open_university,
1466                )
1467                .set_enable_credit_registration_via_suotar(enable_credit_registration_via_suotar),
1468        )
1469        .await?;
1470    }
1471    for (chapter, module) in updates.moved_chapters {
1472        chapters::set_module(&mut tx, chapter, module).await?;
1473    }
1474    for deleted in updates.deleted_modules {
1475        delete(&mut tx, deleted).await?;
1476    }
1477
1478    tx.commit().await?;
1479    Ok(())
1480}
1481
1482pub async fn update_certification_enabled(
1483    conn: &mut PgConnection,
1484    id: Uuid,
1485    enabled: bool,
1486) -> ModelResult<()> {
1487    sqlx::query!(
1488        "
1489UPDATE course_modules
1490SET certification_enabled = $1
1491WHERE id = $2
1492",
1493        enabled,
1494        id
1495    )
1496    .execute(conn)
1497    .await?;
1498    Ok(())
1499}
1500
1501#[cfg(test)]
1502mod tests {
1503
1504    mod automatic_completion_requirements {
1505        use uuid::Uuid;
1506
1507        use super::super::AutomaticCompletionRequirements;
1508
1509        #[test]
1510        fn passes_exercise_tresholds() {
1511            let requirements1 = AutomaticCompletionRequirements {
1512                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1513                number_of_exercises_attempted_treshold: Some(10),
1514                number_of_points_treshold: Some(50),
1515                requires_exam: false,
1516            };
1517            let requirements2 = AutomaticCompletionRequirements {
1518                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1519                number_of_exercises_attempted_treshold: Some(50),
1520                number_of_points_treshold: Some(10),
1521                requires_exam: false,
1522            };
1523
1524            let requirements3 = AutomaticCompletionRequirements {
1525                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1526                number_of_exercises_attempted_treshold: Some(0),
1527                number_of_points_treshold: Some(0),
1528                requires_exam: false,
1529            };
1530
1531            let requirements4 = AutomaticCompletionRequirements {
1532                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1533                number_of_exercises_attempted_treshold: Some(10),
1534                number_of_points_treshold: None,
1535                requires_exam: false,
1536            };
1537
1538            let requirements5 = AutomaticCompletionRequirements {
1539                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1540                number_of_exercises_attempted_treshold: None,
1541                number_of_points_treshold: Some(10),
1542                requires_exam: false,
1543            };
1544            assert!(requirements1.passes_exercise_tresholds(10, 50));
1545            assert!(requirements2.passes_exercise_tresholds(50, 10));
1546
1547            assert!(!requirements1.passes_exercise_tresholds(50, 10));
1548            assert!(!requirements2.passes_exercise_tresholds(10, 50));
1549
1550            assert!(!requirements1.passes_exercise_tresholds(100, 0));
1551            assert!(!requirements2.passes_exercise_tresholds(100, 0));
1552
1553            assert!(requirements3.passes_exercise_tresholds(1, 1));
1554            assert!(requirements3.passes_exercise_tresholds(0, 0));
1555
1556            assert!(requirements4.passes_exercise_tresholds(10, 1));
1557            assert!(!requirements4.passes_exercise_tresholds(1, 10));
1558
1559            assert!(requirements5.passes_exercise_tresholds(0, 10));
1560            assert!(!requirements5.passes_exercise_tresholds(10, 0));
1561        }
1562    }
1563}