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