headless_lms_models/
chapters.rs

1use std::path::PathBuf;
2
3use crate::{
4    course_modules,
5    pages::{PageMetadata, PageWithExercises},
6    prelude::*,
7};
8use headless_lms_utils::{
9    ApplicationConfiguration, file_store::FileStore,
10    numbers::option_f32_to_f32_two_decimals_with_none_as_zero,
11};
12
13#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
14#[cfg_attr(feature = "ts_rs", derive(TS))]
15pub struct DatabaseChapter {
16    pub id: Uuid,
17    pub created_at: DateTime<Utc>,
18    pub updated_at: DateTime<Utc>,
19    pub name: String,
20    pub color: Option<String>,
21    pub course_id: Uuid,
22    pub deleted_at: Option<DateTime<Utc>>,
23    pub chapter_image_path: Option<String>,
24    pub chapter_number: i32,
25    pub front_page_id: Option<Uuid>,
26    pub opens_at: Option<DateTime<Utc>>,
27    pub deadline: Option<DateTime<Utc>>,
28    pub copied_from: Option<Uuid>,
29    pub course_module_id: Uuid,
30}
31
32impl DatabaseChapter {
33    /// True if the chapter is currently open or was open and is now closed.
34    pub fn has_opened(&self) -> bool {
35        self.opens_at
36            .map(|opens_at| opens_at < Utc::now())
37            .unwrap_or(true)
38    }
39}
40
41#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
42#[cfg_attr(feature = "ts_rs", derive(TS))]
43pub struct Chapter {
44    pub id: Uuid,
45    pub created_at: DateTime<Utc>,
46    pub updated_at: DateTime<Utc>,
47    pub name: String,
48    pub color: Option<String>,
49    pub course_id: Uuid,
50    pub deleted_at: Option<DateTime<Utc>>,
51    pub chapter_image_url: Option<String>,
52    pub chapter_number: i32,
53    pub front_page_id: Option<Uuid>,
54    pub opens_at: Option<DateTime<Utc>>,
55    pub deadline: Option<DateTime<Utc>>,
56    pub copied_from: Option<Uuid>,
57    pub course_module_id: Uuid,
58}
59
60impl Chapter {
61    pub fn from_database_chapter(
62        chapter: &DatabaseChapter,
63        file_store: &dyn FileStore,
64        app_conf: &ApplicationConfiguration,
65    ) -> Self {
66        let chapter_image_url = chapter.chapter_image_path.as_ref().map(|image| {
67            let path = PathBuf::from(image);
68            file_store.get_download_url(path.as_path(), app_conf)
69        });
70        Self {
71            id: chapter.id,
72            created_at: chapter.created_at,
73            updated_at: chapter.updated_at,
74            name: chapter.name.clone(),
75            color: chapter.color.clone(),
76            course_id: chapter.course_id,
77            deleted_at: chapter.deleted_at,
78            chapter_image_url,
79            chapter_number: chapter.chapter_number,
80            front_page_id: chapter.front_page_id,
81            opens_at: chapter.opens_at,
82            copied_from: chapter.copied_from,
83            deadline: chapter.deadline,
84            course_module_id: chapter.course_module_id,
85        }
86    }
87}
88
89#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
90#[cfg_attr(feature = "ts_rs", derive(TS))]
91#[serde(rename_all = "snake_case")]
92pub enum ChapterStatus {
93    Open,
94    Closed,
95}
96
97impl Default for ChapterStatus {
98    fn default() -> Self {
99        Self::Closed
100    }
101}
102
103#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
104pub struct ChapterPagesWithExercises {
105    pub id: Uuid,
106    pub created_at: DateTime<Utc>,
107    pub updated_at: DateTime<Utc>,
108    pub name: String,
109    pub course_id: Uuid,
110    pub deleted_at: Option<DateTime<Utc>>,
111    pub chapter_number: i32,
112    pub pages: Vec<PageWithExercises>,
113}
114
115// Represents the subset of page fields that are required to create a new course.
116#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
117#[cfg_attr(feature = "ts_rs", derive(TS))]
118pub struct NewChapter {
119    pub name: String,
120    pub color: Option<String>,
121    pub course_id: Uuid,
122    pub chapter_number: i32,
123    pub front_page_id: Option<Uuid>,
124    pub opens_at: Option<DateTime<Utc>>,
125    pub deadline: Option<DateTime<Utc>>,
126    /// If undefined when creating a chapter, will use the course default one.
127    /// CHANGE TO NON NULL WHEN FRONTEND MODULE EDITING IMPLEMENTED
128    pub course_module_id: Option<Uuid>,
129}
130
131#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
132#[cfg_attr(feature = "ts_rs", derive(TS))]
133pub struct ChapterUpdate {
134    pub name: String,
135    pub color: Option<String>,
136    pub front_page_id: Option<Uuid>,
137    pub deadline: Option<DateTime<Utc>>,
138    pub opens_at: Option<DateTime<Utc>>,
139    /// CHANGE TO NON NULL WHEN FRONTEND MODULE EDITING IMPLEMENTED
140    pub course_module_id: Option<Uuid>,
141}
142
143pub struct ChapterInfo {
144    pub chapter_id: Uuid,
145    pub chapter_name: String,
146    pub chapter_front_page_id: Option<Uuid>,
147}
148
149pub async fn insert(
150    conn: &mut PgConnection,
151    pkey_policy: PKeyPolicy<Uuid>,
152    new_chapter: &NewChapter,
153) -> ModelResult<Uuid> {
154    // Refactor notice: At the moment frontend can optionally decide which module the new chapter
155    // belongs to. However, chapters should be grouped in a way that all chapters in the same
156    // module have consecutive order numbers. Hence this issue should be resolved first. Ideally
157    // this bit was not needed at all.
158    // ---------- ----------
159    let course_module_id = if let Some(course_module_id) = new_chapter.course_module_id {
160        course_module_id
161    } else {
162        let module = course_modules::get_default_by_course_id(conn, new_chapter.course_id).await?;
163        module.id
164    };
165    // ---------- ----------
166    let res = sqlx::query!(
167        r"
168INSERT INTO chapters(
169    id,
170    name,
171    color,
172    course_id,
173    chapter_number,
174    deadline,
175    opens_at,
176    course_module_id
177  )
178VALUES($1, $2, $3, $4, $5, $6, $7, $8)
179RETURNING id
180        ",
181        pkey_policy.into_uuid(),
182        new_chapter.name,
183        new_chapter.color,
184        new_chapter.course_id,
185        new_chapter.chapter_number,
186        new_chapter.deadline,
187        new_chapter.opens_at,
188        course_module_id,
189    )
190    .fetch_one(conn)
191    .await?;
192    Ok(res.id)
193}
194
195pub async fn set_front_page(
196    conn: &mut PgConnection,
197    chapter_id: Uuid,
198    front_page_id: Uuid,
199) -> ModelResult<()> {
200    sqlx::query!(
201        "UPDATE chapters SET front_page_id = $1 WHERE id = $2",
202        front_page_id,
203        chapter_id
204    )
205    .execute(conn)
206    .await?;
207    Ok(())
208}
209
210pub async fn set_opens_at(
211    conn: &mut PgConnection,
212    chapter_id: Uuid,
213    opens_at: DateTime<Utc>,
214) -> ModelResult<()> {
215    sqlx::query!(
216        "UPDATE chapters SET opens_at = $1 WHERE id = $2",
217        opens_at,
218        chapter_id,
219    )
220    .execute(conn)
221    .await?;
222    Ok(())
223}
224
225/// Checks the opens_at field for the chapter and compares it to the current time. If null, the chapter is always open.
226pub async fn is_open(conn: &mut PgConnection, chapter_id: Uuid) -> ModelResult<bool> {
227    let res = sqlx::query!(
228        r#"
229SELECT opens_at
230FROM chapters
231WHERE id = $1
232"#,
233        chapter_id
234    )
235    .fetch_one(conn)
236    .await?;
237    let open = res.opens_at.map(|o| o <= Utc::now()).unwrap_or(true);
238    Ok(open)
239}
240
241pub async fn get_chapter(
242    conn: &mut PgConnection,
243    chapter_id: Uuid,
244) -> ModelResult<DatabaseChapter> {
245    let chapter = sqlx::query_as!(
246        DatabaseChapter,
247        "
248SELECT *
249from chapters
250where id = $1;",
251        chapter_id,
252    )
253    .fetch_one(conn)
254    .await?;
255    Ok(chapter)
256}
257
258pub async fn get_course_id(conn: &mut PgConnection, chapter_id: Uuid) -> ModelResult<Uuid> {
259    let course_id = sqlx::query!("SELECT course_id from chapters where id = $1", chapter_id)
260        .fetch_one(conn)
261        .await?
262        .course_id;
263    Ok(course_id)
264}
265
266pub async fn update_chapter(
267    conn: &mut PgConnection,
268    chapter_id: Uuid,
269    chapter_update: ChapterUpdate,
270) -> ModelResult<DatabaseChapter> {
271    let res = sqlx::query_as!(
272        DatabaseChapter,
273        r#"
274UPDATE chapters
275SET name = $2,
276  deadline = $3,
277  opens_at = $4,
278  course_module_id = $5,
279  color = $6
280WHERE id = $1
281RETURNING *;
282    "#,
283        chapter_id,
284        chapter_update.name,
285        chapter_update.deadline,
286        chapter_update.opens_at,
287        chapter_update.course_module_id,
288        chapter_update.color,
289    )
290    .fetch_one(conn)
291    .await?;
292    Ok(res)
293}
294
295pub async fn update_chapter_image_path(
296    conn: &mut PgConnection,
297    chapter_id: Uuid,
298    chapter_image_path: Option<String>,
299) -> ModelResult<DatabaseChapter> {
300    let updated_chapter = sqlx::query_as!(
301        DatabaseChapter,
302        "
303UPDATE chapters
304SET chapter_image_path = $1
305WHERE id = $2
306RETURNING *;",
307        chapter_image_path,
308        chapter_id
309    )
310    .fetch_one(conn)
311    .await?;
312    Ok(updated_chapter)
313}
314
315#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
316#[cfg_attr(feature = "ts_rs", derive(TS))]
317pub struct ChapterWithStatus {
318    pub id: Uuid,
319    pub created_at: DateTime<Utc>,
320    pub updated_at: DateTime<Utc>,
321    pub name: String,
322    pub color: Option<String>,
323    pub course_id: Uuid,
324    pub deleted_at: Option<DateTime<Utc>>,
325    pub chapter_number: i32,
326    pub front_page_id: Option<Uuid>,
327    pub opens_at: Option<DateTime<Utc>>,
328    pub status: ChapterStatus,
329    pub chapter_image_url: Option<String>,
330    pub course_module_id: Uuid,
331}
332
333impl ChapterWithStatus {
334    pub fn from_database_chapter_timestamp_and_image_url(
335        database_chapter: DatabaseChapter,
336        timestamp: DateTime<Utc>,
337        chapter_image_url: Option<String>,
338    ) -> Self {
339        let open = database_chapter
340            .opens_at
341            .map(|o| o <= timestamp)
342            .unwrap_or(true);
343        let status = if open {
344            ChapterStatus::Open
345        } else {
346            ChapterStatus::Closed
347        };
348        ChapterWithStatus {
349            id: database_chapter.id,
350            created_at: database_chapter.created_at,
351            updated_at: database_chapter.updated_at,
352            name: database_chapter.name,
353            color: database_chapter.color,
354            course_id: database_chapter.course_id,
355            deleted_at: database_chapter.deleted_at,
356            chapter_number: database_chapter.chapter_number,
357            front_page_id: database_chapter.front_page_id,
358            opens_at: database_chapter.opens_at,
359            status,
360            chapter_image_url,
361            course_module_id: database_chapter.course_module_id,
362        }
363    }
364}
365
366#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy)]
367#[cfg_attr(feature = "ts_rs", derive(TS))]
368pub struct UserCourseInstanceChapterProgress {
369    pub score_given: f32,
370    pub score_maximum: i32,
371    pub total_exercises: Option<u32>,
372    pub attempted_exercises: Option<u32>,
373}
374
375pub async fn course_chapters(
376    conn: &mut PgConnection,
377    course_id: Uuid,
378) -> ModelResult<Vec<DatabaseChapter>> {
379    let chapters = sqlx::query_as!(
380        DatabaseChapter,
381        r#"
382SELECT id,
383  created_at,
384  updated_at,
385  name,
386  color,
387  course_id,
388  deleted_at,
389  chapter_image_path,
390  chapter_number,
391  front_page_id,
392  opens_at,
393  copied_from,
394  deadline,
395  course_module_id
396FROM chapters
397WHERE course_id = $1
398  AND deleted_at IS NULL;
399"#,
400        course_id
401    )
402    .fetch_all(conn)
403    .await?;
404    Ok(chapters)
405}
406
407pub async fn course_instance_chapters(
408    conn: &mut PgConnection,
409    course_instance_id: Uuid,
410) -> ModelResult<Vec<DatabaseChapter>> {
411    let chapters = sqlx::query_as!(
412        DatabaseChapter,
413        r#"
414SELECT id,
415  created_at,
416  updated_at,
417  name,
418  color,
419  course_id,
420  deleted_at,
421  chapter_image_path,
422  chapter_number,
423  front_page_id,
424  opens_at,
425  copied_from,
426  deadline,
427  course_module_id
428FROM chapters
429WHERE course_id = (SELECT course_id FROM course_instances WHERE id = $1)
430  AND deleted_at IS NULL;
431"#,
432        course_instance_id
433    )
434    .fetch_all(conn)
435    .await?;
436    Ok(chapters)
437}
438
439pub async fn delete_chapter(
440    conn: &mut PgConnection,
441    chapter_id: Uuid,
442) -> ModelResult<DatabaseChapter> {
443    let mut tx = conn.begin().await?;
444    let deleted = sqlx::query_as!(
445        DatabaseChapter,
446        r#"
447UPDATE chapters
448SET deleted_at = now()
449WHERE id = $1
450RETURNING *;
451"#,
452        chapter_id
453    )
454    .fetch_one(&mut *tx)
455    .await?;
456    // We'll also delete all the pages and exercises so that they don't conflict with future chapters
457    sqlx::query!(
458        "UPDATE pages SET deleted_at = now() WHERE chapter_id = $1 AND deleted_at IS NULL;",
459        chapter_id
460    )
461    .execute(&mut *tx)
462    .await?;
463    sqlx::query!(
464        "UPDATE exercise_tasks SET deleted_at = now() WHERE deleted_at IS NULL AND exercise_slide_id IN (SELECT id FROM exercise_slides WHERE exercise_slides.deleted_at IS NULL AND exercise_id IN (SELECT id FROM exercises WHERE chapter_id = $1 AND exercises.deleted_at IS NULL));",
465        chapter_id
466    )
467    .execute(&mut *tx).await?;
468    sqlx::query!(
469        "UPDATE exercise_slides SET deleted_at = now() WHERE deleted_at IS NULL AND exercise_id IN (SELECT id FROM exercises WHERE chapter_id = $1 AND exercises.deleted_at IS NULL);",
470        chapter_id
471    )
472    .execute(&mut *tx).await?;
473    sqlx::query!(
474        "UPDATE exercises SET deleted_at = now() WHERE deleted_at IS NULL AND chapter_id = $1;",
475        chapter_id
476    )
477    .execute(&mut *tx)
478    .await?;
479    tx.commit().await?;
480    Ok(deleted)
481}
482
483pub async fn get_user_course_instance_chapter_progress(
484    conn: &mut PgConnection,
485    course_instance_id: Uuid,
486    chapter_id: Uuid,
487    user_id: Uuid,
488) -> ModelResult<UserCourseInstanceChapterProgress> {
489    let course_instance =
490        crate::course_instances::get_course_instance(conn, course_instance_id).await?;
491    let mut exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
492
493    let exercise_ids: Vec<Uuid> = exercises.iter_mut().map(|e| e.id).collect();
494    let score_maximum: i32 = exercises.into_iter().map(|e| e.score_maximum).sum();
495
496    let user_chapter_metrics = crate::user_exercise_states::get_user_course_chapter_metrics(
497        conn,
498        course_instance.course_id,
499        &exercise_ids,
500        user_id,
501    )
502    .await?;
503
504    let result = UserCourseInstanceChapterProgress {
505        score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
506            user_chapter_metrics.score_given,
507        ),
508        score_maximum,
509        total_exercises: Some(TryInto::try_into(exercise_ids.len())).transpose()?,
510        attempted_exercises: user_chapter_metrics
511            .attempted_exercises
512            .map(TryInto::try_into)
513            .transpose()?,
514    };
515    Ok(result)
516}
517
518pub async fn get_chapter_by_page_id(
519    conn: &mut PgConnection,
520    page_id: Uuid,
521) -> ModelResult<DatabaseChapter> {
522    let chapter = sqlx::query_as!(
523        DatabaseChapter,
524        "
525SELECT c.*
526FROM chapters c,
527  pages p
528WHERE c.id = p.chapter_id
529  AND p.id = $1
530  AND c.deleted_at IS NULL
531    ",
532        page_id
533    )
534    .fetch_one(conn)
535    .await?;
536
537    Ok(chapter)
538}
539
540pub async fn get_chapter_info_by_page_metadata(
541    conn: &mut PgConnection,
542    current_page_metadata: &PageMetadata,
543) -> ModelResult<ChapterInfo> {
544    let chapter_page = sqlx::query_as!(
545        ChapterInfo,
546        "
547        SELECT
548            c.id as chapter_id,
549            c.name as chapter_name,
550            c.front_page_id as chapter_front_page_id
551        FROM chapters c
552        WHERE c.id = $1
553        AND c.course_id = $2
554            AND c.deleted_at IS NULL;
555        ",
556        current_page_metadata.chapter_id,
557        current_page_metadata.course_id
558    )
559    .fetch_one(conn)
560    .await?;
561
562    Ok(chapter_page)
563}
564
565pub async fn set_module(
566    conn: &mut PgConnection,
567    chapter_id: Uuid,
568    module_id: Uuid,
569) -> ModelResult<()> {
570    sqlx::query!(
571        "
572UPDATE chapters
573SET course_module_id = $2
574WHERE id = $1
575",
576        chapter_id,
577        module_id
578    )
579    .execute(conn)
580    .await?;
581    Ok(())
582}
583
584pub async fn get_for_module(conn: &mut PgConnection, module_id: Uuid) -> ModelResult<Vec<Uuid>> {
585    let res = sqlx::query!(
586        "
587SELECT id
588FROM chapters
589WHERE course_module_id = $1
590AND deleted_at IS NULL
591",
592        module_id
593    )
594    .map(|c| c.id)
595    .fetch_all(conn)
596    .await?;
597    Ok(res)
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    mod constraints {
605        use super::*;
606        use crate::{courses::NewCourse, library, test_helper::*};
607
608        #[tokio::test]
609        async fn cannot_create_chapter_for_different_course_than_its_module() {
610            insert_data!(:tx, :user, :org, course: course_1, instance: _instance, :course_module);
611            let course_2 = library::content_management::create_new_course(
612                tx.as_mut(),
613                PKeyPolicy::Generate,
614                NewCourse {
615                    name: "".to_string(),
616                    slug: "course-2".to_string(),
617                    organization_id: org,
618                    language_code: "en-US".to_string(),
619                    teacher_in_charge_name: "Teacher".to_string(),
620                    teacher_in_charge_email: "teacher@example.com".to_string(),
621                    description: "".to_string(),
622                    is_draft: false,
623                    is_test_mode: false,
624                    is_unlisted: false,
625                    copy_user_permissions: false,
626                    is_joinable_by_code_only: false,
627                    join_code: None,
628                    ask_marketing_consent: false,
629                    flagged_answers_threshold: Some(3),
630                    can_add_chatbot: false,
631                },
632                user,
633                |_, _, _| unimplemented!(),
634                |_| unimplemented!(),
635            )
636            .await
637            .unwrap()
638            .0
639            .id;
640            let chapter_result_2 = insert(
641                tx.as_mut(),
642                PKeyPolicy::Generate,
643                &NewChapter {
644                    name: "Chapter of second course".to_string(),
645                    color: None,
646                    course_id: course_2,
647                    chapter_number: 0,
648                    front_page_id: None,
649                    opens_at: None,
650                    deadline: None,
651                    course_module_id: Some(course_module.id),
652                },
653            )
654            .await;
655            assert!(
656                chapter_result_2.is_err(),
657                "Expected chapter creation to fail when course module belongs to a different course."
658            );
659        }
660    }
661}