Skip to main content

headless_lms_models/
course_modules.rs

1use std::collections::HashMap;
2
3use utoipa::ToSchema;
4
5use crate::{
6    chapters, course_module_suotar_configurations, course_module_suotar_realisations,
7    error::missing_model_error, library::credit_registration::grade_mapping::grade_scale_family,
8    prelude::*,
9};
10
11/// The subset of `course_modules` columns [`CourseModule`] is built from; the credit-registration
12/// ones are read through [`CourseModuleCreditRegistrationConfig`], hence no `SELECT *` here.
13struct CourseModulesSchema {
14    id: Uuid,
15    created_at: DateTime<Utc>,
16    updated_at: DateTime<Utc>,
17    deleted_at: Option<DateTime<Utc>>,
18    name: Option<String>,
19    course_id: Uuid,
20    order_number: i32,
21    copied_from: Option<Uuid>,
22    uh_course_code: Option<String>,
23    automatic_completion: bool,
24    automatic_completion_number_of_exercises_attempted_treshold: Option<i32>,
25    automatic_completion_number_of_points_treshold: Option<i32>,
26    automatic_completion_requires_exam: bool,
27    completion_registration_link_override: Option<String>,
28    ects_credits: Option<f32>,
29    enable_registering_completion_to_uh_open_university: bool,
30    certification_enabled: bool,
31    enable_credit_registration_via_suotar: bool,
32}
33/// Like [CourseModulesSchema], but the automatic-completion columns are collapsed into
34/// `completion_policy`.
35#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
36pub struct CourseModule {
37    pub id: Uuid,
38    pub created_at: DateTime<Utc>,
39    pub updated_at: DateTime<Utc>,
40    pub deleted_at: Option<DateTime<Utc>>,
41    pub name: Option<String>,
42    pub course_id: Uuid,
43    pub order_number: i32,
44    pub copied_from: Option<Uuid>,
45    pub uh_course_code: Option<String>,
46    pub completion_policy: CompletionPolicy,
47    /// If set, use this link rather than the default one when registering course completions.
48    pub completion_registration_link_override: Option<String>,
49    pub ects_credits: Option<f32>,
50    pub enable_registering_completion_to_uh_open_university: bool,
51    pub certification_enabled: bool,
52    pub enable_credit_registration_via_suotar: bool,
53}
54
55impl CourseModule {
56    pub fn new(id: Uuid, course_id: Uuid) -> Self {
57        Self {
58            id,
59            created_at: Utc::now(),
60            updated_at: Utc::now(),
61            deleted_at: None,
62            name: None,
63            course_id,
64            order_number: 0,
65            copied_from: None,
66            uh_course_code: None,
67            completion_policy: CompletionPolicy::Manual,
68            completion_registration_link_override: None,
69            ects_credits: None,
70            enable_registering_completion_to_uh_open_university: false,
71            certification_enabled: false,
72            enable_credit_registration_via_suotar: false,
73        }
74    }
75    pub fn set_timestamps(
76        mut self,
77        created_at: DateTime<Utc>,
78        updated_at: DateTime<Utc>,
79        deleted_at: Option<DateTime<Utc>>,
80    ) -> Self {
81        self.created_at = created_at;
82        self.updated_at = updated_at;
83        self.deleted_at = deleted_at;
84        self
85    }
86
87    /// order_number == 0 in and only if name == None
88    pub fn set_name_and_order_number(mut self, name: Option<String>, order_number: i32) -> Self {
89        self.name = name;
90        self.order_number = order_number;
91        self
92    }
93
94    pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
95        self.completion_policy = completion_policy;
96        self
97    }
98
99    pub fn set_registration_info(
100        mut self,
101        uh_course_code: Option<String>,
102        ects_credits: Option<f32>,
103        completion_registration_link_override: Option<String>,
104        enable_registering_completion_to_uh_open_university: bool,
105        enable_credit_registration_via_suotar: bool,
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.enable_registering_completion_to_uh_open_university =
111            enable_registering_completion_to_uh_open_university;
112        self.enable_credit_registration_via_suotar = enable_credit_registration_via_suotar;
113        self
114    }
115
116    pub fn set_certification_enabled(mut self, certification_enabled: bool) -> Self {
117        self.certification_enabled = certification_enabled;
118        self
119    }
120
121    pub fn is_default_module(&self) -> bool {
122        self.name.is_none()
123    }
124}
125
126impl From<CourseModulesSchema> for CourseModule {
127    fn from(schema: CourseModulesSchema) -> Self {
128        let completion_policy = if schema.automatic_completion {
129            CompletionPolicy::Automatic(AutomaticCompletionRequirements {
130                course_module_id: schema.id,
131                number_of_exercises_attempted_treshold: schema
132                    .automatic_completion_number_of_exercises_attempted_treshold,
133                number_of_points_treshold: schema.automatic_completion_number_of_points_treshold,
134                requires_exam: schema.automatic_completion_requires_exam,
135            })
136        } else {
137            CompletionPolicy::Manual
138        };
139        Self {
140            id: schema.id,
141            created_at: schema.created_at,
142            updated_at: schema.updated_at,
143            deleted_at: schema.deleted_at,
144            name: schema.name,
145            course_id: schema.course_id,
146            order_number: schema.order_number,
147            copied_from: schema.copied_from,
148            uh_course_code: schema.uh_course_code,
149            completion_policy,
150            completion_registration_link_override: schema.completion_registration_link_override,
151            ects_credits: schema.ects_credits,
152            enable_registering_completion_to_uh_open_university: schema
153                .enable_registering_completion_to_uh_open_university,
154            certification_enabled: schema.certification_enabled,
155            enable_credit_registration_via_suotar: schema.enable_credit_registration_via_suotar,
156        }
157    }
158}
159
160#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
161pub struct NewCourseModule {
162    completion_policy: CompletionPolicy,
163    completion_registration_link_override: Option<String>,
164    course_id: Uuid,
165    ects_credits: Option<f32>,
166    name: Option<String>,
167    order_number: i32,
168    uh_course_code: Option<String>,
169    enable_registering_completion_to_uh_open_university: bool,
170    enable_credit_registration_via_suotar: bool,
171}
172
173impl NewCourseModule {
174    pub fn new(course_id: Uuid, name: Option<String>, order_number: i32) -> Self {
175        Self {
176            completion_policy: CompletionPolicy::Manual,
177            completion_registration_link_override: None,
178            course_id,
179            ects_credits: None,
180            name,
181            order_number,
182            uh_course_code: None,
183            enable_registering_completion_to_uh_open_university: false,
184            enable_credit_registration_via_suotar: false,
185        }
186    }
187
188    pub fn new_course_default(course_id: Uuid) -> Self {
189        Self::new(course_id, None, 0)
190    }
191
192    pub fn set_uh_course_code(mut self, uh_course_code: Option<String>) -> Self {
193        self.uh_course_code = uh_course_code;
194        self
195    }
196
197    pub fn set_completion_policy(mut self, completion_policy: CompletionPolicy) -> Self {
198        self.completion_policy = completion_policy;
199        self
200    }
201
202    pub fn set_completion_registration_link_override(
203        mut self,
204        completion_registration_link_override: Option<String>,
205    ) -> Self {
206        self.completion_registration_link_override = completion_registration_link_override;
207        self
208    }
209
210    pub fn set_ects_credits(mut self, ects_credits: Option<f32>) -> Self {
211        self.ects_credits = ects_credits;
212        self
213    }
214
215    pub fn set_enable_registering_completion_to_uh_open_university(
216        mut self,
217        enable_registering_completion_to_uh_open_university: bool,
218    ) -> Self {
219        self.enable_registering_completion_to_uh_open_university =
220            enable_registering_completion_to_uh_open_university;
221        self
222    }
223
224    pub fn set_enable_credit_registration_via_suotar(
225        mut self,
226        enable_credit_registration_via_suotar: bool,
227    ) -> Self {
228        self.enable_credit_registration_via_suotar = enable_credit_registration_via_suotar;
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    enable_registering_completion_to_uh_open_university: bool,
243    enable_credit_registration_via_suotar: bool,
244    credit_registration: CourseModuleCreditRegistrationEdit,
245}
246
247#[derive(Debug, Deserialize, ToSchema)]
248pub struct ModifiedModule {
249    id: Uuid,
250    name: Option<String>,
251    order_number: i32,
252    uh_course_code: Option<String>,
253    ects_credits: Option<f32>,
254    completion_policy: CompletionPolicy,
255    completion_registration_link_override: Option<String>,
256    enable_registering_completion_to_uh_open_university: bool,
257    enable_credit_registration_via_suotar: bool,
258    credit_registration: CourseModuleCreditRegistrationEdit,
259}
260
261#[derive(Debug, Deserialize, ToSchema)]
262pub struct CourseAuditingModuleUpdate {
263    pub id: Uuid,
264    pub name: Option<String>,
265    pub order_number: i32,
266    pub uh_course_code: Option<String>,
267    pub ects_credits: Option<f32>,
268    pub completion_registration_link_override: Option<String>,
269    pub enable_registering_completion_to_uh_open_university: bool,
270}
271
272/// The module editor's writable half of the Suotar configuration. The pause and the
273/// config-validation verdict are not here: their writers are the admin dashboard and the pipeline.
274#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
275pub struct CourseModuleCreditRegistrationEdit {
276    pub open_university_product_id: Option<String>,
277    /// `None` means derive the grade scale from the completion.
278    pub grade_scale_id: Option<String>,
279    /// The full set for the module; anything missing from it is soft-deleted.
280    pub realisations: Vec<CourseModuleSuotarRealisationEdit>,
281}
282
283impl CourseModuleCreditRegistrationEdit {
284    /// Whether the editor sent nothing worth storing. Blank strings count as empty because that is
285    /// what the form submits for an untouched field.
286    pub fn is_empty(&self) -> bool {
287        self.open_university_product_id
288            .as_deref()
289            .and_then(non_empty)
290            .is_none()
291            && self.grade_scale_id.as_deref().and_then(non_empty).is_none()
292            && self.realisations.is_empty()
293    }
294}
295
296#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
297pub struct CourseModuleSuotarRealisationEdit {
298    pub course_unit_realisation_id: String,
299    /// Rendered to students as the name of the realisation their credits go against.
300    pub label: Option<String>,
301    pub active: bool,
302}
303
304#[derive(Debug, Deserialize, ToSchema)]
305pub struct ModuleUpdates {
306    new_modules: Vec<NewModule>,
307    deleted_modules: Vec<Uuid>,
308    modified_modules: Vec<ModifiedModule>,
309    moved_chapters: Vec<(Uuid, Uuid)>,
310}
311
312/// How many chapters and exercises a course module contains. Used for deciding whether the module
313/// is small enough to be exempt from the minimum cheater threshold.
314pub struct ModuleSizeCounts {
315    pub chapters: i64,
316    pub exercises: i64,
317}
318
319/// Per-module credit-registration configuration: the rollout switch and the module's own fields
320/// merged with its `course_module_suotar_configurations` row. Every field of that row is optional
321/// here because a module with no configuration row is a valid, unconfigured module.
322#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
323pub struct CourseModuleCreditRegistrationConfig {
324    pub course_module_id: Uuid,
325    pub course_id: Uuid,
326    pub enable_credit_registration_via_suotar: bool,
327    pub uh_course_code: Option<String>,
328    pub ects_credits: Option<f32>,
329    pub open_university_product_id: Option<String>,
330    /// `None` means derive the grade scale from the completion.
331    pub credit_registration_grade_scale_id: Option<String>,
332    pub credit_registration_paused_at: Option<DateTime<Utc>>,
333    pub credit_registration_paused_by_user_id: Option<Uuid>,
334    pub credit_registration_pause_reason: Option<String>,
335    pub credit_registration_config_checked_at: Option<DateTime<Utc>>,
336    /// `None` means never checked, which is not the same as a failed check.
337    pub credit_registration_course_code_resolves: Option<bool>,
338    pub credit_registration_product_token_found: Option<bool>,
339    pub credit_registration_config_check_message: Option<String>,
340}
341
342#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
343pub struct AutomaticCompletionRequirements {
344    /// Course module associated with these requirements.
345    pub course_module_id: Uuid,
346    pub number_of_exercises_attempted_treshold: Option<i32>,
347    pub number_of_points_treshold: Option<i32>,
348    pub requires_exam: bool,
349}
350
351impl AutomaticCompletionRequirements {
352    /// Shorthand for checking whether the given exercise related values pass their respective
353    /// tresholds.
354    pub fn passes_exercise_tresholds(
355        &self,
356        exercises_attempted: i32,
357        exercise_points: i32,
358    ) -> bool {
359        self.passes_number_of_exercises_attempted_treshold(exercises_attempted)
360            && self.passes_number_of_exercise_points_treshold(exercise_points)
361    }
362
363    /// Whether the given number is higher than the exercises attempted treshold. Always returns
364    /// true if there is no treshold.
365    pub fn passes_number_of_exercises_attempted_treshold(&self, exercises_attempted: i32) -> bool {
366        self.number_of_exercises_attempted_treshold
367            .map(|x| x <= exercises_attempted)
368            .unwrap_or(true)
369    }
370
371    /// Whether the given number is higher than the exercise points treshold. Always returns true
372    /// if there is no treshold.
373    pub fn passes_number_of_exercise_points_treshold(&self, exercise_points: i32) -> bool {
374        self.number_of_points_treshold
375            .map(|x| x <= exercise_points)
376            .unwrap_or(true)
377    }
378}
379
380#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, ToSchema)]
381#[serde(tag = "policy", rename_all = "kebab-case")]
382pub enum CompletionPolicy {
383    Automatic(AutomaticCompletionRequirements),
384    Manual,
385}
386
387impl CompletionPolicy {
388    /// Returns associated data for `Automatic` variant, if matches.
389    pub fn automatic(&self) -> Option<&AutomaticCompletionRequirements> {
390        match self {
391            CompletionPolicy::Automatic(requirements) => Some(requirements),
392            CompletionPolicy::Manual => None,
393        }
394    }
395
396    fn to_database_fields(&self) -> (bool, Option<i32>, Option<i32>, bool) {
397        match self {
398            CompletionPolicy::Automatic(requirements) => (
399                true,
400                requirements.number_of_exercises_attempted_treshold,
401                requirements.number_of_points_treshold,
402                requirements.requires_exam,
403            ),
404            CompletionPolicy::Manual => (false, None, None, false),
405        }
406    }
407}
408
409/// Both paths would put the same attainment in Sisu. `course_modules_one_credit_registration_path`
410/// enforces this too; here it becomes an error the module editor can render.
411fn validate_one_credit_registration_path(
412    enable_credit_registration_via_suotar: bool,
413    enable_registering_completion_to_uh_open_university: bool,
414) -> ModelResult<()> {
415    if enable_credit_registration_via_suotar && enable_registering_completion_to_uh_open_university
416    {
417        return Err(model_err!(
418            PreconditionFailed,
419            "A course module cannot register completions both via Suotar and via the open university."
420                .to_string()
421        ));
422    }
423    Ok(())
424}
425
426pub async fn insert(
427    conn: &mut PgConnection,
428    pkey_policy: PKeyPolicy<Uuid>,
429    new_course_module: &NewCourseModule,
430) -> ModelResult<CourseModule> {
431    validate_one_credit_registration_path(
432        new_course_module.enable_credit_registration_via_suotar,
433        new_course_module.enable_registering_completion_to_uh_open_university,
434    )?;
435    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
436        new_course_module.completion_policy.to_database_fields();
437    let res = sqlx::query_as!(
438        CourseModulesSchema,
439        "
440INSERT INTO course_modules (
441    id,
442    course_id,
443    name,
444    order_number,
445    automatic_completion,
446    automatic_completion_number_of_exercises_attempted_treshold,
447    automatic_completion_number_of_points_treshold,
448    automatic_completion_requires_exam,
449    ects_credits,
450    enable_registering_completion_to_uh_open_university,
451    uh_course_code,
452    enable_credit_registration_via_suotar
453  )
454VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
455RETURNING id,
456  created_at,
457  updated_at,
458  deleted_at,
459  name,
460  course_id,
461  order_number,
462  copied_from,
463  uh_course_code,
464  automatic_completion,
465  automatic_completion_number_of_exercises_attempted_treshold,
466  automatic_completion_number_of_points_treshold,
467  automatic_completion_requires_exam,
468  completion_registration_link_override,
469  ects_credits,
470  enable_registering_completion_to_uh_open_university,
471  certification_enabled,
472  enable_credit_registration_via_suotar
473        ",
474        pkey_policy.into_uuid(),
475        new_course_module.course_id,
476        new_course_module.name,
477        new_course_module.order_number,
478        automatic_completion,
479        exercises_treshold,
480        points_treshold,
481        requires_exam,
482        new_course_module.ects_credits,
483        new_course_module.enable_registering_completion_to_uh_open_university,
484        new_course_module.uh_course_code,
485        new_course_module.enable_credit_registration_via_suotar
486    )
487    .fetch_one(conn)
488    .await?;
489    Ok(res.into())
490}
491
492pub async fn rename(conn: &mut PgConnection, id: Uuid, name: &str) -> ModelResult<()> {
493    sqlx::query!(
494        "
495UPDATE course_modules
496SET name = $1
497WHERE id = $2
498",
499        name,
500        id
501    )
502    .execute(conn)
503    .await?;
504    Ok(())
505}
506
507pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
508    let associated_chapters = chapters::get_for_module(conn, id).await?;
509    if !associated_chapters.is_empty() {
510        return Err(ModelError::new(
511            ModelErrorType::InvalidRequest,
512            format!(
513                "Cannot remove module {id} because it has {} chapters associated with it",
514                associated_chapters.len()
515            ),
516            None,
517        ));
518    }
519    sqlx::query!(
520        "
521UPDATE course_modules
522SET deleted_at = now()
523WHERE id = $1
524AND deleted_at IS NULL
525",
526        id
527    )
528    .execute(conn)
529    .await?;
530    Ok(())
531}
532
533pub async fn get_all_modules(conn: &mut PgConnection) -> ModelResult<Vec<CourseModule>> {
534    let res = sqlx::query_as!(
535        CourseModulesSchema,
536        "
537SELECT id,
538  created_at,
539  updated_at,
540  deleted_at,
541  name,
542  course_id,
543  order_number,
544  copied_from,
545  uh_course_code,
546  automatic_completion,
547  automatic_completion_number_of_exercises_attempted_treshold,
548  automatic_completion_number_of_points_treshold,
549  automatic_completion_requires_exam,
550  completion_registration_link_override,
551  ects_credits,
552  enable_registering_completion_to_uh_open_university,
553  certification_enabled,
554  enable_credit_registration_via_suotar
555FROM course_modules
556WHERE deleted_at IS NULL
557        ",
558    )
559    .map(|x| x.into())
560    .fetch_all(conn)
561    .await?;
562    Ok(res)
563}
564
565pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CourseModule> {
566    let res = sqlx::query_as!(
567        CourseModulesSchema,
568        "
569SELECT id,
570  created_at,
571  updated_at,
572  deleted_at,
573  name,
574  course_id,
575  order_number,
576  copied_from,
577  uh_course_code,
578  automatic_completion,
579  automatic_completion_number_of_exercises_attempted_treshold,
580  automatic_completion_number_of_points_treshold,
581  automatic_completion_requires_exam,
582  completion_registration_link_override,
583  ects_credits,
584  enable_registering_completion_to_uh_open_university,
585  certification_enabled,
586  enable_credit_registration_via_suotar
587FROM course_modules
588WHERE id = $1
589  AND deleted_at IS NULL
590        ",
591        id,
592    )
593    .fetch_one(conn)
594    .await?;
595    Ok(res.into())
596}
597
598pub async fn get_by_ids(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<Vec<CourseModule>> {
599    let res = sqlx::query_as!(
600        CourseModulesSchema,
601        "
602SELECT id,
603  created_at,
604  updated_at,
605  deleted_at,
606  name,
607  course_id,
608  order_number,
609  copied_from,
610  uh_course_code,
611  automatic_completion,
612  automatic_completion_number_of_exercises_attempted_treshold,
613  automatic_completion_number_of_points_treshold,
614  automatic_completion_requires_exam,
615  completion_registration_link_override,
616  ects_credits,
617  enable_registering_completion_to_uh_open_university,
618  certification_enabled,
619  enable_credit_registration_via_suotar
620FROM course_modules
621WHERE id = ANY($1)
622  AND deleted_at IS NULL
623        ",
624        ids,
625    )
626    .map(|x| x.into())
627    .fetch_all(conn)
628    .await?;
629    Ok(res)
630}
631
632pub async fn get_by_course_id(
633    conn: &mut PgConnection,
634    course_id: Uuid,
635) -> ModelResult<Vec<CourseModule>> {
636    let res = sqlx::query_as!(
637        CourseModulesSchema,
638        "
639SELECT id,
640  created_at,
641  updated_at,
642  deleted_at,
643  name,
644  course_id,
645  order_number,
646  copied_from,
647  uh_course_code,
648  automatic_completion,
649  automatic_completion_number_of_exercises_attempted_treshold,
650  automatic_completion_number_of_points_treshold,
651  automatic_completion_requires_exam,
652  completion_registration_link_override,
653  ects_credits,
654  enable_registering_completion_to_uh_open_university,
655  certification_enabled,
656  enable_credit_registration_via_suotar
657FROM course_modules
658WHERE course_id = $1
659AND deleted_at IS NULL
660",
661        course_id
662    )
663    .map(|x| x.into())
664    .fetch_all(conn)
665    .await?;
666    Ok(res)
667}
668
669/// Batched [`get_by_course_id`]: modules for many courses in one query, to group in memory instead
670/// of one query per course.
671pub async fn get_by_course_ids(
672    conn: &mut PgConnection,
673    course_ids: &[Uuid],
674) -> ModelResult<Vec<CourseModule>> {
675    let res = sqlx::query_as!(
676        CourseModulesSchema,
677        "
678SELECT id,
679  created_at,
680  updated_at,
681  deleted_at,
682  name,
683  course_id,
684  order_number,
685  copied_from,
686  uh_course_code,
687  automatic_completion,
688  automatic_completion_number_of_exercises_attempted_treshold,
689  automatic_completion_number_of_points_treshold,
690  automatic_completion_requires_exam,
691  completion_registration_link_override,
692  ects_credits,
693  enable_registering_completion_to_uh_open_university,
694  certification_enabled,
695  enable_credit_registration_via_suotar
696FROM course_modules
697WHERE course_id = ANY($1)
698AND deleted_at IS NULL
699",
700        course_ids
701    )
702    .map(|x| x.into())
703    .fetch_all(conn)
704    .await?;
705    Ok(res)
706}
707
708pub async fn get_by_course_id_only_with_open_chapters(
709    conn: &mut PgConnection,
710    course_id: Uuid,
711) -> ModelResult<Vec<CourseModule>> {
712    let res = sqlx::query_as!(
713        CourseModulesSchema,
714        "
715SELECT cm.id,
716  cm.created_at,
717  cm.updated_at,
718  cm.deleted_at,
719  cm.name,
720  cm.course_id,
721  cm.order_number,
722  cm.copied_from,
723  cm.uh_course_code,
724  cm.automatic_completion,
725  cm.automatic_completion_number_of_exercises_attempted_treshold,
726  cm.automatic_completion_number_of_points_treshold,
727  cm.automatic_completion_requires_exam,
728  cm.completion_registration_link_override,
729  cm.ects_credits,
730  cm.enable_registering_completion_to_uh_open_university,
731  cm.certification_enabled,
732  enable_credit_registration_via_suotar
733FROM course_modules as cm
734WHERE EXISTS (
735  SELECT 1
736  FROM chapters as ch
737  WHERE ch.course_module_id = cm.id
738    AND ((ch.opens_at < now()) OR ch.opens_at IS NULL)
739    AND ch.deleted_at IS NULL
740)
741  AND cm.course_id = $1
742  AND cm.deleted_at IS NULL
743",
744        course_id
745    )
746    .map(|x| x.into())
747    .fetch_all(conn)
748    .await?;
749    Ok(res)
750}
751
752/// Gets course module where the given exercise belongs to. This will result in an error in the case
753/// of an exam exercise.
754pub async fn get_by_exercise_id(
755    conn: &mut PgConnection,
756    exercise_id: Uuid,
757) -> ModelResult<CourseModule> {
758    let res = sqlx::query_as!(
759        CourseModulesSchema,
760        r#"
761SELECT course_modules.id AS "id!",
762  course_modules.created_at AS "created_at!",
763  course_modules.updated_at AS "updated_at!",
764  course_modules.deleted_at,
765  course_modules.name,
766  course_modules.course_id AS "course_id!",
767  course_modules.order_number AS "order_number!",
768  course_modules.copied_from,
769  course_modules.uh_course_code,
770  course_modules.automatic_completion AS "automatic_completion!",
771  course_modules.automatic_completion_number_of_exercises_attempted_treshold,
772  course_modules.automatic_completion_number_of_points_treshold,
773  course_modules.automatic_completion_requires_exam AS "automatic_completion_requires_exam!",
774  course_modules.completion_registration_link_override,
775  course_modules.ects_credits,
776  course_modules.enable_registering_completion_to_uh_open_university AS "enable_registering_completion_to_uh_open_university!",
777  course_modules.certification_enabled AS "certification_enabled!",
778  course_modules.enable_credit_registration_via_suotar AS "enable_credit_registration_via_suotar!"
779FROM exercises
780  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
781  LEFT JOIN course_modules ON (chapters.course_module_id = course_modules.id)
782WHERE exercises.id = $1
783  AND chapters.deleted_at IS NULL
784  AND course_modules.deleted_at IS NULL
785        "#,
786        exercise_id,
787    )
788    .fetch_one(conn)
789    .await?;
790    Ok(res.into())
791}
792
793pub async fn get_course_module_id_by_chapter(
794    conn: &mut PgConnection,
795    chapter_id: Uuid,
796) -> ModelResult<Uuid> {
797    let res: Uuid = sqlx::query!(
798        r#"
799SELECT c.course_module_id
800from chapters c
801where c.id = $1
802  AND deleted_at IS NULL
803  "#,
804        chapter_id
805    )
806    .map(|record| record.course_module_id)
807    .fetch_one(conn)
808    .await?;
809    Ok(res)
810}
811
812pub async fn get_chapter_and_exercise_counts(
813    conn: &mut PgConnection,
814    course_module_id: Uuid,
815) -> ModelResult<ModuleSizeCounts> {
816    let res = sqlx::query_as!(
817        ModuleSizeCounts,
818        r#"
819SELECT COUNT(DISTINCT c.id) AS "chapters!",
820  COUNT(e.id) AS "exercises!"
821FROM chapters c
822  LEFT JOIN exercises e ON e.chapter_id = c.id
823  AND e.deleted_at IS NULL
824WHERE c.course_module_id = $1
825  AND c.deleted_at IS NULL
826        "#,
827        course_module_id
828    )
829    .fetch_one(conn)
830    .await?;
831    Ok(res)
832}
833
834pub async fn get_default_by_course_id(
835    conn: &mut PgConnection,
836    course_id: Uuid,
837) -> ModelResult<CourseModule> {
838    let res = sqlx::query_as!(
839        CourseModulesSchema,
840        "
841SELECT id,
842  created_at,
843  updated_at,
844  deleted_at,
845  name,
846  course_id,
847  order_number,
848  copied_from,
849  uh_course_code,
850  automatic_completion,
851  automatic_completion_number_of_exercises_attempted_treshold,
852  automatic_completion_number_of_points_treshold,
853  automatic_completion_requires_exam,
854  completion_registration_link_override,
855  ects_credits,
856  enable_registering_completion_to_uh_open_university,
857  certification_enabled,
858  enable_credit_registration_via_suotar
859FROM course_modules
860WHERE course_id = $1
861  AND name IS NULL
862  AND deleted_at IS NULL
863        ",
864        course_id,
865    )
866    .fetch_one(conn)
867    .await?;
868    Ok(res.into())
869}
870
871/// Gets all course modules with a matching `uh_course_code` or course `slug`.
872///
873/// In the latter case only one record at most is returned, but there is no way to distinguish between
874/// these two scenarios in advance.
875pub async fn get_ids_by_course_slug_or_uh_course_code(
876    conn: &mut PgConnection,
877    course_slug_or_code: &str,
878) -> ModelResult<Vec<Uuid>> {
879    let res = sqlx::query!(
880        "
881SELECT course_modules.id
882FROM course_modules
883  LEFT JOIN courses ON (course_modules.course_id = courses.id)
884WHERE (
885    course_modules.uh_course_code = $1
886    OR courses.slug = $1
887  )
888  AND course_modules.deleted_at IS NULL
889        ",
890        course_slug_or_code,
891    )
892    .map(|record| record.id)
893    .fetch_all(conn)
894    .await?;
895    Ok(res)
896}
897
898/// Gets course modules for the given course as a map, indexed by the `id` field.
899pub async fn get_by_course_id_as_map(
900    conn: &mut PgConnection,
901    course_id: Uuid,
902) -> ModelResult<HashMap<Uuid, CourseModule>> {
903    let res = get_by_course_id(conn, course_id)
904        .await?
905        .into_iter()
906        .map(|course_module| (course_module.id, course_module))
907        .collect();
908    Ok(res)
909}
910
911pub async fn get_all_uh_course_codes_for_open_university(
912    conn: &mut PgConnection,
913) -> ModelResult<Vec<String>> {
914    let res = sqlx::query!(
915        "
916SELECT DISTINCT uh_course_code
917FROM course_modules
918WHERE uh_course_code IS NOT NULL
919  AND enable_registering_completion_to_uh_open_university = true
920  AND deleted_at IS NULL
921"
922    )
923    .fetch_all(conn)
924    .await?
925    .into_iter()
926    .filter_map(|x| x.uh_course_code)
927    .collect();
928    Ok(res)
929}
930
931/// Ids of the course's modules opted in to credit registration; the flag is not on the
932/// [`CourseModule`] DTO.
933pub async fn get_credit_registration_enabled_ids_for_course(
934    conn: &mut PgConnection,
935    course_id: Uuid,
936) -> ModelResult<Vec<Uuid>> {
937    let res = sqlx::query_scalar!(
938        "
939SELECT id
940FROM course_modules
941WHERE course_id = $1
942  AND enable_credit_registration_via_suotar
943  AND deleted_at IS NULL
944ORDER BY order_number
945        ",
946        course_id
947    )
948    .fetch_all(conn)
949    .await?;
950    Ok(res)
951}
952
953/// The gate for admin actions that are not tied to one course (e.g. resolving or manually
954/// linking a student number), where `get_credit_registration_enabled_ids_for_course` has no
955/// course to check against.
956pub async fn any_credit_registration_enabled(conn: &mut PgConnection) -> ModelResult<bool> {
957    let res = sqlx::query_scalar!(
958        r#"
959SELECT EXISTS(
960  SELECT 1
961  FROM course_modules
962  WHERE enable_credit_registration_via_suotar
963    AND deleted_at IS NULL
964) AS "exists!"
965        "#
966    )
967    .fetch_one(conn)
968    .await?;
969    Ok(res)
970}
971
972/// Every course the user is enrolled on that has at least one module opted in to credit
973/// registration, so the profile page can list a course the student was never asked about.
974pub async fn get_credit_registration_course_ids_for_enrolled_user(
975    conn: &mut PgConnection,
976    user_id: Uuid,
977) -> ModelResult<Vec<Uuid>> {
978    let res = sqlx::query_scalar!(
979        "
980SELECT DISTINCT cm.course_id
981FROM course_modules cm
982  JOIN course_instance_enrollments cie ON cie.course_id = cm.course_id
983  JOIN courses c ON c.id = cm.course_id
984WHERE cie.user_id = $1
985  AND cie.deleted_at IS NULL
986  AND cm.enable_credit_registration_via_suotar
987  AND cm.deleted_at IS NULL
988  AND c.deleted_at IS NULL
989        ",
990        user_id
991    )
992    .fetch_all(conn)
993    .await?;
994    Ok(res)
995}
996
997/// Shared by every getter below, so the 13-column join lives in one place. `enabled_only` isn't a
998/// "this id or any" filter like the other two: the by-id and by-course lookups want every module,
999/// opted in or not, while the all-modules listing wants only the opted-in ones.
1000async fn credit_registration_configs(
1001    conn: &mut PgConnection,
1002    course_module_id: Option<Uuid>,
1003    course_id: Option<Uuid>,
1004    enabled_only: bool,
1005) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
1006    let res = sqlx::query_as!(
1007        CourseModuleCreditRegistrationConfig,
1008        r#"
1009SELECT cm.id AS course_module_id,
1010  cm.course_id,
1011  cm.enable_credit_registration_via_suotar,
1012  cm.uh_course_code,
1013  cm.ects_credits,
1014  c.open_university_product_id AS "open_university_product_id?",
1015  c.grade_scale_id AS "credit_registration_grade_scale_id?",
1016  c.paused_at AS "credit_registration_paused_at?",
1017  c.paused_by_user_id AS "credit_registration_paused_by_user_id?",
1018  c.pause_reason AS "credit_registration_pause_reason?",
1019  c.config_checked_at AS "credit_registration_config_checked_at?",
1020  c.course_code_resolves AS "credit_registration_course_code_resolves?",
1021  c.product_token_found AS "credit_registration_product_token_found?",
1022  c.config_check_message AS "credit_registration_config_check_message?"
1023FROM course_modules cm
1024  LEFT JOIN course_module_suotar_configurations c ON c.course_module_id = cm.id
1025  AND c.deleted_at IS NULL
1026WHERE ($1::uuid IS NULL OR cm.id = $1)
1027  AND ($2::uuid IS NULL OR cm.course_id = $2)
1028  AND (NOT $3::bool OR cm.enable_credit_registration_via_suotar)
1029  AND cm.deleted_at IS NULL
1030ORDER BY cm.course_id,
1031  cm.order_number
1032        "#,
1033        course_module_id,
1034        course_id,
1035        enabled_only,
1036    )
1037    .fetch_all(conn)
1038    .await?;
1039    Ok(res)
1040}
1041
1042pub async fn get_credit_registration_config(
1043    conn: &mut PgConnection,
1044    course_module_id: Uuid,
1045) -> ModelResult<CourseModuleCreditRegistrationConfig> {
1046    credit_registration_configs(conn, Some(course_module_id), None, false)
1047        .await?
1048        .into_iter()
1049        .next()
1050        .ok_or_else(missing_model_error(
1051            ModelErrorType::RecordNotFound,
1052            "Course module not found".to_string(),
1053        ))
1054}
1055
1056/// Every module of one course with its Suotar configuration, opted in or not: the module editor has
1057/// to show an unconfigured module's empty fields.
1058pub async fn get_credit_registration_configs_by_course_id(
1059    conn: &mut PgConnection,
1060    course_id: Uuid,
1061) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
1062    credit_registration_configs(conn, None, Some(course_id), false).await
1063}
1064
1065/// Every module opted in to credit registration via Suotar, paused ones included.
1066pub async fn get_all_suotar_enabled(
1067    conn: &mut PgConnection,
1068) -> ModelResult<Vec<CourseModuleCreditRegistrationConfig>> {
1069    credit_registration_configs(conn, None, None, true).await
1070}
1071
1072pub async fn update_automatic_completion_status(
1073    conn: &mut PgConnection,
1074    id: Uuid,
1075    automatic_completion_policy: &CompletionPolicy,
1076) -> ModelResult<CourseModule> {
1077    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
1078        automatic_completion_policy.to_database_fields();
1079    let res = sqlx::query_as!(
1080        CourseModulesSchema,
1081        "
1082UPDATE course_modules
1083SET automatic_completion = $1,
1084  automatic_completion_number_of_exercises_attempted_treshold = $2,
1085  automatic_completion_number_of_points_treshold = $3,
1086  automatic_completion_requires_exam = $4
1087WHERE id = $5
1088  AND deleted_at IS NULL
1089RETURNING id,
1090  created_at,
1091  updated_at,
1092  deleted_at,
1093  name,
1094  course_id,
1095  order_number,
1096  copied_from,
1097  uh_course_code,
1098  automatic_completion,
1099  automatic_completion_number_of_exercises_attempted_treshold,
1100  automatic_completion_number_of_points_treshold,
1101  automatic_completion_requires_exam,
1102  completion_registration_link_override,
1103  ects_credits,
1104  enable_registering_completion_to_uh_open_university,
1105  certification_enabled,
1106  enable_credit_registration_via_suotar
1107        ",
1108        automatic_completion,
1109        exercises_treshold,
1110        points_treshold,
1111        requires_exam,
1112        id,
1113    )
1114    .fetch_one(conn)
1115    .await?;
1116    Ok(res.into())
1117}
1118
1119pub async fn update_uh_course_code(
1120    conn: &mut PgConnection,
1121    id: Uuid,
1122    uh_course_code: Option<String>,
1123) -> ModelResult<CourseModule> {
1124    let res = sqlx::query_as!(
1125        CourseModulesSchema,
1126        "
1127UPDATE course_modules
1128SET uh_course_code = $1
1129WHERE id = $2
1130  AND deleted_at IS NULL
1131RETURNING id,
1132  created_at,
1133  updated_at,
1134  deleted_at,
1135  name,
1136  course_id,
1137  order_number,
1138  copied_from,
1139  uh_course_code,
1140  automatic_completion,
1141  automatic_completion_number_of_exercises_attempted_treshold,
1142  automatic_completion_number_of_points_treshold,
1143  automatic_completion_requires_exam,
1144  completion_registration_link_override,
1145  ects_credits,
1146  enable_registering_completion_to_uh_open_university,
1147  certification_enabled,
1148  enable_credit_registration_via_suotar
1149        ",
1150        uh_course_code,
1151        id,
1152    )
1153    .fetch_one(conn)
1154    .await?;
1155    Ok(res.into())
1156}
1157
1158pub async fn update_enable_registering_completion_to_uh_open_university(
1159    conn: &mut PgConnection,
1160    id: Uuid,
1161    enable_registering_completion_to_uh_open_university: bool,
1162) -> ModelResult<CourseModule> {
1163    let res = sqlx::query_as!(
1164        CourseModulesSchema,
1165        "
1166UPDATE course_modules
1167SET enable_registering_completion_to_uh_open_university = $1
1168WHERE id = $2
1169  AND deleted_at IS NULL
1170RETURNING id,
1171  created_at,
1172  updated_at,
1173  deleted_at,
1174  name,
1175  course_id,
1176  order_number,
1177  copied_from,
1178  uh_course_code,
1179  automatic_completion,
1180  automatic_completion_number_of_exercises_attempted_treshold,
1181  automatic_completion_number_of_points_treshold,
1182  automatic_completion_requires_exam,
1183  completion_registration_link_override,
1184  ects_credits,
1185  enable_registering_completion_to_uh_open_university,
1186  certification_enabled,
1187  enable_credit_registration_via_suotar
1188        ",
1189        enable_registering_completion_to_uh_open_university,
1190        id,
1191    )
1192    .fetch_one(conn)
1193    .await?;
1194    Ok(res.into())
1195}
1196
1197pub async fn update_with_order_number(
1198    conn: &mut PgConnection,
1199    id: Uuid,
1200    name: Option<&str>,
1201    order_number: i32,
1202) -> ModelResult<()> {
1203    sqlx::query!(
1204        "
1205UPDATE course_modules
1206SET name = COALESCE($1, name),
1207  order_number = $2
1208WHERE id = $3
1209",
1210        name,
1211        order_number,
1212        id,
1213    )
1214    .execute(conn)
1215    .await?;
1216    Ok(())
1217}
1218
1219pub async fn update(
1220    conn: &mut PgConnection,
1221    id: Uuid,
1222    updated_course_module: &NewCourseModule,
1223) -> ModelResult<()> {
1224    // destructure so new fields cause a compilation error here
1225    let NewCourseModule {
1226        completion_policy: _,
1227        course_id: _,
1228        ects_credits,
1229        order_number,
1230        name,
1231        uh_course_code,
1232        completion_registration_link_override,
1233        enable_registering_completion_to_uh_open_university,
1234        enable_credit_registration_via_suotar,
1235    } = updated_course_module;
1236    validate_one_credit_registration_path(
1237        *enable_credit_registration_via_suotar,
1238        *enable_registering_completion_to_uh_open_university,
1239    )?;
1240    let (automatic_completion, exercises_treshold, points_treshold, requires_exam) =
1241        updated_course_module.completion_policy.to_database_fields();
1242    sqlx::query!(
1243        "
1244UPDATE course_modules
1245SET name = COALESCE($2, name),
1246  order_number = $3,
1247  uh_course_code = $4,
1248  ects_credits = $5,
1249  automatic_completion = $6,
1250  automatic_completion_number_of_exercises_attempted_treshold = $7,
1251  automatic_completion_number_of_points_treshold = $8,
1252  automatic_completion_requires_exam = $9,
1253  completion_registration_link_override = $10,
1254  enable_registering_completion_to_uh_open_university = $11,
1255  enable_credit_registration_via_suotar = $12
1256WHERE id = $1
1257        ",
1258        id,
1259        name.as_ref(),
1260        order_number,
1261        uh_course_code.as_ref(),
1262        ects_credits.as_ref(),
1263        automatic_completion,
1264        exercises_treshold,
1265        points_treshold,
1266        requires_exam,
1267        completion_registration_link_override.as_ref(),
1268        enable_registering_completion_to_uh_open_university,
1269        enable_credit_registration_via_suotar
1270    )
1271    .execute(conn)
1272    .await?;
1273    Ok(())
1274}
1275
1276/// Writes the module's Suotar configuration row and reconciles its realisations. An unknown grade
1277/// scale is refused here because otherwise it surfaces as `no_grade_scale_mapping` on every
1278/// completion of the module, long after the teacher left the editor.
1279pub async fn set_credit_registration_config(
1280    conn: &mut PgConnection,
1281    course_module_id: Uuid,
1282    edit: &CourseModuleCreditRegistrationEdit,
1283) -> ModelResult<()> {
1284    let grade_scale_id = edit.grade_scale_id.as_deref().and_then(non_empty);
1285    if let Some(scale) = grade_scale_id
1286        && grade_scale_family(scale).is_none()
1287    {
1288        return Err(model_err!(
1289            PreconditionFailed,
1290            format!("The study registry does not know the grade scale {scale}.")
1291        ));
1292    }
1293    course_module_suotar_configurations::upsert(
1294        conn,
1295        course_module_id,
1296        edit.open_university_product_id
1297            .as_deref()
1298            .and_then(non_empty),
1299        grade_scale_id,
1300    )
1301    .await?;
1302    course_module_suotar_realisations::replace_for_course_module(
1303        conn,
1304        course_module_id,
1305        &edit.realisations,
1306    )
1307    .await?;
1308    Ok(())
1309}
1310
1311/// A blanked text field means "not configured", not the empty string.
1312fn non_empty(value: &str) -> Option<&str> {
1313    let trimmed = value.trim();
1314    (!trimmed.is_empty()).then_some(trimmed)
1315}
1316
1317pub async fn update_modules(
1318    conn: &mut PgConnection,
1319    course_id: Uuid,
1320    updates: ModuleUpdates,
1321) -> ModelResult<()> {
1322    let mut tx = conn.begin().await?;
1323
1324    // scramble order of modified and deleted modules
1325    for module_id in updates
1326        .modified_modules
1327        .iter()
1328        // do not scramble the default module, it should always be first
1329        .filter(|m| m.order_number != 0)
1330        .map(|m| m.id)
1331        .chain(updates.deleted_modules.iter().copied())
1332    {
1333        update_with_order_number(&mut tx, module_id, None, rand::random()).await?;
1334    }
1335    let mut modified_and_new_modules = updates.modified_modules;
1336    for new in updates.new_modules {
1337        // destructure so new fields cause a compilation error here
1338        let NewModule {
1339            name,
1340            order_number,
1341            chapters,
1342            uh_course_code,
1343            ects_credits,
1344            completion_policy,
1345            completion_registration_link_override,
1346            enable_registering_completion_to_uh_open_university,
1347            enable_credit_registration_via_suotar,
1348            credit_registration,
1349        } = new;
1350        // insert with a random order number to avoid conflicts
1351        let new_course_module = NewCourseModule::new(course_id, Some(name.clone()), rand::random())
1352            .set_completion_policy(completion_policy.clone())
1353            .set_completion_registration_link_override(completion_registration_link_override)
1354            .set_ects_credits(ects_credits)
1355            .set_uh_course_code(uh_course_code)
1356            .set_enable_registering_completion_to_uh_open_university(
1357                enable_registering_completion_to_uh_open_university,
1358            )
1359            .set_enable_credit_registration_via_suotar(enable_credit_registration_via_suotar);
1360        let module = insert(&mut tx, PKeyPolicy::Generate, &new_course_module).await?;
1361        for chapter in chapters {
1362            chapters::set_module(&mut tx, chapter, module.id).await?;
1363        }
1364        //modify the order number with the rest
1365        modified_and_new_modules.push(ModifiedModule {
1366            id: module.id,
1367            name: None,
1368            order_number,
1369            uh_course_code: module.uh_course_code,
1370            ects_credits,
1371            completion_policy,
1372            completion_registration_link_override: module.completion_registration_link_override,
1373            enable_registering_completion_to_uh_open_university: module
1374                .enable_registering_completion_to_uh_open_university,
1375            enable_credit_registration_via_suotar,
1376            credit_registration,
1377        })
1378    }
1379    // update modified and new modules
1380    for module in modified_and_new_modules {
1381        // destructure so new fields cause a compilation error here
1382        let ModifiedModule {
1383            id,
1384            name,
1385            order_number,
1386            uh_course_code,
1387            ects_credits,
1388            completion_policy,
1389            completion_registration_link_override,
1390            enable_registering_completion_to_uh_open_university,
1391            enable_credit_registration_via_suotar,
1392            credit_registration,
1393        } = module;
1394        update(
1395            &mut tx,
1396            id,
1397            &NewCourseModule::new(course_id, name.clone(), order_number)
1398                .set_completion_policy(completion_policy)
1399                .set_completion_registration_link_override(completion_registration_link_override)
1400                .set_ects_credits(ects_credits)
1401                .set_uh_course_code(uh_course_code)
1402                .set_enable_registering_completion_to_uh_open_university(
1403                    enable_registering_completion_to_uh_open_university,
1404                )
1405                .set_enable_credit_registration_via_suotar(enable_credit_registration_via_suotar),
1406        )
1407        .await?;
1408        // Skipped for a module that is neither enabled nor configured and has nothing stored, so
1409        // editing an unrelated module cannot create a configuration row for it — nor undelete one,
1410        // since the upsert clears `deleted_at` while keeping a stale `paused_at`. A module that
1411        // does have a row still writes, or clearing every field would be discarded rather than
1412        // applied: the form submits the same empty payload either way.
1413        if enable_credit_registration_via_suotar
1414            || !credit_registration.is_empty()
1415            || course_module_suotar_configurations::exists(&mut tx, id).await?
1416        {
1417            set_credit_registration_config(&mut tx, id, &credit_registration).await?;
1418        }
1419    }
1420    for (chapter, module) in updates.moved_chapters {
1421        chapters::set_module(&mut tx, chapter, module).await?;
1422    }
1423    for deleted in updates.deleted_modules {
1424        delete(&mut tx, deleted).await?;
1425    }
1426
1427    tx.commit().await?;
1428    Ok(())
1429}
1430
1431pub async fn update_certification_enabled(
1432    conn: &mut PgConnection,
1433    id: Uuid,
1434    enabled: bool,
1435) -> ModelResult<()> {
1436    sqlx::query!(
1437        "
1438UPDATE course_modules
1439SET certification_enabled = $1
1440WHERE id = $2
1441",
1442        enabled,
1443        id
1444    )
1445    .execute(conn)
1446    .await?;
1447    Ok(())
1448}
1449
1450#[cfg(test)]
1451mod tests {
1452
1453    mod automatic_completion_requirements {
1454        use uuid::Uuid;
1455
1456        use super::super::AutomaticCompletionRequirements;
1457
1458        #[test]
1459        fn passes_exercise_tresholds() {
1460            let requirements1 = AutomaticCompletionRequirements {
1461                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1462                number_of_exercises_attempted_treshold: Some(10),
1463                number_of_points_treshold: Some(50),
1464                requires_exam: false,
1465            };
1466            let requirements2 = AutomaticCompletionRequirements {
1467                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1468                number_of_exercises_attempted_treshold: Some(50),
1469                number_of_points_treshold: Some(10),
1470                requires_exam: false,
1471            };
1472
1473            let requirements3 = AutomaticCompletionRequirements {
1474                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1475                number_of_exercises_attempted_treshold: Some(0),
1476                number_of_points_treshold: Some(0),
1477                requires_exam: false,
1478            };
1479
1480            let requirements4 = AutomaticCompletionRequirements {
1481                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1482                number_of_exercises_attempted_treshold: Some(10),
1483                number_of_points_treshold: None,
1484                requires_exam: false,
1485            };
1486
1487            let requirements5 = AutomaticCompletionRequirements {
1488                course_module_id: Uuid::parse_str("66d98fc6-784a-4b39-a494-24ae9b1c9b14").unwrap(),
1489                number_of_exercises_attempted_treshold: None,
1490                number_of_points_treshold: Some(10),
1491                requires_exam: false,
1492            };
1493            assert!(requirements1.passes_exercise_tresholds(10, 50));
1494            assert!(requirements2.passes_exercise_tresholds(50, 10));
1495
1496            assert!(!requirements1.passes_exercise_tresholds(50, 10));
1497            assert!(!requirements2.passes_exercise_tresholds(10, 50));
1498
1499            assert!(!requirements1.passes_exercise_tresholds(100, 0));
1500            assert!(!requirements2.passes_exercise_tresholds(100, 0));
1501
1502            assert!(requirements3.passes_exercise_tresholds(1, 1));
1503            assert!(requirements3.passes_exercise_tresholds(0, 0));
1504
1505            assert!(requirements4.passes_exercise_tresholds(10, 1));
1506            assert!(!requirements4.passes_exercise_tresholds(1, 10));
1507
1508            assert!(requirements5.passes_exercise_tresholds(0, 10));
1509            assert!(!requirements5.passes_exercise_tresholds(10, 0));
1510        }
1511    }
1512
1513    mod credit_registration_config {
1514        use super::super::*;
1515        use crate::test_helper::*;
1516        use headless_lms_base::error::backend_error::BackendError;
1517
1518        fn edit() -> CourseModuleCreditRegistrationEdit {
1519            CourseModuleCreditRegistrationEdit {
1520                open_university_product_id: Some(" hy-opt-cur-1 ".to_string()),
1521                grade_scale_id: Some("".to_string()),
1522                realisations: vec![CourseModuleSuotarRealisationEdit {
1523                    course_unit_realisation_id: "hy-CUR-1".to_string(),
1524                    label: Some("Autumn 2026".to_string()),
1525                    active: true,
1526                }],
1527            }
1528        }
1529
1530        #[tokio::test]
1531        async fn a_module_cannot_take_both_registration_paths() {
1532            insert_data!(:tx, :user, :org, :course);
1533            let course_module = insert(
1534                tx.as_mut(),
1535                PKeyPolicy::Generate,
1536                &NewCourseModule::new(course, Some("Module".to_string()), 1),
1537            )
1538            .await
1539            .unwrap();
1540            let both = NewCourseModule::new(course, Some("Both".to_string()), 1)
1541                .set_enable_registering_completion_to_uh_open_university(true)
1542                .set_enable_credit_registration_via_suotar(true);
1543
1544            let updated = update(tx.as_mut(), course_module.id, &both)
1545                .await
1546                .unwrap_err();
1547            assert_eq!(*updated.error_type(), ModelErrorType::PreconditionFailed);
1548            let inserted = insert(tx.as_mut(), PKeyPolicy::Generate, &both)
1549                .await
1550                .unwrap_err();
1551            assert_eq!(*inserted.error_type(), ModelErrorType::PreconditionFailed);
1552        }
1553
1554        /// Blanks must land as absences, not empty strings.
1555        #[tokio::test]
1556        async fn the_editor_fields_land_in_the_configuration_tables() {
1557            insert_data!(:tx, :user, :org, :course);
1558            let course_module = insert(
1559                tx.as_mut(),
1560                PKeyPolicy::Generate,
1561                &NewCourseModule::new(course, Some("Module".to_string()), 1),
1562            )
1563            .await
1564            .unwrap();
1565            update(
1566                tx.as_mut(),
1567                course_module.id,
1568                &NewCourseModule::new(course, course_module.name.clone(), 1)
1569                    .set_enable_credit_registration_via_suotar(true),
1570            )
1571            .await
1572            .unwrap();
1573            set_credit_registration_config(tx.as_mut(), course_module.id, &edit())
1574                .await
1575                .unwrap();
1576
1577            let config = get_credit_registration_config(tx.as_mut(), course_module.id)
1578                .await
1579                .unwrap();
1580            assert!(config.enable_credit_registration_via_suotar);
1581            assert_eq!(
1582                config.open_university_product_id.as_deref(),
1583                Some("hy-opt-cur-1")
1584            );
1585            assert_eq!(config.credit_registration_grade_scale_id, None);
1586            let realisations = course_module_suotar_realisations::get_by_course_module_id(
1587                tx.as_mut(),
1588                course_module.id,
1589            )
1590            .await
1591            .unwrap();
1592            assert_eq!(realisations.len(), 1);
1593            assert_eq!(realisations[0].course_unit_realisation_id, "hy-CUR-1");
1594            assert_eq!(realisations[0].label.as_deref(), Some("Autumn 2026"));
1595        }
1596
1597        #[tokio::test]
1598        async fn removing_a_realisation_deletes_it_and_an_unknown_scale_is_refused() {
1599            insert_data!(:tx, :user, :org, :course);
1600            let course_module = insert(
1601                tx.as_mut(),
1602                PKeyPolicy::Generate,
1603                &NewCourseModule::new(course, Some("Module".to_string()), 1),
1604            )
1605            .await
1606            .unwrap();
1607            set_credit_registration_config(tx.as_mut(), course_module.id, &edit())
1608                .await
1609                .unwrap();
1610            set_credit_registration_config(
1611                tx.as_mut(),
1612                course_module.id,
1613                &CourseModuleCreditRegistrationEdit {
1614                    realisations: Vec::new(),
1615                    ..edit()
1616                },
1617            )
1618            .await
1619            .unwrap();
1620            assert!(
1621                course_module_suotar_realisations::get_by_course_module_id(
1622                    tx.as_mut(),
1623                    course_module.id
1624                )
1625                .await
1626                .unwrap()
1627                .is_empty()
1628            );
1629
1630            let refused = set_credit_registration_config(
1631                tx.as_mut(),
1632                course_module.id,
1633                &CourseModuleCreditRegistrationEdit {
1634                    grade_scale_id: Some("sis-nonsense".to_string()),
1635                    ..edit()
1636                },
1637            )
1638            .await
1639            .unwrap_err();
1640            assert_eq!(*refused.error_type(), ModelErrorType::PreconditionFailed);
1641        }
1642    }
1643}