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        register_credits_via_suotar: bool,
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            register_credits_via_suotar,
146        };
147
148        let completion_id = course_module_completions::insert_seed_row(conn, &seed).await?;
149
150        // Mark registration attempt (seed always sets this)
151        course_module_completions::update_completion_registration_attempt_date(
152            conn,
153            completion_id,
154            Utc::now(),
155        )
156        .await
157        .ok();
158
159        if let Some(r) = &self.register {
160            let registrar_id = if let Some(id) = r.registrar_id {
161                id
162            } else if let Some(def) = default_registrar_id {
163                def
164            } else {
165                return Ok(());
166            };
167
168            if let Some(student_number) = &r.real_student_number {
169                course_module_completion_registered_to_study_registries::insert_or_ignore(
170                    conn,
171                    &course_module_completion_registered_to_study_registries::NewCourseModuleCompletionRegisteredToStudyRegistry {
172                        course_id,
173                        course_module_completion_id: completion_id,
174                        course_module_id,
175                        study_registry_registrar_id: registrar_id,
176                        user_id: self.user_id,
177                        real_student_number: student_number.clone(),
178                    },
179                )
180                .await
181                .context("insert registry record")?;
182            }
183        }
184
185        Ok(())
186    }
187}
188
189/// Writes the module's Suotar configuration.
190#[derive(Debug, Clone, Default)]
191pub struct CreditRegistrationSeed {
192    /// Stored as the module's `completion_registration_link_override`.
193    pub enrolment_link: Option<String>,
194    /// Pauses the module, which every phase's claim query skips: without it the workers running in
195    /// the test deployment walk read-only fixtures onwards.
196    pub paused_reason: Option<String>,
197    /// Whether completions seeded here go through Suotar. Without it they are seeded as from before
198    /// the push path, which the pull path registers.
199    pub registers_seeded_completions: bool,
200}
201
202/// Builder for course modules that group chapters with ECTS credits and credit 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 completion_policy: CompletionPolicy,
210    pub completions: Vec<CompletionBuilder>,
211    pub default_registrar_id: Option<Uuid>,
212    pub uh_course_code: Option<String>,
213    pub credit_registration: Option<CreditRegistrationSeed>,
214}
215
216impl Default for ModuleBuilder {
217    fn default() -> Self {
218        Self::new()
219    }
220}
221
222impl ModuleBuilder {
223    pub fn new() -> Self {
224        Self {
225            name: None,
226            order: None,
227            ects: None,
228            chapters: vec![],
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 chapter(mut self, c: ChapterBuilder) -> Self {
271        self.chapters.push(c);
272        self
273    }
274    pub fn chapters<I: IntoIterator<Item = ChapterBuilder>>(mut self, it: I) -> Self {
275        self.chapters.extend(it);
276        self
277    }
278    pub fn completion_policy(mut self, policy: CompletionPolicy) -> Self {
279        self.completion_policy = policy;
280        self
281    }
282    pub fn manual_completion(mut self) -> Self {
283        self.completion_policy = CompletionPolicy::Manual;
284        self
285    }
286    pub fn automatic_completion(
287        mut self,
288        exercises_threshold: Option<i32>,
289        points_threshold: Option<i32>,
290        requires_exam: bool,
291    ) -> Self {
292        // We'll set the course_module_id when seeding
293        self.completion_policy = CompletionPolicy::Automatic(AutomaticCompletionRequirements {
294            course_module_id: uuid::Uuid::new_v4(), // Temporary, will be updated during seeding
295            number_of_exercises_attempted_treshold: exercises_threshold,
296            number_of_points_treshold: points_threshold,
297            requires_exam,
298        });
299        self
300    }
301
302    pub(crate) async fn seed(
303        self,
304        conn: &mut PgConnection,
305        cx: &SeedContext,
306        course_id: uuid::Uuid,
307        fallback_order: i32,
308    ) -> Result<CourseModule> {
309        let order = self.order.unwrap_or(fallback_order);
310
311        let module = course_modules::insert(
312            conn,
313            headless_lms_models::PKeyPolicy::Generate,
314            &NewCourseModule::new(course_id, self.name, order)
315                .set_ects_credits(self.ects)
316                .set_completion_policy(self.completion_policy.clone())
317                .set_uh_course_code(self.uh_course_code)
318                .set_completion_registration_link_override(
319                    self.credit_registration
320                        .as_ref()
321                        .and_then(|seed| seed.enrolment_link.clone()),
322                ),
323        )
324        .await
325        .with_context(|| format!("inserting module (order {:?})", order))?;
326
327        // Update completion policy if it's automatic to set the correct module ID
328        if let CompletionPolicy::Automatic(mut requirements) = self.completion_policy {
329            requirements.course_module_id = module.id;
330            let updated_policy = CompletionPolicy::Automatic(requirements);
331            course_modules::update_automatic_completion_status(conn, module.id, &updated_policy)
332                .await
333                .context("updating automatic completion policy")?;
334        }
335
336        if let Some(credit_registration) = &self.credit_registration {
337            course_module_suotar_configurations::ensure_exists(conn, module.id)
338                .await
339                .context("writing the module's credit registration configuration")?;
340            if let Some(reason) = &credit_registration.paused_reason {
341                course_module_suotar_configurations::set_paused(
342                    conn,
343                    module.id,
344                    Some(SuotarPause {
345                        paused_at: Utc::now(),
346                        paused_by_user_id: cx.teacher,
347                        reason: Some(reason),
348                    }),
349                )
350                .await
351                .context("pausing the module's credit registration")?;
352            }
353        }
354
355        let registers_seeded_completions = self
356            .credit_registration
357            .as_ref()
358            .is_some_and(|seed| seed.registers_seeded_completions);
359        for comp in &self.completions {
360            comp.seed(
361                conn,
362                course_id,
363                module.id,
364                self.default_registrar_id,
365                registers_seeded_completions,
366            )
367            .await?;
368        }
369
370        for ch in self.chapters {
371            ch.seed(conn, cx, course_id, module.id).await?;
372        }
373
374        Ok(module)
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use crate::programs::seed::builder::chapter::ChapterBuilder;
381
382    use super::*;
383
384    #[test]
385    fn module_builder_new() {
386        let module = ModuleBuilder::new();
387
388        assert!(module.order.is_none());
389        assert!(module.name.is_none());
390        assert!(module.ects.is_none());
391        assert!(module.chapters.is_empty());
392        assert_eq!(module.completion_policy, CompletionPolicy::Manual);
393    }
394
395    #[test]
396    fn module_builder_order() {
397        let module = ModuleBuilder::new().order(5);
398
399        assert_eq!(module.order, Some(5));
400    }
401
402    #[test]
403    fn module_builder_name() {
404        let module = ModuleBuilder::new().name("Test Module");
405
406        assert_eq!(module.name, Some("Test Module".to_string()));
407    }
408
409    #[test]
410    fn module_builder_name_string_conversion() {
411        let module1 = ModuleBuilder::new().name("String literal");
412        let module2 = ModuleBuilder::new().name(String::from("Owned string"));
413
414        assert_eq!(module1.name, Some("String literal".to_string()));
415        assert_eq!(module2.name, Some("Owned string".to_string()));
416    }
417
418    #[test]
419    fn module_builder_ects() {
420        let module = ModuleBuilder::new().ects(5.0);
421
422        assert_eq!(module.ects, Some(5.0));
423    }
424
425    #[test]
426    fn module_builder_ects_fractional() {
427        let module = ModuleBuilder::new().ects(2.5);
428
429        assert_eq!(module.ects, Some(2.5));
430    }
431
432    #[test]
433    fn module_builder_chapter() {
434        let chapter = ChapterBuilder::new(1, "Test Chapter");
435        let module = ModuleBuilder::new().chapter(chapter);
436
437        assert_eq!(module.chapters.len(), 1);
438        assert_eq!(module.chapters[0].number, 1);
439        assert_eq!(module.chapters[0].name, "Test Chapter");
440    }
441
442    #[test]
443    fn module_builder_multiple_chapters() {
444        let chapter1 = ChapterBuilder::new(1, "Chapter 1");
445        let chapter2 = ChapterBuilder::new(2, "Chapter 2");
446        let module = ModuleBuilder::new().chapter(chapter1).chapter(chapter2);
447
448        assert_eq!(module.chapters.len(), 2);
449        assert_eq!(module.chapters[0].number, 1);
450        assert_eq!(module.chapters[0].name, "Chapter 1");
451        assert_eq!(module.chapters[1].number, 2);
452        assert_eq!(module.chapters[1].name, "Chapter 2");
453    }
454
455    #[test]
456    fn module_builder_fluent_interface() {
457        let chapter1 = ChapterBuilder::new(1, "Chapter 1");
458        let chapter2 = ChapterBuilder::new(2, "Chapter 2");
459
460        let module = ModuleBuilder::new()
461            .order(1)
462            .name("Advanced Module")
463            .ects(3.5)
464            .chapter(chapter1)
465            .chapter(chapter2);
466
467        assert_eq!(module.order, Some(1));
468        assert_eq!(module.name, Some("Advanced Module".to_string()));
469        assert_eq!(module.ects, Some(3.5));
470        assert_eq!(module.chapters.len(), 2);
471    }
472
473    #[test]
474    fn module_builder_method_chaining_order() {
475        let chapter1 = ChapterBuilder::new(1, "Test Chapter");
476        let chapter2 = ChapterBuilder::new(1, "Test Chapter");
477
478        let module1 = ModuleBuilder::new()
479            .order(1)
480            .name("Module 1")
481            .ects(2.0)
482            .chapter(chapter1);
483
484        let module2 = ModuleBuilder::new()
485            .chapter(chapter2)
486            .ects(2.0)
487            .name("Module 1")
488            .order(1);
489
490        assert_eq!(module1.name, module2.name);
491        assert_eq!(module1.ects, module2.ects);
492        assert_eq!(module1.chapters.len(), module2.chapters.len());
493        assert_eq!(module1.order, module2.order);
494    }
495
496    #[test]
497    fn module_builder_default_values() {
498        let module = ModuleBuilder::new();
499
500        assert!(module.order.is_none());
501        assert!(module.name.is_none());
502        assert!(module.ects.is_none());
503        assert!(module.chapters.is_empty());
504    }
505
506    #[test]
507    fn module_builder_order_preservation() {
508        let module = ModuleBuilder::new().order(999);
509
510        assert_eq!(module.order, Some(999));
511    }
512
513    #[test]
514    fn module_builder_manual_completion() {
515        let module = ModuleBuilder::new().manual_completion();
516
517        assert_eq!(module.completion_policy, CompletionPolicy::Manual);
518    }
519
520    #[test]
521    fn module_builder_automatic_completion() {
522        let module = ModuleBuilder::new().automatic_completion(Some(5), Some(100), true);
523
524        match module.completion_policy {
525            CompletionPolicy::Automatic(requirements) => {
526                assert_eq!(requirements.number_of_exercises_attempted_treshold, Some(5));
527                assert_eq!(requirements.number_of_points_treshold, Some(100));
528                assert!(requirements.requires_exam);
529            }
530            CompletionPolicy::Manual => panic!("Expected automatic completion policy"),
531        }
532    }
533
534    #[test]
535    fn module_builder_automatic_completion_no_thresholds() {
536        let module = ModuleBuilder::new().automatic_completion(None, None, false);
537
538        match module.completion_policy {
539            CompletionPolicy::Automatic(requirements) => {
540                assert_eq!(requirements.number_of_exercises_attempted_treshold, None);
541                assert_eq!(requirements.number_of_points_treshold, None);
542                assert!(!requirements.requires_exam);
543            }
544            CompletionPolicy::Manual => panic!("Expected automatic completion policy"),
545        }
546    }
547
548    #[test]
549    fn module_builder_completion_policy_fluent_interface() {
550        let chapter1 = ChapterBuilder::new(1, "Chapter 1");
551        let chapter2 = ChapterBuilder::new(2, "Chapter 2");
552
553        let module = ModuleBuilder::new()
554            .order(1)
555            .name("Advanced Module")
556            .ects(3.5)
557            .automatic_completion(Some(10), Some(200), true)
558            .chapter(chapter1)
559            .chapter(chapter2);
560
561        assert_eq!(module.order, Some(1));
562        assert_eq!(module.name, Some("Advanced Module".to_string()));
563        assert_eq!(module.ects, Some(3.5));
564        assert_eq!(module.chapters.len(), 2);
565
566        match module.completion_policy {
567            CompletionPolicy::Automatic(requirements) => {
568                assert_eq!(
569                    requirements.number_of_exercises_attempted_treshold,
570                    Some(10)
571                );
572                assert_eq!(requirements.number_of_points_treshold, Some(200));
573                assert!(requirements.requires_exam);
574            }
575            CompletionPolicy::Manual => panic!("Expected automatic completion policy"),
576        }
577    }
578
579    #[test]
580    fn module_builder_completion_policy_override() {
581        let module1 = ModuleBuilder::new().manual_completion();
582        let module2 = ModuleBuilder::new()
583            .automatic_completion(Some(5), Some(100), false)
584            .manual_completion();
585
586        assert_eq!(module1.completion_policy, CompletionPolicy::Manual);
587        assert_eq!(module2.completion_policy, CompletionPolicy::Manual);
588    }
589}