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