headless_lms_server/programs/seed/builder/
chapter.rs1use anyhow::{Context, Result, bail};
2use chrono::{DateTime, Utc};
3use uuid::Uuid;
4
5use headless_lms_models::{
6 PKeyPolicy,
7 chapters::{self, NewChapter},
8 library,
9};
10use headless_lms_utils::document_schema_processor::GutenbergBlock;
11
12use crate::programs::seed::{
13 builder::{context::SeedContext, page::PageBuilder},
14 seed_helpers::get_seed_spec_fetcher,
15};
16
17#[derive(Debug, Clone)]
19pub struct ChapterBuilder {
20 pub number: i32,
21 pub name: String,
22 pub opens_at: Option<DateTime<Utc>>,
23 pub deadline: Option<DateTime<Utc>>,
24 pub pages: Vec<PageBuilder>,
25 pub chapter_id: Option<Uuid>,
26 pub front_page_id: Option<Uuid>,
27 pub front_page_content: Option<Vec<GutenbergBlock>>,
28}
29
30impl ChapterBuilder {
31 pub fn new(number: i32, name: impl Into<String>) -> Self {
32 Self {
33 number,
34 name: name.into(),
35 opens_at: None,
36 deadline: None,
37 pages: vec![],
38 chapter_id: None,
39 front_page_id: None,
40 front_page_content: None,
41 }
42 }
43 pub fn opens(mut self, t: DateTime<Utc>) -> Self {
44 self.opens_at = Some(t);
45 self
46 }
47 pub fn deadline(mut self, t: DateTime<Utc>) -> Self {
48 self.deadline = Some(t);
49 self
50 }
51 pub fn chapter_id(mut self, ch_id: Uuid) -> Self {
52 self.chapter_id = Some(ch_id);
53 self
54 }
55 pub fn front_page_id(mut self, front_id: Uuid) -> Self {
56 self.front_page_id = Some(front_id);
57 self
58 }
59 pub fn fixed_ids(mut self, chapter_id: Uuid, front_page_id: Uuid) -> Self {
60 self.chapter_id = Some(chapter_id);
61 self.front_page_id = Some(front_page_id);
62 self
63 }
64 pub fn page(mut self, p: PageBuilder) -> Self {
65 self.pages.push(p);
66 self
67 }
68 pub fn pages<I: IntoIterator<Item = PageBuilder>>(mut self, it: I) -> Self {
69 self.pages.extend(it);
70 self
71 }
72
73 pub fn front_page_content(mut self, content: Vec<GutenbergBlock>) -> Self {
75 self.front_page_content = Some(content);
76 self
77 }
78
79 pub(crate) async fn seed(
80 self,
81 cx: &mut SeedContext<'_>,
82 course_id: Uuid,
83 module_id: Uuid,
84 ) -> Result<()> {
85 if (self.chapter_id.is_some()) ^ (self.front_page_id.is_some()) {
86 bail!("ChapterBuilder: chapter_id and front_page_id must both be set or neither.");
87 }
88
89 let new_chapter = NewChapter {
90 chapter_number: self.number,
91 course_id,
92 front_page_id: self.front_page_id,
93 name: self.name,
94 color: None,
95 opens_at: self.opens_at,
96 deadline: self.deadline,
97 course_module_id: Some(module_id),
98 };
99
100 let spec = get_seed_spec_fetcher();
101
102 let (chapter, _front) = match (self.chapter_id, self.front_page_id) {
103 (Some(ch_id), Some(fp_id)) => {
104 library::content_management::create_new_chapter_with_content(
105 cx.conn,
106 PKeyPolicy::Fixed((ch_id, fp_id)),
107 &new_chapter,
108 cx.teacher,
109 spec,
110 crate::domain::models_requests::fetch_service_info,
111 self.front_page_content,
112 )
113 .await
114 .context("creating chapter with fixed IDs")?
115 }
116 _ => library::content_management::create_new_chapter_with_content(
117 cx.conn,
118 PKeyPolicy::Generate,
119 &new_chapter,
120 cx.teacher,
121 spec,
122 crate::domain::models_requests::fetch_service_info,
123 self.front_page_content,
124 )
125 .await
126 .context("creating chapter with generated IDs")?,
127 };
128
129 if let Some(opens_at) = self.opens_at {
130 chapters::set_opens_at(cx.conn, chapter.id, opens_at)
131 .await
132 .context("setting chapter opens_at")?;
133 }
134
135 for p in self.pages {
136 p.seed(cx, course_id, chapter.id).await?;
137 }
138 Ok(())
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use chrono::TimeZone;
146
147 #[test]
148 fn chapter_builder_new() {
149 let chapter = ChapterBuilder::new(1, "Test Chapter");
150
151 assert_eq!(chapter.number, 1);
152 assert_eq!(chapter.name, "Test Chapter");
153 assert!(chapter.opens_at.is_none());
154 assert!(chapter.deadline.is_none());
155 assert!(chapter.pages.is_empty());
156 assert!(chapter.chapter_id.is_none());
157 assert!(chapter.front_page_id.is_none());
158 }
159
160 #[test]
161 fn chapter_builder_opens() {
162 let now = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
163 let chapter = ChapterBuilder::new(1, "Test Chapter").opens(now);
164
165 assert_eq!(chapter.opens_at, Some(now));
166 }
167
168 #[test]
169 fn chapter_builder_deadline() {
170 let deadline = Utc.with_ymd_and_hms(2024, 12, 31, 23, 59, 59).unwrap();
171 let chapter = ChapterBuilder::new(1, "Test Chapter").deadline(deadline);
172
173 assert_eq!(chapter.deadline, Some(deadline));
174 }
175
176 #[test]
177 fn chapter_builder_chapter_id() {
178 let chapter_id = Uuid::new_v4();
179 let chapter = ChapterBuilder::new(1, "Test Chapter").chapter_id(chapter_id);
180
181 assert_eq!(chapter.chapter_id, Some(chapter_id));
182 assert!(chapter.front_page_id.is_none());
183 }
184
185 #[test]
186 fn chapter_builder_front_page_id() {
187 let front_page_id = Uuid::new_v4();
188 let chapter = ChapterBuilder::new(1, "Test Chapter").front_page_id(front_page_id);
189
190 assert_eq!(chapter.front_page_id, Some(front_page_id));
191 assert!(chapter.chapter_id.is_none());
192 }
193
194 #[test]
195 fn chapter_builder_page() {
196 let page = PageBuilder::new("/test", "Test Page");
197 let chapter = ChapterBuilder::new(1, "Test Chapter").page(page);
198
199 assert_eq!(chapter.pages.len(), 1);
200 assert_eq!(chapter.pages[0].url, "/test");
201 assert_eq!(chapter.pages[0].title, "Test Page");
202 }
203
204 #[test]
205 fn chapter_builder_multiple_pages() {
206 let page1 = PageBuilder::new("/page1", "Page 1");
207 let page2 = PageBuilder::new("/page2", "Page 2");
208 let chapter = ChapterBuilder::new(1, "Test Chapter")
209 .page(page1)
210 .page(page2);
211
212 assert_eq!(chapter.pages.len(), 2);
213 assert_eq!(chapter.pages[0].url, "/page1");
214 assert_eq!(chapter.pages[1].url, "/page2");
215 }
216
217 #[test]
218 fn chapter_builder_fluent_interface() {
219 let now = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
220 let deadline = Utc.with_ymd_and_hms(2024, 12, 31, 23, 59, 59).unwrap();
221 let chapter_id = Uuid::new_v4();
222 let front_page_id = Uuid::new_v4();
223
224 let page = PageBuilder::new("/intro", "Introduction");
225
226 let chapter = ChapterBuilder::new(1, "Advanced Chapter")
227 .opens(now)
228 .deadline(deadline)
229 .chapter_id(chapter_id)
230 .front_page_id(front_page_id)
231 .page(page);
232
233 assert_eq!(chapter.number, 1);
234 assert_eq!(chapter.name, "Advanced Chapter");
235 assert_eq!(chapter.opens_at, Some(now));
236 assert_eq!(chapter.deadline, Some(deadline));
237 assert_eq!(chapter.chapter_id, Some(chapter_id));
238 assert_eq!(chapter.front_page_id, Some(front_page_id));
239 assert_eq!(chapter.pages.len(), 1);
240 assert_eq!(chapter.pages[0].url, "/intro");
241 assert_eq!(chapter.pages[0].title, "Introduction");
242 }
243
244 #[test]
245 fn chapter_builder_string_conversion() {
246 let chapter1 = ChapterBuilder::new(1, "String literal");
247 let chapter2 = ChapterBuilder::new(2, String::from("Owned string"));
248
249 assert_eq!(chapter1.name, "String literal");
250 assert_eq!(chapter2.name, "Owned string");
251 }
252
253 #[test]
254 fn chapter_builder_front_page_content() {
255 let custom_content = vec![
256 GutenbergBlock::hero_section("Custom Chapter", "Custom description"),
257 GutenbergBlock::empty_block_from_name("moocfi/pages-in-chapter".to_string()),
258 ];
259
260 let chapter =
261 ChapterBuilder::new(1, "Test Chapter").front_page_content(custom_content.clone());
262
263 assert_eq!(chapter.front_page_content, Some(custom_content));
264 }
265
266 #[test]
267 fn chapter_builder_front_page_content_default() {
268 let chapter = ChapterBuilder::new(1, "Test Chapter");
269
270 assert_eq!(chapter.front_page_content, None);
271 }
272}