headless_lms_models/
course_modules.rs

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