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#[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#[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#[derive(Debug, Clone)]
50pub struct GlossaryEntry {
51 pub acronym: String,
52 pub definition: String,
53}
54
55#[derive(Debug, Clone)]
57pub struct CertificateConfig {
58 pub background_svg_path: String,
59 pub certificate_id: Option<Uuid>,
60}
61
62#[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 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 pub fn front_page_content(mut self, content: Vec<GutenbergBlock>) -> Self {
191 self.front_page_content = Some(content);
192 self
193 }
194
195 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 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 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 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 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 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 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 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}