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_course_id(
314    conn: &mut PgConnection,
315    course_id: Uuid,
316) -> ModelResult<Vec<CourseModule>> {
317    let res = sqlx::query_as!(
318        CourseModulesSchema,
319        "
320SELECT *
321FROM course_modules
322WHERE course_id = $1
323AND deleted_at IS NULL
324",
325        course_id
326    )
327    .map(|x| x.into())
328    .fetch_all(conn)
329    .await?;
330    Ok(res)
331}
332
333pub async fn get_by_course_id_only_with_open_chapters(
334    conn: &mut PgConnection,
335    course_id: Uuid,
336) -> ModelResult<Vec<CourseModule>> {
337    let res = sqlx::query_as!(
338        CourseModulesSchema,
339        "
340SELECT *
341FROM course_modules as cm
342WHERE EXISTS (
343  SELECT 1
344  FROM chapters as ch
345  WHERE ch.course_module_id = cm.id
346    AND ((ch.opens_at < now()) OR ch.opens_at IS NULL)
347    AND ch.deleted_at IS NULL
348)
349  AND cm.course_id = $1
350  AND cm.deleted_at IS NULL
351",
352        course_id
353    )
354    .map(|x| x.into())
355    .fetch_all(conn)
356    .await?;
357    Ok(res)
358}
359
360/// Gets course module where the given exercise belongs to. This will result in an error in the case
361/// of an exam exercise.
362pub async fn get_by_exercise_id(
363    conn: &mut PgConnection,
364    exercise_id: Uuid,
365) -> ModelResult<CourseModule> {
366    let res = sqlx::query_as!(
367        CourseModulesSchema,
368        "
369SELECT course_modules.*
370FROM exercises
371  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
372  LEFT JOIN course_modules ON (chapters.course_module_id = course_modules.id)
373WHERE exercises.id = $1
374AND chapters.deleted_at IS NULL
375AND course_modules.deleted_at IS NULL
376        ",
377        exercise_id,
378    )
379    .fetch_one(conn)
380    .await?;
381    Ok(res.into())
382}
383
384pub async fn get_course_module_id_by_chapter(
385    conn: &mut PgConnection,
386    chapter_id: Uuid,
387) -> ModelResult<Uuid> {
388    let res: Uuid = sqlx::query!(
389        r#"
390SELECT c.course_module_id
391from chapters c
392where c.id = $1
393  AND deleted_at IS NULL
394  "#,
395        chapter_id
396    )
397    .map(|record| record.course_module_id)
398    .fetch_one(conn)
399    .await?;
400    Ok(res)
401}
402
403pub async fn get_default_by_course_id(
404    conn: &mut PgConnection,
405    course_id: Uuid,
406) -> ModelResult<CourseModule> {
407    let res = sqlx::query_as!(
408        CourseModulesSchema,
409        "
410SELECT *
411FROM course_modules
412WHERE course_id = $1
413  AND name IS NULL
414  AND deleted_at IS NULL
415        ",
416        course_id,
417    )
418    .fetch_one(conn)
419    .await?;
420    Ok(res.into())
421}
422
423/// Gets all course modules with a matching `uh_course_code` or course `slug`.
424///
425/// In the latter case only one record at most is returned, but there is no way to distinguish between
426/// these two scenarios in advance.
427pub async fn get_ids_by_course_slug_or_uh_course_code(
428    conn: &mut PgConnection,
429    course_slug_or_code: &str,
430) -> ModelResult<Vec<Uuid>> {
431    let res = sqlx::query!(
432        "
433SELECT course_modules.id
434FROM course_modules
435  LEFT JOIN courses ON (course_modules.course_id = courses.id)
436WHERE (
437    course_modules.uh_course_code = $1
438    OR courses.slug = $1
439  )
440  AND course_modules.deleted_at IS NULL
441        ",
442        course_slug_or_code,
443    )
444    .map(|record| record.id)
445    .fetch_all(conn)
446    .await?;
447    Ok(res)
448}
449
450/// Gets course modules for the given course as a map, indexed by the `id` field.
451pub async fn get_by_course_id_as_map(
452    conn: &mut PgConnection,
453    course_id: Uuid,
454) -> ModelResult<HashMap<Uuid, CourseModule>> {
455    let res = get_by_course_id(conn, course_id)
456        .await?
457        .into_iter()
458        .map(|course_module| (course_module.id, course_module))
459        .collect();
460    Ok(res)
461}
462
463pub async fn get_all_uh_course_codes_for_open_university(
464    conn: &mut PgConnection,
465) -> ModelResult<Vec<String>> {
466    let res = sqlx::query!(
467        "
468SELECT DISTINCT uh_course_code
469FROM course_modules
470WHERE uh_course_code IS NOT NULL
471  AND enable_registering_completion_to_uh_open_university = true
472  AND deleted_at IS NULL
473"
474    )
475    .fetch_all(conn)
476    .await?
477    .into_iter()
478    .filter_map(|x| x.uh_course_code)
479    .collect();
480    Ok(res)
481}
482
483#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
484
485pub struct AutomaticCompletionRequirements {
486    /// Course module associated with these requirements.
487    pub course_module_id: Uuid,
488    pub number_of_exercises_attempted_treshold: Option<i32>,
489    pub number_of_points_treshold: Option<i32>,
490    pub requires_exam: bool,
491}
492
493impl AutomaticCompletionRequirements {
494    /// Shorthand for checking whether the given exercise related values pass their respective
495    /// tresholds.
496    pub fn passes_exercise_tresholds(
497        &self,
498        exercises_attempted: i32,
499        exercise_points: i32,
500    ) -> bool {
501        self.passes_number_of_exercises_attempted_treshold(exercises_attempted)
502            && self.passes_number_of_exercise_points_treshold(exercise_points)
503    }
504
505    /// Whether the given number is higher than the exercises attempted treshold. Always returns
506    /// true if there is no treshold.
507    pub fn passes_number_of_exercises_attempted_treshold(&self, exercises_attempted: i32) -> bool {
508        self.number_of_exercises_attempted_treshold
509            .map(|x| x <= exercises_attempted)
510            .unwrap_or(true)
511    }
512
513    /// Whether the given number is higher than the exercise points treshold. Always returns true
514    /// if there is no treshold.
515    pub fn passes_number_of_exercise_points_treshold(&self, exercise_points: i32) -> bool {
516        self.number_of_points_treshold
517            .map(|x| x <= exercise_points)
518            .unwrap_or(true)
519    }
520}
521
522#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)]
523#[serde(tag = "policy", rename_all = "kebab-case")]
524pub enum CompletionPolicy {
525    Automatic(AutomaticCompletionRequirements),
526    Manual,
527}
528
529impl CompletionPolicy {
530    /// Returns associated data for `Automatic` variant, if matches.
531    pub fn automatic(&self) -> Option<&AutomaticCompletionRequirements> {
532        match self {
533            CompletionPolicy::Automatic(requirements) => Some(requirements),
534            CompletionPolicy::Manual => None,
535        }
536    }
537
538    fn to_database_fields(&self) -> (bool, Option<i32>, Option<i32>, bool) {
539        match self {
540            CompletionPolicy::Automatic(requirements) => (
541                true,
542                requirements.number_of_exercises_attempted_treshold,
543                requirements.number_of_points_treshold,
544                requirements.requires_exam,
545            ),
546            CompletionPolicy::Manual => (false, None, None, false),
547        }
548    }
549}
550
551pub async fn update_automatic_completion_status(
552    conn: &mut PgConnection,
553    id: Uuid,
554    automatic_completion_policy: &CompletionPolicy,
555) -> ModelResult<CourseModule> {
556    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
557        automatic_completion_policy.to_database_fields();
558    let res = sqlx::query_as!(
559        CourseModulesSchema,
560        "
561UPDATE course_modules
562SET automatic_completion = $1,
563  automatic_completion_number_of_exercises_attempted_treshold = $2,
564  automatic_completion_number_of_points_treshold = $3,
565  automatic_completion_requires_exam = $4
566WHERE id = $5
567  AND deleted_at IS NULL
568RETURNING *
569        ",
570        automatic_completion,
571        exercises_treshold,
572        points_treshold,
573        requires_exam,
574        id,
575    )
576    .fetch_one(conn)
577    .await?;
578    Ok(res.into())
579}
580
581pub async fn update_uh_course_code(
582    conn: &mut PgConnection,
583    id: Uuid,
584    uh_course_code: Option<String>,
585) -> ModelResult<CourseModule> {
586    let res = sqlx::query_as!(
587        CourseModulesSchema,
588        "
589UPDATE course_modules
590SET uh_course_code = $1
591WHERE id = $2
592  AND deleted_at IS NULL
593RETURNING *
594        ",
595        uh_course_code,
596        id,
597    )
598    .fetch_one(conn)
599    .await?;
600    Ok(res.into())
601}
602
603pub async fn update_enable_registering_completion_to_uh_open_university(
604    conn: &mut PgConnection,
605    id: Uuid,
606    enable_registering_completion_to_uh_open_university: bool,
607) -> ModelResult<CourseModule> {
608    let res = sqlx::query_as!(
609        CourseModulesSchema,
610        "
611UPDATE course_modules
612SET enable_registering_completion_to_uh_open_university = $1
613WHERE id = $2
614  AND deleted_at IS NULL
615RETURNING *
616        ",
617        enable_registering_completion_to_uh_open_university,
618        id,
619    )
620    .fetch_one(conn)
621    .await?;
622    Ok(res.into())
623}
624
625#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
626
627pub struct NewModule {
628    name: String,
629    order_number: i32,
630    chapters: Vec<Uuid>,
631    uh_course_code: Option<String>,
632    ects_credits: Option<f32>,
633    completion_policy: CompletionPolicy,
634    completion_registration_link_override: Option<String>,
635    enable_registering_completion_to_uh_open_university: bool,
636}
637
638#[derive(Debug, Deserialize, ToSchema)]
639
640pub struct ModifiedModule {
641    id: Uuid,
642    name: Option<String>,
643    order_number: i32,
644    uh_course_code: Option<String>,
645    ects_credits: Option<f32>,
646    completion_policy: CompletionPolicy,
647    completion_registration_link_override: Option<String>,
648    enable_registering_completion_to_uh_open_university: bool,
649}
650
651#[derive(Debug, Deserialize, ToSchema)]
652
653pub struct ModuleUpdates {
654    new_modules: Vec<NewModule>,
655    deleted_modules: Vec<Uuid>,
656    modified_modules: Vec<ModifiedModule>,
657    moved_chapters: Vec<(Uuid, Uuid)>,
658}
659
660pub async fn update_with_order_number(
661    conn: &mut PgConnection,
662    id: Uuid,
663    name: Option<&str>,
664    order_number: i32,
665) -> ModelResult<()> {
666    sqlx::query!(
667        "
668UPDATE course_modules
669SET name = COALESCE($1, name),
670  order_number = $2
671WHERE id = $3
672",
673        name,
674        order_number,
675        id,
676    )
677    .execute(conn)
678    .await?;
679    Ok(())
680}
681
682pub async fn update(
683    conn: &mut PgConnection,
684    id: Uuid,
685    updated_course_module: &NewCourseModule,
686) -> ModelResult<()> {
687    // destructure so new fields cause a compilation error here
688    let NewCourseModule {
689        completion_policy: _,
690        course_id: _,
691        ects_credits,
692        order_number,
693        name,
694        uh_course_code,
695        completion_registration_link_override,
696        enable_registering_completion_to_uh_open_university,
697    } = updated_course_module;
698    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
699        updated_course_module.completion_policy.to_database_fields();
700    sqlx::query!(
701        "
702UPDATE course_modules
703SET name = COALESCE($2, name),
704  order_number = $3,
705  uh_course_code = $4,
706  ects_credits = $5,
707  automatic_completion = $6,
708  automatic_completion_number_of_exercises_attempted_treshold = $7,
709  automatic_completion_number_of_points_treshold = $8,
710  automatic_completion_requires_exam = $9,
711  completion_registration_link_override = $10,
712  enable_registering_completion_to_uh_open_university = $11
713WHERE id = $1
714        ",
715        id,
716        name.as_ref(),
717        order_number,
718        uh_course_code.as_ref(),
719        ects_credits.as_ref(),
720        automatic_completion,
721        exercises_treshold,
722        points_treshold,
723        requires_exam,
724        completion_registration_link_override.as_ref(),
725        enable_registering_completion_to_uh_open_university
726    )
727    .execute(conn)
728    .await?;
729    Ok(())
730}
731
732pub async fn update_modules(
733    conn: &mut PgConnection,
734    course_id: Uuid,
735    updates: ModuleUpdates,
736) -> ModelResult<()> {
737    let mut tx = conn.begin().await?;
738
739    // scramble order of modified and deleted modules
740    for module_id in updates
741        .modified_modules
742        .iter()
743        // do not scramble the default module, it should always be first
744        .filter(|m| m.order_number != 0)
745        .map(|m| m.id)
746        .chain(updates.deleted_modules.iter().copied())
747    {
748        update_with_order_number(&mut tx, module_id, None, rand::random()).await?;
749    }
750    let mut modified_and_new_modules = updates.modified_modules;
751    for new in updates.new_modules {
752        // destructure so new fields cause a compilation error here
753        let NewModule {
754            name,
755            order_number,
756            chapters,
757            uh_course_code,
758            ects_credits,
759            completion_policy,
760            completion_registration_link_override,
761            enable_registering_completion_to_uh_open_university,
762        } = new;
763        // insert with a random order number to avoid conflicts
764        let new_course_module = NewCourseModule::new(course_id, Some(name.clone()), rand::random())
765            .set_completion_policy(completion_policy.clone())
766            .set_completion_registration_link_override(completion_registration_link_override)
767            .set_ects_credits(ects_credits)
768            .set_uh_course_code(uh_course_code)
769            .set_enable_registering_completion_to_uh_open_university(
770                enable_registering_completion_to_uh_open_university,
771            );
772        let module = insert(&mut tx, PKeyPolicy::Generate, &new_course_module).await?;
773        for chapter in chapters {
774            chapters::set_module(&mut tx, chapter, module.id).await?;
775        }
776        //modify the order number with the rest
777        modified_and_new_modules.push(ModifiedModule {
778            id: module.id,
779            name: None,
780            order_number,
781            uh_course_code: module.uh_course_code,
782            ects_credits,
783            completion_policy,
784            completion_registration_link_override: module.completion_registration_link_override,
785            enable_registering_completion_to_uh_open_university: module
786                .enable_registering_completion_to_uh_open_university,
787        })
788    }
789    // update modified and new modules
790    for module in modified_and_new_modules {
791        // destructure so new fields cause a compilation error here
792        let ModifiedModule {
793            id,
794            name,
795            order_number,
796            uh_course_code,
797            ects_credits,
798            completion_policy,
799            completion_registration_link_override,
800            enable_registering_completion_to_uh_open_university,
801        } = module;
802        update(
803            &mut tx,
804            id,
805            &NewCourseModule::new(course_id, name.clone(), order_number)
806                .set_completion_policy(completion_policy)
807                .set_completion_registration_link_override(completion_registration_link_override)
808                .set_ects_credits(ects_credits)
809                .set_uh_course_code(uh_course_code)
810                .set_enable_registering_completion_to_uh_open_university(
811                    enable_registering_completion_to_uh_open_university,
812                ),
813        )
814        .await?;
815    }
816    for (chapter, module) in updates.moved_chapters {
817        chapters::set_module(&mut tx, chapter, module).await?;
818    }
819    for deleted in updates.deleted_modules {
820        delete(&mut tx, deleted).await?;
821    }
822
823    tx.commit().await?;
824    Ok(())
825}
826
827pub async fn update_certification_enabled(
828    conn: &mut PgConnection,
829    id: Uuid,
830    enabled: bool,
831) -> ModelResult<()> {
832    sqlx::query!(
833        "
834UPDATE course_modules
835SET certification_enabled = $1
836WHERE id = $2
837",
838        enabled,
839        id
840    )
841    .execute(conn)
842    .await?;
843    Ok(())
844}
845
846#[cfg(test)]
847mod tests {
848
849    mod automatic_completion_requirements {
850        use uuid::Uuid;
851
852        use super::super::AutomaticCompletionRequirements;
853
854        #[test]
855        fn passes_exercise_tresholds() {
856            let requirements1 = AutomaticCompletionRequirements {
857                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
858                number_of_exercises_attempted_treshold: Some(10),
859                number_of_points_treshold: Some(50),
860                requires_exam: false,
861            };
862            let requirements2 = AutomaticCompletionRequirements {
863                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
864                number_of_exercises_attempted_treshold: Some(50),
865                number_of_points_treshold: Some(10),
866                requires_exam: false,
867            };
868
869            let requirements3 = AutomaticCompletionRequirements {
870                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
871                number_of_exercises_attempted_treshold: Some(0),
872                number_of_points_treshold: Some(0),
873                requires_exam: false,
874            };
875
876            let requirements4 = AutomaticCompletionRequirements {
877                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
878                number_of_exercises_attempted_treshold: Some(10),
879                number_of_points_treshold: None,
880                requires_exam: false,
881            };
882
883            let requirements5 = AutomaticCompletionRequirements {
884                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
885                number_of_exercises_attempted_treshold: None,
886                number_of_points_treshold: Some(10),
887                requires_exam: false,
888            };
889            assert!(requirements1.passes_exercise_tresholds(10, 50));
890            assert!(requirements2.passes_exercise_tresholds(50, 10));
891
892            assert!(!requirements1.passes_exercise_tresholds(50, 10));
893            assert!(!requirements2.passes_exercise_tresholds(10, 50));
894
895            assert!(!requirements1.passes_exercise_tresholds(100, 0));
896            assert!(!requirements2.passes_exercise_tresholds(100, 0));
897
898            assert!(requirements3.passes_exercise_tresholds(1, 1));
899            assert!(requirements3.passes_exercise_tresholds(0, 0));
900
901            assert!(requirements4.passes_exercise_tresholds(10, 1));
902            assert!(!requirements4.passes_exercise_tresholds(1, 10));
903
904            assert!(requirements5.passes_exercise_tresholds(0, 10));
905            assert!(!requirements5.passes_exercise_tresholds(10, 0));
906        }
907    }
908}