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/// Matches the columns in the database.
8struct CourseModulesSchema {
9    id: Uuid,
10    created_at: DateTime<Utc>,
11    updated_at: DateTime<Utc>,
12    deleted_at: Option<DateTime<Utc>>,
13    name: Option<String>,
14    course_id: Uuid,
15    order_number: i32,
16    copied_from: Option<Uuid>,
17    uh_course_code: Option<String>,
18    automatic_completion: bool,
19    automatic_completion_number_of_exercises_attempted_treshold: Option<i32>,
20    automatic_completion_number_of_points_treshold: Option<i32>,
21    automatic_completion_requires_exam: bool,
22    completion_registration_link_override: Option<String>,
23    ects_credits: Option<f32>,
24    enable_registering_completion_to_uh_open_university: bool,
25    certification_enabled: bool,
26}
27/**
28 * Based on [CourseModulesSchema] but completion_policy parsed and addded (and some not needeed fields removed).
29 */
30#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
31
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}
49
50impl CourseModule {
51    pub fn new(id: Uuid, course_id: Uuid) -> Self {
52        Self {
53            id,
54            created_at: Utc::now(),
55            updated_at: Utc::now(),
56            deleted_at: None,
57            name: None,
58            course_id,
59            order_number: 0,
60            copied_from: None,
61            uh_course_code: None,
62            completion_policy: CompletionPolicy::Manual,
63            completion_registration_link_override: None,
64            ects_credits: None,
65            enable_registering_completion_to_uh_open_university: false,
66            certification_enabled: false,
67        }
68    }
69    pub fn set_timestamps(
70        mut self,
71        created_at: DateTime<Utc>,
72        updated_at: DateTime<Utc>,
73        deleted_at: Option<DateTime<Utc>>,
74    ) -> Self {
75        self.created_at = created_at;
76        self.updated_at = updated_at;
77        self.deleted_at = deleted_at;
78        self
79    }
80
81    /// order_number == 0 in and only if name == None
82    pub fn set_name_and_order_number(mut self, name: Option<String>, order_number: i32) -> Self {
83        self.name = name;
84        self.order_number = order_number;
85        self
86    }
87
88    pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
89        self.completion_policy = completion_policy;
90        self
91    }
92
93    pub fn set_registration_info(
94        mut self,
95        uh_course_code: Option<String>,
96        ects_credits: Option<f32>,
97        completion_registration_link_override: Option<String>,
98        enable_registering_completion_to_uh_open_university: bool,
99    ) -> Self {
100        self.uh_course_code = uh_course_code;
101        self.ects_credits = ects_credits;
102        self.completion_registration_link_override = completion_registration_link_override;
103        self.enable_registering_completion_to_uh_open_university =
104            enable_registering_completion_to_uh_open_university;
105        self
106    }
107
108    pub fn set_certification_enabled(mut self, certification_enabled: bool) -> Self {
109        self.certification_enabled = certification_enabled;
110        self
111    }
112
113    pub fn is_default_module(&self) -> bool {
114        self.name.is_none()
115    }
116}
117
118impl From<CourseModulesSchema> for CourseModule {
119    fn from(schema: CourseModulesSchema) -> Self {
120        let completion_policy = if schema.automatic_completion {
121            CompletionPolicy::Automatic(AutomaticCompletionRequirements {
122                course_module_id: schema.id,
123                number_of_exercises_attempted_treshold: schema
124                    .automatic_completion_number_of_exercises_attempted_treshold,
125                number_of_points_treshold: schema.automatic_completion_number_of_points_treshold,
126                requires_exam: schema.automatic_completion_requires_exam,
127            })
128        } else {
129            CompletionPolicy::Manual
130        };
131        Self {
132            id: schema.id,
133            created_at: schema.created_at,
134            updated_at: schema.updated_at,
135            deleted_at: schema.deleted_at,
136            name: schema.name,
137            course_id: schema.course_id,
138            order_number: schema.order_number,
139            copied_from: schema.copied_from,
140            uh_course_code: schema.uh_course_code,
141            completion_policy,
142            completion_registration_link_override: schema.completion_registration_link_override,
143            ects_credits: schema.ects_credits,
144            enable_registering_completion_to_uh_open_university: schema
145                .enable_registering_completion_to_uh_open_university,
146            certification_enabled: schema.certification_enabled,
147        }
148    }
149}
150
151#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
152
153pub struct NewCourseModule {
154    completion_policy: CompletionPolicy,
155    completion_registration_link_override: Option<String>,
156    course_id: Uuid,
157    ects_credits: Option<f32>,
158    name: Option<String>,
159    order_number: i32,
160    uh_course_code: Option<String>,
161    enable_registering_completion_to_uh_open_university: bool,
162}
163
164impl NewCourseModule {
165    pub fn new(course_id: Uuid, name: Option<String>, order_number: i32) -> Self {
166        Self {
167            completion_policy: CompletionPolicy::Manual,
168            completion_registration_link_override: None,
169            course_id,
170            ects_credits: None,
171            name,
172            order_number,
173            uh_course_code: None,
174            enable_registering_completion_to_uh_open_university: false,
175        }
176    }
177
178    pub fn new_course_default(course_id: Uuid) -> Self {
179        Self::new(course_id, None, 0)
180    }
181
182    pub fn set_uh_course_code(mut self, uh_course_code: Option<String>) -> Self {
183        self.uh_course_code = uh_course_code;
184        self
185    }
186
187    pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
188        self.completion_policy = completion_policy;
189        self
190    }
191
192    pub fn set_completion_registration_link_override(
193        mut self,
194        completion_registration_link_override: Option<String>,
195    ) -> Self {
196        self.completion_registration_link_override = completion_registration_link_override;
197        self
198    }
199
200    pub fn set_ects_credits(mut self, ects_credits: Option<f32>) -> Self {
201        self.ects_credits = ects_credits;
202        self
203    }
204
205    pub fn set_enable_registering_completion_to_uh_open_university(
206        mut self,
207        enable_registering_completion_to_uh_open_university: bool,
208    ) -> Self {
209        self.enable_registering_completion_to_uh_open_university =
210            enable_registering_completion_to_uh_open_university;
211        self
212    }
213}
214
215pub async fn insert(
216    conn: &mut PgConnection,
217    pkey_policy: PKeyPolicy<Uuid>,
218    new_course_module: &NewCourseModule,
219) -> ModelResult<CourseModule> {
220    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
221        new_course_module.completion_policy.to_database_fields();
222    let res = sqlx::query_as!(
223        CourseModulesSchema,
224        "
225INSERT INTO course_modules (
226    id,
227    course_id,
228    name,
229    order_number,
230    automatic_completion,
231    automatic_completion_number_of_exercises_attempted_treshold,
232    automatic_completion_number_of_points_treshold,
233    automatic_completion_requires_exam,
234    ects_credits,
235    enable_registering_completion_to_uh_open_university
236  )
237VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
238RETURNING *
239        ",
240        pkey_policy.into_uuid(),
241        new_course_module.course_id,
242        new_course_module.name,
243        new_course_module.order_number,
244        automatic_completion,
245        exercises_treshold,
246        points_treshold,
247        requires_exam,
248        new_course_module.ects_credits,
249        new_course_module.enable_registering_completion_to_uh_open_university
250    )
251    .fetch_one(conn)
252    .await?;
253    Ok(res.into())
254}
255
256pub async fn rename(conn: &mut PgConnection, id: Uuid, name: &str) -> ModelResult<()> {
257    sqlx::query!(
258        "
259UPDATE course_modules
260SET name = $1
261WHERE id = $2
262",
263        name,
264        id
265    )
266    .execute(conn)
267    .await?;
268    Ok(())
269}
270
271pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
272    let associated_chapters = chapters::get_for_module(conn, id).await?;
273    if !associated_chapters.is_empty() {
274        return Err(ModelError::new(
275            ModelErrorType::InvalidRequest,
276            format!(
277                "Cannot remove module {id} because it has {} chapters associated with it",
278                associated_chapters.len()
279            ),
280            None,
281        ));
282    }
283    sqlx::query!(
284        "
285UPDATE course_modules
286SET deleted_at = now()
287WHERE id = $1
288AND deleted_at IS NULL
289",
290        id
291    )
292    .execute(conn)
293    .await?;
294    Ok(())
295}
296
297pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModule> {
298    let res = sqlx::query_as!(
299        CourseModulesSchema,
300        "
301SELECT *
302FROM course_modules
303WHERE id = $1
304  AND deleted_at IS NULL
305        ",
306        id,
307    )
308    .fetch_one(conn)
309    .await?;
310    Ok(res.into())
311}
312
313pub async fn get_by_ids(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<Vec<CourseModule>> {
314    let res = sqlx::query_as!(
315        CourseModulesSchema,
316        "
317SELECT *
318FROM course_modules
319WHERE id = ANY($1)
320  AND deleted_at IS NULL
321        ",
322        ids,
323    )
324    .map(|x| x.into())
325    .fetch_all(conn)
326    .await?;
327    Ok(res)
328}
329
330pub async fn get_by_course_id(
331    conn: &mut PgConnection,
332    course_id: Uuid,
333) -> ModelResult<Vec<CourseModule>> {
334    let res = sqlx::query_as!(
335        CourseModulesSchema,
336        "
337SELECT *
338FROM course_modules
339WHERE course_id = $1
340AND deleted_at IS NULL
341",
342        course_id
343    )
344    .map(|x| x.into())
345    .fetch_all(conn)
346    .await?;
347    Ok(res)
348}
349
350pub async fn get_by_course_id_only_with_open_chapters(
351    conn: &mut PgConnection,
352    course_id: Uuid,
353) -> ModelResult<Vec<CourseModule>> {
354    let res = sqlx::query_as!(
355        CourseModulesSchema,
356        "
357SELECT *
358FROM course_modules as cm
359WHERE EXISTS (
360  SELECT 1
361  FROM chapters as ch
362  WHERE ch.course_module_id = cm.id
363    AND ((ch.opens_at < now()) OR ch.opens_at IS NULL)
364    AND ch.deleted_at IS NULL
365)
366  AND cm.course_id = $1
367  AND cm.deleted_at IS NULL
368",
369        course_id
370    )
371    .map(|x| x.into())
372    .fetch_all(conn)
373    .await?;
374    Ok(res)
375}
376
377/// Gets course module where the given exercise belongs to. This will result in an error in the case
378/// of an exam exercise.
379pub async fn get_by_exercise_id(
380    conn: &mut PgConnection,
381    exercise_id: Uuid,
382) -> ModelResult<CourseModule> {
383    let res = sqlx::query_as!(
384        CourseModulesSchema,
385        "
386SELECT course_modules.*
387FROM exercises
388  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
389  LEFT JOIN course_modules ON (chapters.course_module_id = course_modules.id)
390WHERE exercises.id = $1
391AND chapters.deleted_at IS NULL
392AND course_modules.deleted_at IS NULL
393        ",
394        exercise_id,
395    )
396    .fetch_one(conn)
397    .await?;
398    Ok(res.into())
399}
400
401pub async fn get_course_module_id_by_chapter(
402    conn: &mut PgConnection,
403    chapter_id: Uuid,
404) -> ModelResult<Uuid> {
405    let res: Uuid = sqlx::query!(
406        r#"
407SELECT c.course_module_id
408from chapters c
409where c.id = $1
410  AND deleted_at IS NULL
411  "#,
412        chapter_id
413    )
414    .map(|record| record.course_module_id)
415    .fetch_one(conn)
416    .await?;
417    Ok(res)
418}
419
420pub async fn get_default_by_course_id(
421    conn: &mut PgConnection,
422    course_id: Uuid,
423) -> ModelResult<CourseModule> {
424    let res = sqlx::query_as!(
425        CourseModulesSchema,
426        "
427SELECT *
428FROM course_modules
429WHERE course_id = $1
430  AND name IS NULL
431  AND deleted_at IS NULL
432        ",
433        course_id,
434    )
435    .fetch_one(conn)
436    .await?;
437    Ok(res.into())
438}
439
440/// Gets all course modules with a matching `uh_course_code` or course `slug`.
441///
442/// In the latter case only one record at most is returned, but there is no way to distinguish between
443/// these two scenarios in advance.
444pub async fn get_ids_by_course_slug_or_uh_course_code(
445    conn: &mut PgConnection,
446    course_slug_or_code: &str,
447) -> ModelResult<Vec<Uuid>> {
448    let res = sqlx::query!(
449        "
450SELECT course_modules.id
451FROM course_modules
452  LEFT JOIN courses ON (course_modules.course_id = courses.id)
453WHERE (
454    course_modules.uh_course_code = $1
455    OR courses.slug = $1
456  )
457  AND course_modules.deleted_at IS NULL
458        ",
459        course_slug_or_code,
460    )
461    .map(|record| record.id)
462    .fetch_all(conn)
463    .await?;
464    Ok(res)
465}
466
467/// Gets course modules for the given course as a map, indexed by the `id` field.
468pub async fn get_by_course_id_as_map(
469    conn: &mut PgConnection,
470    course_id: Uuid,
471) -> ModelResult<HashMap<Uuid, CourseModule>> {
472    let res = get_by_course_id(conn, course_id)
473        .await?
474        .into_iter()
475        .map(|course_module| (course_module.id, course_module))
476        .collect();
477    Ok(res)
478}
479
480pub async fn get_all_uh_course_codes_for_open_university(
481    conn: &mut PgConnection,
482) -> ModelResult<Vec<String>> {
483    let res = sqlx::query!(
484        "
485SELECT DISTINCT uh_course_code
486FROM course_modules
487WHERE uh_course_code IS NOT NULL
488  AND enable_registering_completion_to_uh_open_university = true
489  AND deleted_at IS NULL
490"
491    )
492    .fetch_all(conn)
493    .await?
494    .into_iter()
495    .filter_map(|x| x.uh_course_code)
496    .collect();
497    Ok(res)
498}
499
500#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
501
502pub struct AutomaticCompletionRequirements {
503    /// Course module associated with these requirements.
504    pub course_module_id: Uuid,
505    pub number_of_exercises_attempted_treshold: Option<i32>,
506    pub number_of_points_treshold: Option<i32>,
507    pub requires_exam: bool,
508}
509
510impl AutomaticCompletionRequirements {
511    /// Shorthand for checking whether the given exercise related values pass their respective
512    /// tresholds.
513    pub fn passes_exercise_tresholds(
514        &self,
515        exercises_attempted: i32,
516        exercise_points: i32,
517    ) -> bool {
518        self.passes_number_of_exercises_attempted_treshold(exercises_attempted)
519            && self.passes_number_of_exercise_points_treshold(exercise_points)
520    }
521
522    /// Whether the given number is higher than the exercises attempted treshold. Always returns
523    /// true if there is no treshold.
524    pub fn passes_number_of_exercises_attempted_treshold(&self, exercises_attempted: i32) -> bool {
525        self.number_of_exercises_attempted_treshold
526            .map(|x| x <= exercises_attempted)
527            .unwrap_or(true)
528    }
529
530    /// Whether the given number is higher than the exercise points treshold. Always returns true
531    /// if there is no treshold.
532    pub fn passes_number_of_exercise_points_treshold(&self, exercise_points: i32) -> bool {
533        self.number_of_points_treshold
534            .map(|x| x <= exercise_points)
535            .unwrap_or(true)
536    }
537}
538
539#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)]
540#[serde(tag = "policy", rename_all = "kebab-case")]
541pub enum CompletionPolicy {
542    Automatic(AutomaticCompletionRequirements),
543    Manual,
544}
545
546impl CompletionPolicy {
547    /// Returns associated data for `Automatic` variant, if matches.
548    pub fn automatic(&self) -> Option<&AutomaticCompletionRequirements> {
549        match self {
550            CompletionPolicy::Automatic(requirements) => Some(requirements),
551            CompletionPolicy::Manual => None,
552        }
553    }
554
555    fn to_database_fields(&self) -> (bool, Option<i32>, Option<i32>, bool) {
556        match self {
557            CompletionPolicy::Automatic(requirements) => (
558                true,
559                requirements.number_of_exercises_attempted_treshold,
560                requirements.number_of_points_treshold,
561                requirements.requires_exam,
562            ),
563            CompletionPolicy::Manual => (false, None, None, false),
564        }
565    }
566}
567
568pub async fn update_automatic_completion_status(
569    conn: &mut PgConnection,
570    id: Uuid,
571    automatic_completion_policy: &CompletionPolicy,
572) -> ModelResult<CourseModule> {
573    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
574        automatic_completion_policy.to_database_fields();
575    let res = sqlx::query_as!(
576        CourseModulesSchema,
577        "
578UPDATE course_modules
579SET automatic_completion = $1,
580  automatic_completion_number_of_exercises_attempted_treshold = $2,
581  automatic_completion_number_of_points_treshold = $3,
582  automatic_completion_requires_exam = $4
583WHERE id = $5
584  AND deleted_at IS NULL
585RETURNING *
586        ",
587        automatic_completion,
588        exercises_treshold,
589        points_treshold,
590        requires_exam,
591        id,
592    )
593    .fetch_one(conn)
594    .await?;
595    Ok(res.into())
596}
597
598pub async fn update_uh_course_code(
599    conn: &mut PgConnection,
600    id: Uuid,
601    uh_course_code: Option<String>,
602) -> ModelResult<CourseModule> {
603    let res = sqlx::query_as!(
604        CourseModulesSchema,
605        "
606UPDATE course_modules
607SET uh_course_code = $1
608WHERE id = $2
609  AND deleted_at IS NULL
610RETURNING *
611        ",
612        uh_course_code,
613        id,
614    )
615    .fetch_one(conn)
616    .await?;
617    Ok(res.into())
618}
619
620pub async fn update_enable_registering_completion_to_uh_open_university(
621    conn: &mut PgConnection,
622    id: Uuid,
623    enable_registering_completion_to_uh_open_university: bool,
624) -> ModelResult<CourseModule> {
625    let res = sqlx::query_as!(
626        CourseModulesSchema,
627        "
628UPDATE course_modules
629SET enable_registering_completion_to_uh_open_university = $1
630WHERE id = $2
631  AND deleted_at IS NULL
632RETURNING *
633        ",
634        enable_registering_completion_to_uh_open_university,
635        id,
636    )
637    .fetch_one(conn)
638    .await?;
639    Ok(res.into())
640}
641
642#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
643
644pub struct NewModule {
645    name: String,
646    order_number: i32,
647    chapters: Vec<Uuid>,
648    uh_course_code: Option<String>,
649    ects_credits: Option<f32>,
650    completion_policy: CompletionPolicy,
651    completion_registration_link_override: Option<String>,
652    enable_registering_completion_to_uh_open_university: bool,
653}
654
655#[derive(Debug, Deserialize, ToSchema)]
656
657pub struct ModifiedModule {
658    id: Uuid,
659    name: Option<String>,
660    order_number: i32,
661    uh_course_code: Option<String>,
662    ects_credits: Option<f32>,
663    completion_policy: CompletionPolicy,
664    completion_registration_link_override: Option<String>,
665    enable_registering_completion_to_uh_open_university: bool,
666}
667
668#[derive(Debug, Deserialize, ToSchema)]
669
670pub struct ModuleUpdates {
671    new_modules: Vec<NewModule>,
672    deleted_modules: Vec<Uuid>,
673    modified_modules: Vec<ModifiedModule>,
674    moved_chapters: Vec<(Uuid, Uuid)>,
675}
676
677pub async fn update_with_order_number(
678    conn: &mut PgConnection,
679    id: Uuid,
680    name: Option<&str>,
681    order_number: i32,
682) -> ModelResult<()> {
683    sqlx::query!(
684        "
685UPDATE course_modules
686SET name = COALESCE($1, name),
687  order_number = $2
688WHERE id = $3
689",
690        name,
691        order_number,
692        id,
693    )
694    .execute(conn)
695    .await?;
696    Ok(())
697}
698
699pub async fn update(
700    conn: &mut PgConnection,
701    id: Uuid,
702    updated_course_module: &NewCourseModule,
703) -> ModelResult<()> {
704    // destructure so new fields cause a compilation error here
705    let NewCourseModule {
706        completion_policy: _,
707        course_id: _,
708        ects_credits,
709        order_number,
710        name,
711        uh_course_code,
712        completion_registration_link_override,
713        enable_registering_completion_to_uh_open_university,
714    } = updated_course_module;
715    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
716        updated_course_module.completion_policy.to_database_fields();
717    sqlx::query!(
718        "
719UPDATE course_modules
720SET name = COALESCE($2, name),
721  order_number = $3,
722  uh_course_code = $4,
723  ects_credits = $5,
724  automatic_completion = $6,
725  automatic_completion_number_of_exercises_attempted_treshold = $7,
726  automatic_completion_number_of_points_treshold = $8,
727  automatic_completion_requires_exam = $9,
728  completion_registration_link_override = $10,
729  enable_registering_completion_to_uh_open_university = $11
730WHERE id = $1
731        ",
732        id,
733        name.as_ref(),
734        order_number,
735        uh_course_code.as_ref(),
736        ects_credits.as_ref(),
737        automatic_completion,
738        exercises_treshold,
739        points_treshold,
740        requires_exam,
741        completion_registration_link_override.as_ref(),
742        enable_registering_completion_to_uh_open_university
743    )
744    .execute(conn)
745    .await?;
746    Ok(())
747}
748
749pub async fn update_modules(
750    conn: &mut PgConnection,
751    course_id: Uuid,
752    updates: ModuleUpdates,
753) -> ModelResult<()> {
754    let mut tx = conn.begin().await?;
755
756    // scramble order of modified and deleted modules
757    for module_id in updates
758        .modified_modules
759        .iter()
760        // do not scramble the default module, it should always be first
761        .filter(|m| m.order_number != 0)
762        .map(|m| m.id)
763        .chain(updates.deleted_modules.iter().copied())
764    {
765        update_with_order_number(&mut tx, module_id, None, rand::random()).await?;
766    }
767    let mut modified_and_new_modules = updates.modified_modules;
768    for new in updates.new_modules {
769        // destructure so new fields cause a compilation error here
770        let NewModule {
771            name,
772            order_number,
773            chapters,
774            uh_course_code,
775            ects_credits,
776            completion_policy,
777            completion_registration_link_override,
778            enable_registering_completion_to_uh_open_university,
779        } = new;
780        // insert with a random order number to avoid conflicts
781        let new_course_module = NewCourseModule::new(course_id, Some(name.clone()), rand::random())
782            .set_completion_policy(completion_policy.clone())
783            .set_completion_registration_link_override(completion_registration_link_override)
784            .set_ects_credits(ects_credits)
785            .set_uh_course_code(uh_course_code)
786            .set_enable_registering_completion_to_uh_open_university(
787                enable_registering_completion_to_uh_open_university,
788            );
789        let module = insert(&mut tx, PKeyPolicy::Generate, &new_course_module).await?;
790        for chapter in chapters {
791            chapters::set_module(&mut tx, chapter, module.id).await?;
792        }
793        //modify the order number with the rest
794        modified_and_new_modules.push(ModifiedModule {
795            id: module.id,
796            name: None,
797            order_number,
798            uh_course_code: module.uh_course_code,
799            ects_credits,
800            completion_policy,
801            completion_registration_link_override: module.completion_registration_link_override,
802            enable_registering_completion_to_uh_open_university: module
803                .enable_registering_completion_to_uh_open_university,
804        })
805    }
806    // update modified and new modules
807    for module in modified_and_new_modules {
808        // destructure so new fields cause a compilation error here
809        let ModifiedModule {
810            id,
811            name,
812            order_number,
813            uh_course_code,
814            ects_credits,
815            completion_policy,
816            completion_registration_link_override,
817            enable_registering_completion_to_uh_open_university,
818        } = module;
819        update(
820            &mut tx,
821            id,
822            &NewCourseModule::new(course_id, name.clone(), order_number)
823                .set_completion_policy(completion_policy)
824                .set_completion_registration_link_override(completion_registration_link_override)
825                .set_ects_credits(ects_credits)
826                .set_uh_course_code(uh_course_code)
827                .set_enable_registering_completion_to_uh_open_university(
828                    enable_registering_completion_to_uh_open_university,
829                ),
830        )
831        .await?;
832    }
833    for (chapter, module) in updates.moved_chapters {
834        chapters::set_module(&mut tx, chapter, module).await?;
835    }
836    for deleted in updates.deleted_modules {
837        delete(&mut tx, deleted).await?;
838    }
839
840    tx.commit().await?;
841    Ok(())
842}
843
844pub async fn update_certification_enabled(
845    conn: &mut PgConnection,
846    id: Uuid,
847    enabled: bool,
848) -> ModelResult<()> {
849    sqlx::query!(
850        "
851UPDATE course_modules
852SET certification_enabled = $1
853WHERE id = $2
854",
855        enabled,
856        id
857    )
858    .execute(conn)
859    .await?;
860    Ok(())
861}
862
863#[cfg(test)]
864mod tests {
865
866    mod automatic_completion_requirements {
867        use uuid::Uuid;
868
869        use super::super::AutomaticCompletionRequirements;
870
871        #[test]
872        fn passes_exercise_tresholds() {
873            let requirements1 = AutomaticCompletionRequirements {
874                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
875                number_of_exercises_attempted_treshold: Some(10),
876                number_of_points_treshold: Some(50),
877                requires_exam: false,
878            };
879            let requirements2 = AutomaticCompletionRequirements {
880                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
881                number_of_exercises_attempted_treshold: Some(50),
882                number_of_points_treshold: Some(10),
883                requires_exam: false,
884            };
885
886            let requirements3 = AutomaticCompletionRequirements {
887                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
888                number_of_exercises_attempted_treshold: Some(0),
889                number_of_points_treshold: Some(0),
890                requires_exam: false,
891            };
892
893            let requirements4 = AutomaticCompletionRequirements {
894                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
895                number_of_exercises_attempted_treshold: Some(10),
896                number_of_points_treshold: None,
897                requires_exam: false,
898            };
899
900            let requirements5 = AutomaticCompletionRequirements {
901                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
902                number_of_exercises_attempted_treshold: None,
903                number_of_points_treshold: Some(10),
904                requires_exam: false,
905            };
906            assert!(requirements1.passes_exercise_tresholds(10, 50));
907            assert!(requirements2.passes_exercise_tresholds(50, 10));
908
909            assert!(!requirements1.passes_exercise_tresholds(50, 10));
910            assert!(!requirements2.passes_exercise_tresholds(10, 50));
911
912            assert!(!requirements1.passes_exercise_tresholds(100, 0));
913            assert!(!requirements2.passes_exercise_tresholds(100, 0));
914
915            assert!(requirements3.passes_exercise_tresholds(1, 1));
916            assert!(requirements3.passes_exercise_tresholds(0, 0));
917
918            assert!(requirements4.passes_exercise_tresholds(10, 1));
919            assert!(!requirements4.passes_exercise_tresholds(1, 10));
920
921            assert!(requirements5.passes_exercise_tresholds(0, 10));
922            assert!(!requirements5.passes_exercise_tresholds(10, 0));
923        }
924    }
925}