headless_lms_server/programs/seed/builder/
module.rs

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