headless_lms_models/
chapters.rs

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