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#[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#[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#[derive(Debug, Clone)]
48pub struct GlossaryEntry {
49 pub acronym: String,
50 pub definition: String,
51}
52
53#[derive(Debug, Clone)]
55pub struct CertificateConfig {
56 pub background_svg_path: String,
57 pub certificate_id: Option<Uuid>,
58}
59
60#[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 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 pub fn front_page_content(mut self, content: Vec<GutenbergBlock>) -> Self {
178 self.front_page_content = Some(content);
179 self
180 }
181
182 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 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 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 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 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 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 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 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}