headless_lms_server/programs/seed/builder/
chapter.rs

1use 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/// Builder for course chapters with pages, opening times, and deadlines.
18#[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    /// Set custom content for the chapter front page. If not set, default content will be used.
74    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}