headless_lms_server/programs/seed/builder/
course.rs

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