Skip to main content

headless_lms_server/programs/seed/builder/
module.rs

1use anyhow::{Context, Result};
2
3use headless_lms_models::course_module_suotar_configurations::{self, SuotarPause};
4use headless_lms_models::course_modules::{
5    self, AutomaticCompletionRequirements, CompletionPolicy, CourseModule,
6    CourseModuleCreditRegistrationEdit, CourseModuleSuotarRealisationEdit, NewCourseModule,
7};
8use sqlx::PgConnection;
9
10use crate::programs::seed::builder::{chapter::ChapterBuilder, context::SeedContext};
11use chrono::{DateTime, Utc};
12use headless_lms_models::{
13    course_module_completion_registered_to_study_registries, course_module_completions,
14    course_module_completions::NewCourseModuleCompletionSeed,
15};
16
17use uuid::Uuid;
18
19#[derive(Debug, Clone)]
20pub struct CompletionRegisteredBuilder {
21    pub registrar_id: Option<Uuid>,
22    pub real_student_number: Option<String>,
23}
24
25impl CompletionRegisteredBuilder {
26    pub fn new() -> Self {
27        Self {
28            registrar_id: None,
29            real_student_number: None,
30        }
31    }
32
33    pub fn registrar_id(mut self, id: Uuid) -> Self {
34        self.registrar_id = Some(id);
35        self
36    }
37
38    pub fn real_student_number(mut self, num: impl Into<String>) -> Self {
39        self.real_student_number = Some(num.into());
40        self
41    }
42}
43
44impl Default for CompletionRegisteredBuilder {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50/// Builder for seeding a single course_module_completion row.
51#[derive(Debug, Clone)]
52pub struct CompletionBuilder {
53    user_id: Uuid,
54    email: Option<String>,
55    grade: Option<i32>,
56    passed: Option<bool>,
57    completion_date: Option<DateTime<Utc>>,
58    completion_language: Option<String>,
59    eligible_for_ects: Option<bool>,
60    prerequisite_modules_completed: Option<bool>,
61    needs_to_be_reviewed: Option<bool>,
62    register: Option<CompletionRegisteredBuilder>,
63}
64
65impl CompletionBuilder {
66    pub fn new(user_id: Uuid) -> Self {
67        Self {
68            user_id,
69            email: Some(format!("{}@example.com", user_id)),
70            grade: None,
71            passed: Some(true),
72            completion_date: Some(chrono::Utc::now()),
73            completion_language: Some("en-US".to_string()),
74            eligible_for_ects: Some(true),
75            prerequisite_modules_completed: Some(false),
76            needs_to_be_reviewed: Some(false),
77            register: None,
78        }
79    }
80
81    pub fn registered(mut self, r: CompletionRegisteredBuilder) -> Self {
82        self.register = Some(r);
83        self
84    }
85
86    pub fn email(mut self, v: impl Into<String>) -> Self {
87        self.email = Some(v.into());
88        self
89    }
90
91    pub fn grade(mut self, v: i32) -> Self {
92        self.grade = Some(v);
93        self
94    }
95
96    pub fn passed(mut self, v: bool) -> Self {
97        self.passed = Some(v);
98        self
99    }
100
101    pub fn completion_date(mut self, v: DateTime<Utc>) -> Self {
102        self.completion_date = Some(v);
103        self
104    }
105
106    pub fn completion_language(mut self, v: impl Into<String>) -> Self {
107        self.completion_language = Some(v.into());
108        self
109    }
110
111    pub fn eligible_for_ects(mut self, v: bool) -> Self {
112        self.eligible_for_ects = Some(v);
113        self
114    }
115
116    pub fn prerequisite_modules_completed(mut self, v: bool) -> Self {
117        self.prerequisite_modules_completed = Some(v);
118        self
119    }
120
121    pub fn needs_to_be_reviewed(mut self, v: bool) -> Self {
122        self.needs_to_be_reviewed = Some(v);
123        self
124    }
125
126    pub async fn seed(
127        &self,
128        conn: &mut sqlx::PgConnection,
129        course_id: Uuid,
130        course_module_id: Uuid,
131        default_registrar_id: Option<Uuid>,
132    ) -> anyhow::Result<()> {
133        let seed = NewCourseModuleCompletionSeed {
134            course_id,
135            course_module_id,
136            user_id: self.user_id,
137            completion_date: self.completion_date,
138            completion_language: self.completion_language.clone(),
139            eligible_for_ects: self.eligible_for_ects,
140            email: self.email.clone(),
141            grade: self.grade,
142            passed: self.passed,
143            prerequisite_modules_completed: self.prerequisite_modules_completed,
144            needs_to_be_reviewed: self.needs_to_be_reviewed,
145        };
146
147        let completion_id = course_module_completions::insert_seed_row(conn, &seed).await?;
148
149        // Mark registration attempt (seed always sets this)
150        course_module_completions::update_completion_registration_attempt_date(
151            conn,
152            completion_id,
153            Utc::now(),
154        )
155        .await
156        .ok();
157
158        if let Some(r) = &self.register {
159            let registrar_id = if let Some(id) = r.registrar_id {
160                id
161            } else if let Some(def) = default_registrar_id {
162                def
163            } else {
164                return Ok(());
165            };
166
167            if let Some(student_number) = &r.real_student_number {
168                course_module_completion_registered_to_study_registries::insert_or_ignore(
169                    conn,
170                    &course_module_completion_registered_to_study_registries::NewCourseModuleCompletionRegisteredToStudyRegistry {
171                        course_id,
172                        course_module_completion_id: completion_id,
173                        course_module_id,
174                        study_registry_registrar_id: registrar_id,
175                        user_id: self.user_id,
176                        real_student_number: student_number.clone(),
177                    },
178                )
179                .await
180                .context("insert registry record")?;
181            }
182        }
183
184        Ok(())
185    }
186}
187
188/// Turns `enable_credit_registration_via_suotar` on and writes the module's Suotar configuration.
189/// The realisation ids must be the ones the mock answers `list-by-course` for, so derive them as it
190/// does.
191#[derive(Debug, Clone, Default)]
192pub struct CreditRegistrationSeed {
193    pub open_university_product_id: Option<String>,
194    /// `None` derives the scale from the completion.
195    pub grade_scale_id: Option<String>,
196    pub active_realisation_ids: Vec<String>,
197    /// Pauses the module, which every phase's claim query skips: without it the workers running in
198    /// the test deployment walk read-only fixtures onwards.
199    pub paused_reason: Option<String>,
200}
201
202/// Builder for course modules that group chapters with ECTS credits and Open University registration.
203#[derive(Debug, Clone)]
204pub struct ModuleBuilder {
205    pub name: Option<String>,
206    pub order: Option<i32>,
207    pub ects: Option<f32>,
208    pub chapters: Vec<ChapterBuilder>,
209    pub register_to_open_university: bool,
210    pub completion_policy: CompletionPolicy,
211    pub completions: Vec<CompletionBuilder>,
212    pub default_registrar_id: Option<Uuid>,
213    pub uh_course_code: Option<String>,
214    pub credit_registration: Option<CreditRegistrationSeed>,
215}
216
217impl Default for ModuleBuilder {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl ModuleBuilder {
224    pub fn new() -> Self {
225        Self {
226            name: None,
227            order: None,
228            ects: None,
229            chapters: vec![],
230            register_to_open_university: false,
231            completion_policy: CompletionPolicy::Manual,
232            completions: vec![],
233            default_registrar_id: None,
234            uh_course_code: None,
235            credit_registration: None,
236        }
237    }
238    pub fn credit_registration(mut self, seed: CreditRegistrationSeed) -> Self {
239        self.credit_registration = Some(seed);
240        self
241    }
242    pub fn default_registrar(mut self, id: Uuid) -> Self {
243        self.default_registrar_id = Some(id);
244        self
245    }
246    pub fn completion(mut self, c: CompletionBuilder) -> Self {
247        self.completions.push(c);
248        self
249    }
250
251    pub fn completions<I: IntoIterator<Item = CompletionBuilder>>(mut self, it: I) -> Self {
252        self.completions.extend(it);
253        self
254    }
255
256    pub fn order(mut self, n: i32) -> Self {
257        self.order = Some(n);
258        self
259    }
260    pub fn name(mut self, n: impl Into<String>) -> Self {
261        self.name = Some(n.into());
262        self
263    }
264    pub fn ects(mut self, e: f32) -> Self {
265        self.ects = Some(e);
266        self
267    }
268    pub fn uh_course_code(mut self, c: String) -> Self {
269        self.uh_course_code = Some(c);
270        self
271    }
272    pub fn register_to_open_university(mut self, v: bool) -> Self {
273        self.register_to_open_university = v;
274        self
275    }
276    pub fn chapter(mut self, c: ChapterBuilder) -> Self {
277        self.chapters.push(c);
278        self
279    }
280    pub fn chapters<I: IntoIterator<Item = ChapterBuilder>>(mut self, it: I) -> Self {
281        self.chapters.extend(it);
282        self
283    }
284    pub fn completion_policy(mut self, policy: CompletionPolicy) -> Self {
285        self.completion_policy = policy;
286        self
287    }
288    pub fn manual_completion(mut self) -> Self {
289        self.completion_policy = CompletionPolicy::Manual;
290        self
291    }
292    pub fn automatic_completion(
293        mut self,
294        exercises_threshold: Option<i32>,
295        points_threshold: Option<i32>,
296        requires_exam: bool,
297    ) -> Self {
298        // We'll set the course_module_id when seeding
299        self.completion_policy = CompletionPolicy::Automatic(AutomaticCompletionRequirements {
300            course_module_id: uuid::Uuid::new_v4(), // Temporary, will be updated during seeding
301            number_of_exercises_attempted_treshold: exercises_threshold,
302            number_of_points_treshold: points_threshold,
303            requires_exam,
304        });
305        self
306    }
307
308    pub(crate) async fn seed(
309        self,
310        conn: &mut PgConnection,
311        cx: &SeedContext,
312        course_id: uuid::Uuid,
313        fallback_order: i32,
314    ) -> Result<CourseModule> {
315        let order = self.order.unwrap_or(fallback_order);
316
317        let module = course_modules::insert(
318            conn,
319            headless_lms_models::PKeyPolicy::Generate,
320            &NewCourseModule::new(course_id, self.name, order)
321                .set_ects_credits(self.ects)
322                .set_completion_policy(self.completion_policy.clone())
323                .set_uh_course_code(self.uh_course_code)
324                .set_enable_credit_registration_via_suotar(self.credit_registration.is_some()),
325        )
326        .await
327        .with_context(|| format!("inserting module (order {:?})", order))?;
328
329        // Update completion policy if it's automatic to set the correct module ID
330        if let CompletionPolicy::Automatic(mut requirements) = self.completion_policy {
331            requirements.course_module_id = module.id;
332            let updated_policy = CompletionPolicy::Automatic(requirements);
333            course_modules::update_automatic_completion_status(conn, module.id, &updated_policy)
334                .await
335                .context("updating automatic completion policy")?;
336        }
337
338        if let Some(credit_registration) = &self.credit_registration {
339            course_modules::set_credit_registration_config(
340                conn,
341                module.id,
342                &CourseModuleCreditRegistrationEdit {
343                    open_university_product_id: credit_registration
344                        .open_university_product_id
345                        .clone(),
346                    grade_scale_id: credit_registration.grade_scale_id.clone(),
347                    realisations: credit_registration
348                        .active_realisation_ids
349                        .iter()
350                        .map(|id| CourseModuleSuotarRealisationEdit {
351                            course_unit_realisation_id: id.clone(),
352                            label: None,
353                            active: true,
354                        })
355                        .collect(),
356                },
357            )
358            .await
359            .context("writing the module's credit registration configuration")?;
360            if let Some(reason) = &credit_registration.paused_reason {
361                course_module_suotar_configurations::set_paused(
362                    conn,
363                    module.id,
364                    Some(SuotarPause {
365                        paused_at: Utc::now(),
366                        paused_by_user_id: cx.teacher,
367                        reason: Some(reason),
368                    }),
369                )
370                .await
371                .context("pausing the module's credit registration")?;
372            }
373        }
374
375        if self.register_to_open_university {
376            course_modules::update_enable_registering_completion_to_uh_open_university(
377                conn, module.id, true,
378            )
379            .await
380            .context("enabling OU registration for module")?;
381        }
382
383        for comp in &self.completions {
384            comp.seed(conn, course_id, module.id, self.default_registrar_id)
385                .await?;
386        }
387
388        for ch in self.chapters {
389            ch.seed(conn, cx, course_id, module.id).await?;
390        }
391
392        Ok(module)
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use crate::programs::seed::builder::chapter::ChapterBuilder;
399
400    use super::*;
401
402    #[test]
403    fn module_builder_new() {
404        let module = ModuleBuilder::new();
405
406        assert!(module.order.is_none());
407        assert!(module.name.is_none());
408        assert!(module.ects.is_none());
409        assert!(module.chapters.is_empty());
410        assert!(!module.register_to_open_university);
411        assert_eq!(module.completion_policy, CompletionPolicy::Manual);
412    }
413
414    #[test]
415    fn module_builder_order() {
416        let module = ModuleBuilder::new().order(5);
417
418        assert_eq!(module.order, Some(5));
419    }
420
421    #[test]
422    fn module_builder_name() {
423        let module = ModuleBuilder::new().name("Test Module");
424
425        assert_eq!(module.name, Some("Test Module".to_string()));
426    }
427
428    #[test]
429    fn module_builder_name_string_conversion() {
430        let module1 = ModuleBuilder::new().name("String literal");
431        let module2 = ModuleBuilder::new().name(String::from("Owned string"));
432
433        assert_eq!(module1.name, Some("String literal".to_string()));
434        assert_eq!(module2.name, Some("Owned string".to_string()));
435    }
436
437    #[test]
438    fn module_builder_ects() {
439        let module = ModuleBuilder::new().ects(5.0);
440
441        assert_eq!(module.ects, Some(5.0));
442    }
443
444    #[test]
445    fn module_builder_ects_fractional() {
446        let module = ModuleBuilder::new().ects(2.5);
447
448        assert_eq!(module.ects, Some(2.5));
449    }
450
451    #[test]
452    fn module_builder_register_to_open_university_true() {
453        let module = ModuleBuilder::new().register_to_open_university(true);
454
455        assert!(module.register_to_open_university);
456    }
457
458    #[test]
459    fn module_builder_register_to_open_university_false() {
460        let module = ModuleBuilder::new().register_to_open_university(false);
461
462        assert!(!module.register_to_open_university);
463    }
464
465    #[test]
466    fn module_builder_chapter() {
467        let chapter = ChapterBuilder::new(1, "Test Chapter");
468        let module = ModuleBuilder::new().chapter(chapter);
469
470        assert_eq!(module.chapters.len(), 1);
471        assert_eq!(module.chapters[0].number, 1);
472        assert_eq!(module.chapters[0].name, "Test Chapter");
473    }
474
475    #[test]
476    fn module_builder_multiple_chapters() {
477        let chapter1 = ChapterBuilder::new(1, "Chapter 1");
478        let chapter2 = ChapterBuilder::new(2, "Chapter 2");
479        let module = ModuleBuilder::new().chapter(chapter1).chapter(chapter2);
480
481        assert_eq!(module.chapters.len(), 2);
482        assert_eq!(module.chapters[0].number, 1);
483        assert_eq!(module.chapters[0].name, "Chapter 1");
484        assert_eq!(module.chapters[1].number, 2);
485        assert_eq!(module.chapters[1].name, "Chapter 2");
486    }
487
488    #[test]
489    fn module_builder_fluent_interface() {
490        let chapter1 = ChapterBuilder::new(1, "Chapter 1");
491        let chapter2 = ChapterBuilder::new(2, "Chapter 2");
492
493        let module = ModuleBuilder::new()
494            .order(1)
495            .name("Advanced Module")
496            .ects(3.5)
497            .register_to_open_university(true)
498            .chapter(chapter1)
499            .chapter(chapter2);
500
501        assert_eq!(module.order, Some(1));
502        assert_eq!(module.name, Some("Advanced Module".to_string()));
503        assert_eq!(module.ects, Some(3.5));
504        assert!(module.register_to_open_university);
505        assert_eq!(module.chapters.len(), 2);
506    }
507
508    #[test]
509    fn module_builder_method_chaining_order() {
510        let chapter1 = ChapterBuilder::new(1, "Test Chapter");
511        let chapter2 = ChapterBuilder::new(1, "Test Chapter");
512
513        let module1 = ModuleBuilder::new()
514            .order(1)
515            .name("Module 1")
516            .ects(2.0)
517            .register_to_open_university(true)
518            .chapter(chapter1);
519
520        let module2 = ModuleBuilder::new()
521            .register_to_open_university(true)
522            .chapter(chapter2)
523            .ects(2.0)
524            .name("Module 1")
525            .order(1);
526
527        assert_eq!(module1.name, module2.name);
528        assert_eq!(module1.ects, module2.ects);
529        assert_eq!(
530            module1.register_to_open_university,
531            module2.register_to_open_university
532        );
533        assert_eq!(module1.chapters.len(), module2.chapters.len());
534        assert_eq!(module1.order, module2.order);
535    }
536
537    #[test]
538    fn module_builder_default_values() {
539        let module = ModuleBuilder::new();
540
541        assert!(module.order.is_none());
542        assert!(module.name.is_none());
543        assert!(module.ects.is_none());
544        assert!(module.chapters.is_empty());
545        assert!(!module.register_to_open_university);
546    }
547
548    #[test]
549    fn module_builder_order_preservation() {
550        let module = ModuleBuilder::new().order(999);
551
552        assert_eq!(module.order, Some(999));
553    }
554
555    #[test]
556    fn module_builder_manual_completion() {
557        let module = ModuleBuilder::new().manual_completion();
558
559        assert_eq!(module.completion_policy, CompletionPolicy::Manual);
560    }
561
562    #[test]
563    fn module_builder_automatic_completion() {
564        let module = ModuleBuilder::new().automatic_completion(Some(5), Some(100), true);
565
566        match module.completion_policy {
567            CompletionPolicy::Automatic(requirements) => {
568                assert_eq!(requirements.number_of_exercises_attempted_treshold, Some(5));
569                assert_eq!(requirements.number_of_points_treshold, Some(100));
570                assert!(requirements.requires_exam);
571            }
572            CompletionPolicy::Manual => panic!("Expected automatic completion policy"),
573        }
574    }
575
576    #[test]
577    fn module_builder_automatic_completion_no_thresholds() {
578        let module = ModuleBuilder::new().automatic_completion(None, None, false);
579
580        match module.completion_policy {
581            CompletionPolicy::Automatic(requirements) => {
582                assert_eq!(requirements.number_of_exercises_attempted_treshold, None);
583                assert_eq!(requirements.number_of_points_treshold, None);
584                assert!(!requirements.requires_exam);
585            }
586            CompletionPolicy::Manual => panic!("Expected automatic completion policy"),
587        }
588    }
589
590    #[test]
591    fn module_builder_completion_policy_fluent_interface() {
592        let chapter1 = ChapterBuilder::new(1, "Chapter 1");
593        let chapter2 = ChapterBuilder::new(2, "Chapter 2");
594
595        let module = ModuleBuilder::new()
596            .order(1)
597            .name("Advanced Module")
598            .ects(3.5)
599            .register_to_open_university(true)
600            .automatic_completion(Some(10), Some(200), true)
601            .chapter(chapter1)
602            .chapter(chapter2);
603
604        assert_eq!(module.order, Some(1));
605        assert_eq!(module.name, Some("Advanced Module".to_string()));
606        assert_eq!(module.ects, Some(3.5));
607        assert!(module.register_to_open_university);
608        assert_eq!(module.chapters.len(), 2);
609
610        match module.completion_policy {
611            CompletionPolicy::Automatic(requirements) => {
612                assert_eq!(
613                    requirements.number_of_exercises_attempted_treshold,
614                    Some(10)
615                );
616                assert_eq!(requirements.number_of_points_treshold, Some(200));
617                assert!(requirements.requires_exam);
618            }
619            CompletionPolicy::Manual => panic!("Expected automatic completion policy"),
620        }
621    }
622
623    #[test]
624    fn module_builder_completion_policy_override() {
625        let module1 = ModuleBuilder::new().manual_completion();
626        let module2 = ModuleBuilder::new()
627            .automatic_completion(Some(5), Some(100), false)
628            .manual_completion();
629
630        assert_eq!(module1.completion_policy, CompletionPolicy::Manual);
631        assert_eq!(module2.completion_policy, CompletionPolicy::Manual);
632    }
633}