Skip to main content

headless_lms_models/
course_modules.rs

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