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