headless_lms_server/programs/seed/builder/
chapter.rs

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