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