headless_lms_server/programs/seed/builder/
course.rs

1use anyhow::{Context, Result};
2use sqlx::Connection;
3use uuid::Uuid;
4
5use headless_lms_models::{
6    PKeyPolicy, certificate_configuration_to_requirements,
7    certificate_configurations::{self, DatabaseCertificateConfiguration},
8    course_instances::{self, CourseInstance, NewCourseInstance},
9    course_language_groups,
10    course_modules::CourseModule,
11    courses::{self, Course, NewCourse},
12    file_uploads, glossary,
13    pages::{self, NewCoursePage, NewPage},
14    roles::{self, RoleDomain, UserRole},
15};
16use headless_lms_utils::document_schema_processor::GutenbergBlock;
17
18use crate::programs::seed::{
19    builder::{context::SeedContext, module::ModuleBuilder},
20    seed_helpers::get_seed_spec_fetcher,
21};
22
23/// Additional course instance configuration
24#[derive(Debug, Clone)]
25pub struct CourseInstanceConfig {
26    pub name: Option<String>,
27    pub description: Option<String>,
28    pub support_email: Option<String>,
29    pub teacher_in_charge_name: String,
30    pub teacher_in_charge_email: String,
31    pub opening_time: Option<chrono::DateTime<chrono::Utc>>,
32    pub closing_time: Option<chrono::DateTime<chrono::Utc>>,
33    pub instance_id: Option<Uuid>,
34}
35
36/// Generic page configuration for top-level course pages
37#[derive(Debug, Clone)]
38pub struct PageConfig {
39    pub url: String,
40    pub title: String,
41    pub page_number: i32,
42    pub is_hidden: bool,
43    pub content: Option<Vec<headless_lms_utils::document_schema_processor::GutenbergBlock>>,
44}
45
46/// Glossary entry
47#[derive(Debug, Clone)]
48pub struct GlossaryEntry {
49    pub acronym: String,
50    pub definition: String,
51}
52
53/// Certificate configuration
54#[derive(Debug, Clone)]
55pub struct CertificateConfig {
56    pub background_svg_path: String,
57    pub certificate_id: Option<Uuid>,
58}
59
60/// Builder for courses with modules, chapters, pages, and exercises.
61#[derive(Debug, Clone)]
62pub struct CourseBuilder {
63    pub name: String,
64    pub slug: String,
65    pub language_code: String,
66    pub can_add_chatbot: bool,
67    pub description: String,
68    pub default_instance_name: &'static [u8],
69    pub modules: Vec<ModuleBuilder>,
70    pub extra_roles: Vec<(Uuid, UserRole)>,
71    pub course_id: Option<Uuid>,
72    pub instances: Vec<CourseInstanceConfig>,
73    pub pages: Vec<PageConfig>,
74    pub glossary_entries: Vec<GlossaryEntry>,
75    pub certificate_config: Option<CertificateConfig>,
76    pub front_page_content: Option<Vec<GutenbergBlock>>,
77}
78
79impl CourseBuilder {
80    pub fn new(name: impl Into<String>, slug: impl Into<String>) -> Self {
81        Self {
82            name: name.into(),
83            slug: slug.into(),
84            language_code: "en-US".into(),
85            can_add_chatbot: false,
86            description: "Sample course.".into(),
87            default_instance_name: b"default-instance",
88            modules: vec![],
89            extra_roles: vec![],
90            course_id: None,
91            instances: vec![],
92            pages: vec![],
93            glossary_entries: vec![],
94            certificate_config: None,
95            front_page_content: None,
96        }
97    }
98
99    pub fn chatbot(mut self, v: bool) -> Self {
100        self.can_add_chatbot = v;
101        self
102    }
103    pub fn desc(mut self, d: impl Into<String>) -> Self {
104        self.description = d.into();
105        self
106    }
107    pub fn language(mut self, lc: impl Into<String>) -> Self {
108        self.language_code = lc.into();
109        self
110    }
111    pub fn module(mut self, m: ModuleBuilder) -> Self {
112        self.modules.push(m);
113        self
114    }
115    pub fn modules<I: IntoIterator<Item = ModuleBuilder>>(mut self, it: I) -> Self {
116        self.modules.extend(it);
117        self
118    }
119    pub fn role(mut self, user_id: Uuid, role: UserRole) -> Self {
120        self.extra_roles.push((user_id, role));
121        self
122    }
123    pub fn course_id(mut self, id: Uuid) -> Self {
124        self.course_id = Some(id);
125        self
126    }
127
128    pub fn instance(mut self, config: CourseInstanceConfig) -> Self {
129        self.instances.push(config);
130        self
131    }
132
133    /// Add a top-level page to the course
134    pub fn top_level_page(
135        mut self,
136        url: impl Into<String>,
137        title: impl Into<String>,
138        page_number: i32,
139        is_hidden: bool,
140        content: Option<Vec<headless_lms_utils::document_schema_processor::GutenbergBlock>>,
141    ) -> Self {
142        self.pages.push(PageConfig {
143            url: url.into(),
144            title: title.into(),
145            page_number,
146            is_hidden,
147            content,
148        });
149        self
150    }
151
152    pub fn glossary_entry(
153        mut self,
154        acronym: impl Into<String>,
155        definition: impl Into<String>,
156    ) -> Self {
157        self.glossary_entries.push(GlossaryEntry {
158            acronym: acronym.into(),
159            definition: definition.into(),
160        });
161        self
162    }
163
164    pub fn certificate_config(
165        mut self,
166        background_svg_path: impl Into<String>,
167        certificate_id: Option<Uuid>,
168    ) -> Self {
169        self.certificate_config = Some(CertificateConfig {
170            background_svg_path: background_svg_path.into(),
171            certificate_id,
172        });
173        self
174    }
175
176    /// Set custom content for the course front page. If not set, default content will be used.
177    pub fn front_page_content(mut self, content: Vec<GutenbergBlock>) -> Self {
178        self.front_page_content = Some(content);
179        self
180    }
181
182    /// Seeds the course and all nested content. Returns `(course, default_instance, last_module)`.
183    pub async fn seed(
184        self,
185        cx: &mut SeedContext<'_>,
186    ) -> Result<(Course, CourseInstance, CourseModule)> {
187        let course_id = self.course_id.unwrap_or(cx.base_course_ns);
188
189        let new_course = NewCourse {
190            name: self.name.clone(),
191            organization_id: cx.org,
192            slug: self.slug.clone(),
193            language_code: self.language_code.clone(),
194            teacher_in_charge_name: "admin".into(),
195            teacher_in_charge_email: "admin@example.com".into(),
196            description: self.description.clone(),
197            is_draft: false,
198            is_test_mode: false,
199            is_unlisted: false,
200            copy_user_permissions: false,
201            is_joinable_by_code_only: false,
202            join_code: None,
203            ask_marketing_consent: false,
204            flagged_answers_threshold: Some(3),
205            can_add_chatbot: self.can_add_chatbot,
206        };
207
208        // Create course manually without default module
209        let mut tx = cx.conn.begin().await.context("starting transaction")?;
210
211        let course_language_group_id =
212            course_language_groups::insert(&mut tx, headless_lms_models::PKeyPolicy::Generate)
213                .await?;
214
215        let course_id = courses::insert(
216            &mut tx,
217            headless_lms_models::PKeyPolicy::Fixed(course_id),
218            course_language_group_id,
219            &new_course,
220        )
221        .await
222        .context("inserting course")?;
223
224        let course = courses::get_course(&mut tx, course_id)
225            .await
226            .context("getting course")?;
227
228        tx.commit().await.context("committing transaction")?;
229
230        let mut last_module = None;
231
232        for (i, m) in self.modules.into_iter().enumerate() {
233            last_module = Some(m.seed(cx, course.id, i as i32).await?);
234        }
235
236        for (user_id, role) in self.extra_roles {
237            roles::insert(cx.conn, user_id, role, RoleDomain::Course(course.id))
238                .await
239                .context("inserting course role")?;
240        }
241
242        // Create course instances
243        let mut default_instance = None;
244        for instance_config in self.instances {
245            let instance_id = instance_config.instance_id.unwrap_or_else(Uuid::new_v4);
246            let instance = course_instances::insert(
247                cx.conn,
248                PKeyPolicy::Fixed(instance_id),
249                NewCourseInstance {
250                    course_id: course.id,
251                    name: instance_config.name.as_deref(),
252                    description: instance_config.description.as_deref(),
253                    support_email: instance_config.support_email.as_deref(),
254                    teacher_in_charge_name: &instance_config.teacher_in_charge_name,
255                    teacher_in_charge_email: &instance_config.teacher_in_charge_email,
256                    opening_time: instance_config.opening_time,
257                    closing_time: instance_config.closing_time,
258                },
259            )
260            .await
261            .context("inserting course instance")?;
262
263            // Use the first instance as the default instance for return value
264            if default_instance.is_none() {
265                default_instance = Some(instance);
266            }
267        }
268
269        let default_instance =
270            default_instance.expect("At least one course instance must be provided");
271
272        // Create mandatory front page for the course
273        let default_front_page_content = vec![
274            GutenbergBlock::landing_page_hero_section("Welcome to...", "Subheading"),
275            GutenbergBlock::landing_page_copy_text(
276                "About this course",
277                "This course teaches you xxx.",
278            ),
279            GutenbergBlock::course_objective_section(),
280            GutenbergBlock::empty_block_from_name("moocfi/course-chapter-grid".to_string()),
281            GutenbergBlock::empty_block_from_name("moocfi/top-level-pages".to_string()),
282            GutenbergBlock::empty_block_from_name("moocfi/congratulations".to_string()),
283            GutenbergBlock::empty_block_from_name("moocfi/course-progress".to_string()),
284        ];
285
286        let front_page_blocks = self
287            .front_page_content
288            .unwrap_or(default_front_page_content);
289        let course_front_page_content =
290            serde_json::to_value(front_page_blocks).context("creating front page content")?;
291
292        let course_front_page = NewPage {
293            chapter_id: None,
294            content: course_front_page_content,
295            course_id: Some(course.id),
296            exam_id: None,
297            front_page_of_chapter_id: None,
298            title: course.name.clone(),
299            url_path: String::from("/"),
300            exercises: vec![],
301            exercise_slides: vec![],
302            exercise_tasks: vec![],
303            content_search_language: None,
304        };
305
306        pages::insert_page(
307            cx.conn,
308            course_front_page,
309            cx.teacher,
310            get_seed_spec_fetcher(),
311            crate::domain::models_requests::fetch_service_info,
312        )
313        .await
314        .context("inserting course front page")?;
315
316        // Create top-level pages
317        for page_config in self.pages {
318            let mut course_page = NewCoursePage::new(
319                course.id,
320                page_config.page_number,
321                &page_config.url,
322                &page_config.title,
323            );
324
325            if page_config.is_hidden {
326                course_page = course_page.set_hidden(true);
327            }
328
329            if let Some(content) = page_config.content {
330                course_page = course_page.set_content(content);
331            }
332
333            pages::insert_course_page(cx.conn, &course_page, cx.teacher)
334                .await
335                .context("inserting course page")?;
336        }
337
338        // Add glossary entries
339        for glossary_entry in self.glossary_entries {
340            glossary::insert(
341                cx.conn,
342                &glossary_entry.acronym,
343                &glossary_entry.definition,
344                course.id,
345            )
346            .await
347            .context("inserting glossary entry")?;
348        }
349
350        // Create certificate configuration
351        if let Some(cert_config) = self.certificate_config {
352            let background_svg_file_upload_id = file_uploads::insert(
353                cx.conn,
354                &format!(
355                    "background-{}.svg",
356                    last_module
357                        .as_ref()
358                        .expect("At least one module must be provided")
359                        .id
360                ),
361                &cert_config.background_svg_path,
362                "image/svg+xml",
363                None,
364            )
365            .await
366            .context("inserting certificate background file")?;
367
368            let certificate_id = cert_config
369                .certificate_id
370                .unwrap_or_else(|| cx.v5(b"886d3e22-5007-4371-94d7-e0ad93a2391c"));
371            let configuration = DatabaseCertificateConfiguration {
372                id: certificate_id,
373                certificate_owner_name_y_pos: None,
374                certificate_owner_name_x_pos: None,
375                certificate_owner_name_font_size: None,
376                certificate_owner_name_text_color: None,
377                certificate_owner_name_text_anchor: None,
378                certificate_validate_url_y_pos: None,
379                certificate_validate_url_x_pos: None,
380                certificate_validate_url_font_size: None,
381                certificate_validate_url_text_color: None,
382                certificate_validate_url_text_anchor: None,
383                certificate_date_y_pos: None,
384                certificate_date_x_pos: None,
385                certificate_date_font_size: None,
386                certificate_date_text_color: None,
387                certificate_date_text_anchor: None,
388                certificate_locale: None,
389                paper_size: None,
390                background_svg_path: cert_config.background_svg_path,
391                background_svg_file_upload_id,
392                overlay_svg_path: None,
393                overlay_svg_file_upload_id: None,
394                render_certificate_grade: false,
395                certificate_grade_y_pos: None,
396                certificate_grade_x_pos: None,
397                certificate_grade_font_size: None,
398                certificate_grade_text_color: None,
399                certificate_grade_text_anchor: None,
400            };
401            let database_configuration =
402                certificate_configurations::insert(cx.conn, &configuration)
403                    .await
404                    .context("inserting certificate configuration")?;
405            certificate_configuration_to_requirements::insert(
406                cx.conn,
407                database_configuration.id,
408                Some(
409                    last_module
410                        .as_ref()
411                        .expect("At least one module must be provided")
412                        .id,
413                ),
414            )
415            .await
416            .context("linking certificate configuration to requirements")?;
417        }
418
419        Ok((
420            course,
421            default_instance,
422            last_module.expect("At least one module must be provided"),
423        ))
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use crate::programs::seed::builder::module::ModuleBuilder;
430
431    use super::*;
432    use headless_lms_models::roles::UserRole;
433
434    #[test]
435    fn course_builder_new() {
436        let course = CourseBuilder::new("Test Course", "test-course");
437
438        assert_eq!(course.name, "Test Course");
439        assert_eq!(course.slug, "test-course");
440        assert_eq!(course.language_code, "en-US");
441        assert!(!course.can_add_chatbot);
442        assert_eq!(course.description, "Sample course.");
443        assert_eq!(course.default_instance_name, b"default-instance");
444        assert!(course.modules.is_empty());
445        assert!(course.extra_roles.is_empty());
446        assert!(course.course_id.is_none());
447    }
448
449    #[test]
450    fn course_builder_string_conversion() {
451        let course1 = CourseBuilder::new("String literal", "string-slug");
452        let course2 = CourseBuilder::new(String::from("Owned string"), String::from("owned-slug"));
453
454        assert_eq!(course1.name, "String literal");
455        assert_eq!(course1.slug, "string-slug");
456        assert_eq!(course2.name, "Owned string");
457        assert_eq!(course2.slug, "owned-slug");
458    }
459
460    #[test]
461    fn course_builder_chatbot() {
462        let course = CourseBuilder::new("Test Course", "test-course").chatbot(true);
463        assert!(course.can_add_chatbot);
464
465        let course_disabled = CourseBuilder::new("Test Course", "test-course").chatbot(false);
466        assert!(!course_disabled.can_add_chatbot);
467    }
468
469    #[test]
470    fn course_builder_desc() {
471        let course =
472            CourseBuilder::new("Test Course", "test-course").desc("A comprehensive test course");
473
474        assert_eq!(course.description, "A comprehensive test course");
475
476        let desc_string = String::from("Another description");
477        let course2 = CourseBuilder::new("Test Course", "test-course").desc(desc_string);
478        assert_eq!(course2.description, "Another description");
479    }
480
481    #[test]
482    fn course_builder_language() {
483        let course = CourseBuilder::new("Test Course", "test-course").language("fi-FI");
484
485        assert_eq!(course.language_code, "fi-FI");
486
487        let lang_string = String::from("sv-SE");
488        let course2 = CourseBuilder::new("Test Course", "test-course").language(lang_string);
489        assert_eq!(course2.language_code, "sv-SE");
490    }
491
492    #[test]
493    fn course_builder_module() {
494        let module = ModuleBuilder::new().order(0);
495        let course = CourseBuilder::new("Test Course", "test-course").module(module);
496
497        assert_eq!(course.modules.len(), 1);
498        assert_eq!(course.modules[0].order, Some(0));
499    }
500
501    #[test]
502    fn course_builder_multiple_modules() {
503        let module1 = ModuleBuilder::new().order(0);
504        let module2 = ModuleBuilder::new().order(1);
505        let course = CourseBuilder::new("Test Course", "test-course")
506            .module(module1)
507            .module(module2);
508
509        assert_eq!(course.modules.len(), 2);
510        assert_eq!(course.modules[0].order, Some(0));
511        assert_eq!(course.modules[1].order, Some(1));
512    }
513
514    #[test]
515    fn course_builder_role() {
516        let user_id = Uuid::new_v4();
517        let course =
518            CourseBuilder::new("Test Course", "test-course").role(user_id, UserRole::Teacher);
519
520        assert_eq!(course.extra_roles.len(), 1);
521        assert_eq!(course.extra_roles[0].0, user_id);
522        assert_eq!(course.extra_roles[0].1, UserRole::Teacher);
523    }
524
525    #[test]
526    fn course_builder_multiple_roles() {
527        let user1_id = Uuid::new_v4();
528        let user2_id = Uuid::new_v4();
529        let course = CourseBuilder::new("Test Course", "test-course")
530            .role(user1_id, UserRole::Teacher)
531            .role(user2_id, UserRole::MaterialViewer);
532
533        assert_eq!(course.extra_roles.len(), 2);
534        assert_eq!(course.extra_roles[0].0, user1_id);
535        assert_eq!(course.extra_roles[0].1, UserRole::Teacher);
536        assert_eq!(course.extra_roles[1].0, user2_id);
537        assert_eq!(course.extra_roles[1].1, UserRole::MaterialViewer);
538    }
539
540    #[test]
541    fn course_builder_course_id() {
542        let course_id = Uuid::new_v4();
543        let course = CourseBuilder::new("Test Course", "test-course").course_id(course_id);
544
545        assert_eq!(course.course_id, Some(course_id));
546    }
547
548    #[test]
549    fn course_builder_fluent_interface() {
550        let user_id = Uuid::new_v4();
551        let course_id = Uuid::new_v4();
552        let module = ModuleBuilder::new().order(0);
553
554        let course = CourseBuilder::new("Advanced Course", "advanced-course")
555            .desc("A comprehensive advanced course")
556            .language("fi-FI")
557            .chatbot(true)
558            .module(module)
559            .role(user_id, UserRole::Teacher)
560            .course_id(course_id);
561
562        assert_eq!(course.name, "Advanced Course");
563        assert_eq!(course.slug, "advanced-course");
564        assert_eq!(course.description, "A comprehensive advanced course");
565        assert_eq!(course.language_code, "fi-FI");
566        assert!(course.can_add_chatbot);
567        assert_eq!(course.modules.len(), 1);
568        assert_eq!(course.extra_roles.len(), 1);
569        assert_eq!(course.course_id, Some(course_id));
570    }
571
572    #[test]
573    fn course_builder_default_values() {
574        let course = CourseBuilder::new("Minimal Course", "minimal");
575
576        assert_eq!(course.language_code, "en-US");
577        assert!(!course.can_add_chatbot);
578        assert_eq!(course.description, "Sample course.");
579        assert_eq!(course.default_instance_name, b"default-instance");
580        assert!(course.modules.is_empty());
581        assert!(course.extra_roles.is_empty());
582        assert!(course.course_id.is_none());
583    }
584
585    #[test]
586    fn course_builder_empty_strings() {
587        let course = CourseBuilder::new("", "");
588
589        assert_eq!(course.name, "");
590        assert_eq!(course.slug, "");
591        assert_eq!(course.language_code, "en-US");
592        assert_eq!(course.description, "Sample course.");
593    }
594
595    #[test]
596    fn course_builder_method_chaining_order() {
597        let course1 = CourseBuilder::new("Course 1", "course-1")
598            .chatbot(true)
599            .desc("Description 1")
600            .language("fi-FI");
601
602        let course2 = CourseBuilder::new("Course 2", "course-2")
603            .language("fi-FI")
604            .desc("Description 2")
605            .chatbot(true);
606
607        assert_eq!(course1.can_add_chatbot, course2.can_add_chatbot);
608        assert_eq!(course1.language_code, course2.language_code);
609        assert_eq!(course1.description, "Description 1");
610        assert_eq!(course2.description, "Description 2");
611    }
612
613    #[test]
614    fn course_builder_front_page_content() {
615        let custom_content = vec![
616            GutenbergBlock::landing_page_hero_section("Custom Welcome", "Custom Subheading"),
617            GutenbergBlock::course_objective_section(),
618        ];
619
620        let course = CourseBuilder::new("Test Course", "test-course")
621            .front_page_content(custom_content.clone());
622
623        assert_eq!(course.front_page_content, Some(custom_content));
624    }
625
626    #[test]
627    fn course_builder_front_page_content_default() {
628        let course = CourseBuilder::new("Test Course", "test-course");
629
630        assert_eq!(course.front_page_content, None);
631    }
632
633    #[test]
634    fn course_builder_top_level_page() {
635        let course = CourseBuilder::new("Test Course", "test-course")
636            .top_level_page("/welcome", "Welcome Page", 1, false, None)
637            .top_level_page("/hidden", "Hidden Page", 2, true, None);
638
639        assert_eq!(course.pages.len(), 2);
640        assert_eq!(course.pages[0].url, "/welcome");
641        assert_eq!(course.pages[0].title, "Welcome Page");
642        assert_eq!(course.pages[0].page_number, 1);
643        assert!(!course.pages[0].is_hidden);
644        assert_eq!(course.pages[0].content, None);
645
646        assert_eq!(course.pages[1].url, "/hidden");
647        assert_eq!(course.pages[1].title, "Hidden Page");
648        assert_eq!(course.pages[1].page_number, 2);
649        assert!(course.pages[1].is_hidden);
650        assert_eq!(course.pages[1].content, None);
651    }
652
653    #[test]
654    fn course_builder_top_level_page_with_content() {
655        let content = vec![];
656        let course = CourseBuilder::new("Test Course", "test-course").top_level_page(
657            "/content",
658            "Content Page",
659            1,
660            false,
661            Some(content.clone()),
662        );
663
664        assert_eq!(course.pages.len(), 1);
665        assert_eq!(course.pages[0].url, "/content");
666        assert_eq!(course.pages[0].title, "Content Page");
667        assert_eq!(course.pages[0].page_number, 1);
668        assert!(!course.pages[0].is_hidden);
669        assert_eq!(course.pages[0].content, Some(content));
670    }
671
672    #[test]
673    fn course_builder_multiple_top_level_pages() {
674        let course = CourseBuilder::new("Test Course", "test-course")
675            .top_level_page("/welcome", "Welcome", 1, false, None)
676            .top_level_page("/about", "About", 2, false, None)
677            .top_level_page("/secret", "Secret", 3, true, None);
678
679        assert_eq!(course.pages.len(), 3);
680        assert_eq!(course.pages[0].url, "/welcome");
681        assert_eq!(course.pages[1].url, "/about");
682        assert_eq!(course.pages[2].url, "/secret");
683        assert!(course.pages[2].is_hidden);
684    }
685}