headless_lms_models/
chapters.rs

1use std::{collections::HashMap, path::PathBuf};
2
3use crate::{
4    course_modules, courses,
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 AND deleted_at IS NULL;",
247        chapter_id,
248    )
249    .fetch_optional(conn)
250    .await?;
251    chapter.ok_or_else(|| {
252        ModelError::new(
253            ModelErrorType::NotFound,
254            format!(
255                "Chapter with id {} not found or has been deleted",
256                chapter_id
257            ),
258            None,
259        )
260    })
261}
262
263pub async fn get_course_id(conn: &mut PgConnection, chapter_id: Uuid) -> ModelResult<Uuid> {
264    let course_id = sqlx::query!("SELECT course_id from chapters where id = $1", chapter_id)
265        .fetch_one(conn)
266        .await?
267        .course_id;
268    Ok(course_id)
269}
270
271pub async fn update_chapter(
272    conn: &mut PgConnection,
273    chapter_id: Uuid,
274    chapter_update: ChapterUpdate,
275) -> ModelResult<DatabaseChapter> {
276    let res = sqlx::query_as!(
277        DatabaseChapter,
278        r#"
279UPDATE chapters
280SET name = $2,
281  deadline = $3,
282  opens_at = $4,
283  course_module_id = $5,
284  color = $6
285WHERE id = $1
286RETURNING *;
287    "#,
288        chapter_id,
289        chapter_update.name,
290        chapter_update.deadline,
291        chapter_update.opens_at,
292        chapter_update.course_module_id,
293        chapter_update.color,
294    )
295    .fetch_one(conn)
296    .await?;
297    Ok(res)
298}
299
300pub async fn update_chapter_image_path(
301    conn: &mut PgConnection,
302    chapter_id: Uuid,
303    chapter_image_path: Option<String>,
304) -> ModelResult<DatabaseChapter> {
305    let updated_chapter = sqlx::query_as!(
306        DatabaseChapter,
307        "
308UPDATE chapters
309SET chapter_image_path = $1
310WHERE id = $2
311RETURNING *;",
312        chapter_image_path,
313        chapter_id
314    )
315    .fetch_one(conn)
316    .await?;
317    Ok(updated_chapter)
318}
319
320#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
321#[cfg_attr(feature = "ts_rs", derive(TS))]
322pub struct ChapterWithStatus {
323    pub id: Uuid,
324    pub created_at: DateTime<Utc>,
325    pub updated_at: DateTime<Utc>,
326    pub name: String,
327    pub color: Option<String>,
328    pub course_id: Uuid,
329    pub deleted_at: Option<DateTime<Utc>>,
330    pub chapter_number: i32,
331    pub front_page_id: Option<Uuid>,
332    pub opens_at: Option<DateTime<Utc>>,
333    pub deadline: Option<DateTime<Utc>>,
334    pub status: ChapterStatus,
335    pub chapter_image_url: Option<String>,
336    pub course_module_id: Uuid,
337    pub exercise_deadline_override_count: i64,
338    pub exercise_deadline_override_distinct_count: i64,
339    pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
340}
341
342#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, Default)]
343#[cfg_attr(feature = "ts_rs", derive(TS))]
344pub struct ChapterExerciseDeadlineOverrideSummary {
345    pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
346    pub exercise_deadline_override_count: i64,
347    pub exercise_deadline_override_distinct_count: i64,
348}
349
350impl ChapterWithStatus {
351    pub fn from_database_chapter_timestamp_and_image_url(
352        database_chapter: DatabaseChapter,
353        timestamp: DateTime<Utc>,
354        chapter_image_url: Option<String>,
355        exercise_deadline_overrides: Option<ChapterExerciseDeadlineOverrideSummary>,
356    ) -> Self {
357        let open = database_chapter
358            .opens_at
359            .map(|o| o <= timestamp)
360            .unwrap_or(true);
361        let status = if open {
362            ChapterStatus::Open
363        } else {
364            ChapterStatus::Closed
365        };
366        let exercise_deadline_overrides = exercise_deadline_overrides.unwrap_or_default();
367        ChapterWithStatus {
368            id: database_chapter.id,
369            created_at: database_chapter.created_at,
370            updated_at: database_chapter.updated_at,
371            name: database_chapter.name,
372            color: database_chapter.color,
373            course_id: database_chapter.course_id,
374            deleted_at: database_chapter.deleted_at,
375            chapter_number: database_chapter.chapter_number,
376            front_page_id: database_chapter.front_page_id,
377            opens_at: database_chapter.opens_at,
378            deadline: database_chapter.deadline,
379            status,
380            chapter_image_url,
381            course_module_id: database_chapter.course_module_id,
382            exercise_deadline_override_count: exercise_deadline_overrides
383                .exercise_deadline_override_count,
384            exercise_deadline_override_distinct_count: exercise_deadline_overrides
385                .exercise_deadline_override_distinct_count,
386            earliest_exercise_deadline_override: exercise_deadline_overrides
387                .earliest_exercise_deadline_override,
388        }
389    }
390}
391
392#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy)]
393#[cfg_attr(feature = "ts_rs", derive(TS))]
394pub struct UserCourseInstanceChapterProgress {
395    pub score_given: f32,
396    pub score_maximum: i32,
397    pub total_exercises: Option<u32>,
398    pub attempted_exercises: Option<u32>,
399}
400
401pub async fn course_chapters(
402    conn: &mut PgConnection,
403    course_id: Uuid,
404) -> ModelResult<Vec<DatabaseChapter>> {
405    let chapters = sqlx::query_as!(
406        DatabaseChapter,
407        r#"
408SELECT id,
409  created_at,
410  updated_at,
411  name,
412  color,
413  course_id,
414  deleted_at,
415  chapter_image_path,
416  chapter_number,
417  front_page_id,
418  opens_at,
419  copied_from,
420  deadline,
421  course_module_id
422FROM chapters
423WHERE course_id = $1
424  AND deleted_at IS NULL;
425"#,
426        course_id
427    )
428    .fetch_all(conn)
429    .await?;
430    Ok(chapters)
431}
432
433pub async fn exercise_deadline_overrides_by_chapter_for_course(
434    conn: &mut PgConnection,
435    course_id: Uuid,
436) -> ModelResult<HashMap<Uuid, ChapterExerciseDeadlineOverrideSummary>> {
437    let rows = sqlx::query!(
438        r#"
439SELECT
440  e.chapter_id,
441  MIN(COALESCE(e.deadline, c.deadline)) FILTER (
442    WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
443  ) AS earliest_exercise_deadline_override,
444  COUNT(*) FILTER (
445    WHERE e.deadline IS NOT NULL
446      AND (c.deadline IS NULL OR e.deadline <> c.deadline)
447  ) AS exercise_deadline_override_count,
448  COUNT(DISTINCT COALESCE(e.deadline, c.deadline)) FILTER (
449    WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
450  ) AS exercise_deadline_override_distinct_count
451FROM exercises e
452JOIN chapters c ON c.id = e.chapter_id
453WHERE c.course_id = $1
454  AND c.deleted_at IS NULL
455  AND e.deleted_at IS NULL
456GROUP BY e.chapter_id, c.deadline
457        "#,
458        course_id
459    )
460    .fetch_all(conn)
461    .await?;
462
463    let mut summaries = HashMap::new();
464    for row in rows {
465        if let Some(chapter_id) = row.chapter_id {
466            summaries.insert(
467                chapter_id,
468                ChapterExerciseDeadlineOverrideSummary {
469                    earliest_exercise_deadline_override: row.earliest_exercise_deadline_override,
470                    exercise_deadline_override_count: row
471                        .exercise_deadline_override_count
472                        .unwrap_or(0),
473                    exercise_deadline_override_distinct_count: row
474                        .exercise_deadline_override_distinct_count
475                        .unwrap_or(0),
476                },
477            );
478        }
479    }
480    Ok(summaries)
481}
482
483pub async fn course_instance_chapters(
484    conn: &mut PgConnection,
485    course_instance_id: Uuid,
486) -> ModelResult<Vec<DatabaseChapter>> {
487    let chapters = sqlx::query_as!(
488        DatabaseChapter,
489        r#"
490SELECT id,
491  created_at,
492  updated_at,
493  name,
494  color,
495  course_id,
496  deleted_at,
497  chapter_image_path,
498  chapter_number,
499  front_page_id,
500  opens_at,
501  copied_from,
502  deadline,
503  course_module_id
504FROM chapters
505WHERE course_id = (SELECT course_id FROM course_instances WHERE id = $1)
506  AND deleted_at IS NULL;
507"#,
508        course_instance_id
509    )
510    .fetch_all(conn)
511    .await?;
512    Ok(chapters)
513}
514
515pub async fn delete_chapter(
516    conn: &mut PgConnection,
517    chapter_id: Uuid,
518) -> ModelResult<DatabaseChapter> {
519    let mut tx = conn.begin().await?;
520    let deleted = sqlx::query_as!(
521        DatabaseChapter,
522        r#"
523UPDATE chapters
524SET deleted_at = now()
525WHERE id = $1
526AND deleted_at IS NULL
527RETURNING *;
528"#,
529        chapter_id
530    )
531    .fetch_one(&mut *tx)
532    .await?;
533    // We'll also delete all the pages and exercises so that they don't conflict with future chapters
534    sqlx::query!(
535        "UPDATE pages SET deleted_at = now() WHERE chapter_id = $1 AND deleted_at IS NULL;",
536        chapter_id
537    )
538    .execute(&mut *tx)
539    .await?;
540    sqlx::query!(
541        "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));",
542        chapter_id
543    )
544    .execute(&mut *tx).await?;
545    sqlx::query!(
546        "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);",
547        chapter_id
548    )
549    .execute(&mut *tx).await?;
550    sqlx::query!(
551        "UPDATE exercises SET deleted_at = now() WHERE deleted_at IS NULL AND chapter_id = $1;",
552        chapter_id
553    )
554    .execute(&mut *tx)
555    .await?;
556    tx.commit().await?;
557    Ok(deleted)
558}
559
560pub async fn get_user_course_instance_chapter_progress(
561    conn: &mut PgConnection,
562    course_instance_id: Uuid,
563    chapter_id: Uuid,
564    user_id: Uuid,
565) -> ModelResult<UserCourseInstanceChapterProgress> {
566    let course_instance =
567        crate::course_instances::get_course_instance(conn, course_instance_id).await?;
568    let mut exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
569
570    let exercise_ids: Vec<Uuid> = exercises.iter_mut().map(|e| e.id).collect();
571    let score_maximum: i32 = exercises.into_iter().map(|e| e.score_maximum).sum();
572
573    let user_chapter_metrics = crate::user_exercise_states::get_user_course_chapter_metrics(
574        conn,
575        course_instance.course_id,
576        &exercise_ids,
577        user_id,
578    )
579    .await?;
580
581    let result = UserCourseInstanceChapterProgress {
582        score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
583            user_chapter_metrics.score_given,
584        ),
585        score_maximum,
586        total_exercises: Some(TryInto::try_into(exercise_ids.len())).transpose()?,
587        attempted_exercises: user_chapter_metrics
588            .attempted_exercises
589            .map(TryInto::try_into)
590            .transpose()?,
591    };
592    Ok(result)
593}
594
595pub async fn get_chapter_by_page_id(
596    conn: &mut PgConnection,
597    page_id: Uuid,
598) -> ModelResult<DatabaseChapter> {
599    let chapter = sqlx::query_as!(
600        DatabaseChapter,
601        "
602SELECT c.*
603FROM chapters c,
604  pages p
605WHERE c.id = p.chapter_id
606  AND p.id = $1
607  AND c.deleted_at IS NULL
608    ",
609        page_id
610    )
611    .fetch_one(conn)
612    .await?;
613
614    Ok(chapter)
615}
616
617pub async fn get_chapter_info_by_page_metadata(
618    conn: &mut PgConnection,
619    current_page_metadata: &PageMetadata,
620) -> ModelResult<ChapterInfo> {
621    let chapter_page = sqlx::query_as!(
622        ChapterInfo,
623        "
624        SELECT
625            c.id as chapter_id,
626            c.name as chapter_name,
627            c.front_page_id as chapter_front_page_id
628        FROM chapters c
629        WHERE c.id = $1
630        AND c.course_id = $2
631            AND c.deleted_at IS NULL;
632        ",
633        current_page_metadata.chapter_id,
634        current_page_metadata.course_id
635    )
636    .fetch_one(conn)
637    .await?;
638
639    Ok(chapter_page)
640}
641
642pub async fn set_module(
643    conn: &mut PgConnection,
644    chapter_id: Uuid,
645    module_id: Uuid,
646) -> ModelResult<()> {
647    sqlx::query!(
648        "
649UPDATE chapters
650SET course_module_id = $2
651WHERE id = $1
652",
653        chapter_id,
654        module_id
655    )
656    .execute(conn)
657    .await?;
658    Ok(())
659}
660
661pub async fn get_for_module(conn: &mut PgConnection, module_id: Uuid) -> ModelResult<Vec<Uuid>> {
662    let res = sqlx::query!(
663        "
664SELECT id
665FROM chapters
666WHERE course_module_id = $1
667AND deleted_at IS NULL
668",
669        module_id
670    )
671    .map(|c| c.id)
672    .fetch_all(conn)
673    .await?;
674    Ok(res)
675}
676
677#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
678#[cfg_attr(feature = "ts_rs", derive(TS))]
679pub struct UserChapterProgress {
680    pub user_id: Uuid,
681    pub chapter_id: Uuid,
682    pub chapter_number: i32,
683    pub chapter_name: String,
684    pub points_obtained: f64,
685    pub exercises_attempted: i64,
686}
687
688#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
689#[cfg_attr(feature = "ts_rs", derive(TS))]
690pub struct ChapterAvailability {
691    pub chapter_id: Uuid,
692    pub chapter_number: i32,
693    pub chapter_name: String,
694    pub exercises_available: i64,
695    pub points_available: i64,
696}
697
698#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
699#[cfg_attr(feature = "ts_rs", derive(TS))]
700pub struct CourseUserInfo {
701    pub first_name: Option<String>,
702    pub last_name: Option<String>,
703    pub user_id: Uuid,
704    pub email: Option<String>,
705    pub course_instance: Option<String>,
706}
707
708pub async fn fetch_user_chapter_progress(
709    conn: &mut PgConnection,
710    course_id: Uuid,
711) -> ModelResult<Vec<UserChapterProgress>> {
712    let rows = sqlx::query_as!(
713        UserChapterProgress,
714        r#"
715WITH base AS (
716  SELECT ues.user_id,
717    ex.chapter_id,
718    ues.exercise_id,
719    COALESCE(ues.score_given, 0)::double precision AS points
720  FROM user_exercise_states ues
721    JOIN exercises ex ON ex.id = ues.exercise_id
722  WHERE ues.course_id = $1
723    AND ues.deleted_at IS NULL
724    AND ex.deleted_at IS NULL
725)
726SELECT b.user_id AS user_id,
727  c.id AS chapter_id,
728  c.chapter_number AS chapter_number,
729  c.name AS chapter_name,
730  COALESCE(SUM(b.points), 0)::double precision AS "points_obtained!",
731  COALESCE(COUNT(DISTINCT b.exercise_id), 0)::bigint AS "exercises_attempted!"
732FROM base b
733  JOIN chapters c ON c.id = b.chapter_id
734GROUP BY b.user_id,
735  c.id,
736  c.chapter_number,
737  c.name
738ORDER BY b.user_id,
739  c.chapter_number
740        "#,
741        course_id
742    )
743    .fetch_all(&mut *conn)
744    .await?;
745
746    Ok(rows)
747}
748
749pub async fn fetch_chapter_availability(
750    conn: &mut PgConnection,
751    course_id: Uuid,
752) -> ModelResult<Vec<ChapterAvailability>> {
753    let rows = sqlx::query_as!(
754        ChapterAvailability,
755        r#"
756SELECT c.id AS chapter_id,
757  c.chapter_number AS chapter_number,
758  c.name AS chapter_name,
759  COALESCE(COUNT(ex.id), 0)::bigint AS "exercises_available!",
760  COALESCE(COUNT(ex.id), 0)::bigint AS "points_available!"
761FROM chapters c
762  JOIN exercises ex ON ex.chapter_id = c.id
763WHERE c.course_id = $1
764  AND c.deleted_at IS NULL
765  AND ex.deleted_at IS NULL
766GROUP BY c.id,
767  c.chapter_number,
768  c.name
769ORDER BY c.chapter_number
770        "#,
771        course_id
772    )
773    .fetch_all(conn)
774    .await?;
775
776    Ok(rows)
777}
778
779pub async fn fetch_course_users(
780    conn: &mut PgConnection,
781    course_id: Uuid,
782) -> ModelResult<Vec<CourseUserInfo>> {
783    let rows_raw = sqlx::query!(
784        r#"
785    SELECT
786        ud.first_name,
787        ud.last_name,
788        u.id AS user_id,
789        ud.email AS "email?",
790        ci.name AS "course_instance?"
791    FROM course_instance_enrollments AS cie
792    JOIN users              AS u  ON u.id = cie.user_id
793    LEFT JOIN user_details  AS ud ON ud.user_id = u.id
794    JOIN course_instances   AS ci ON ci.id = cie.course_instance_id
795    WHERE cie.course_id = $1
796        AND cie.deleted_at IS NULL
797    ORDER BY 1, user_id
798    "#,
799        course_id
800    )
801    .fetch_all(conn)
802    .await?;
803
804    let rows = rows_raw
805        .into_iter()
806        .map(|r| {
807            let first_name = r
808                .first_name
809                .map(|f| f.trim().to_string())
810                .filter(|f| !f.is_empty());
811            let last_name = r
812                .last_name
813                .map(|l| l.trim().to_string())
814                .filter(|l| !l.is_empty());
815
816            CourseUserInfo {
817                first_name,
818                last_name,
819                user_id: r.user_id,
820                email: r.email,
821                course_instance: r.course_instance,
822            }
823        })
824        .collect();
825
826    Ok(rows)
827}
828
829#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
830#[cfg_attr(feature = "ts_rs", derive(TS))]
831pub struct UnreturnedExercise {
832    pub id: Uuid,
833    pub name: String,
834}
835
836#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
837#[cfg_attr(feature = "ts_rs", derive(TS))]
838pub struct ChapterLockPreview {
839    pub has_unreturned_exercises: bool,
840    pub unreturned_exercises_count: i32,
841    pub unreturned_exercises: Vec<UnreturnedExercise>,
842}
843
844pub async fn get_chapter_lock_preview(
845    conn: &mut PgConnection,
846    chapter_id: Uuid,
847    user_id: Uuid,
848    course_id: Uuid,
849) -> ModelResult<ChapterLockPreview> {
850    let exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
851
852    if exercises.is_empty() {
853        return Ok(ChapterLockPreview {
854            has_unreturned_exercises: false,
855            unreturned_exercises_count: 0,
856            unreturned_exercises: Vec::new(),
857        });
858    }
859
860    let exercise_ids: Vec<Uuid> = exercises.iter().map(|e| e.id).collect();
861
862    let returned_exercise_ids =
863        crate::user_exercise_states::get_returned_exercise_ids_for_user_and_course(
864            conn,
865            &exercise_ids,
866            user_id,
867            course_id,
868        )
869        .await?;
870
871    let returned_ids: std::collections::HashSet<Uuid> = returned_exercise_ids.into_iter().collect();
872
873    let unreturned_exercises: Vec<UnreturnedExercise> = exercises
874        .into_iter()
875        .filter(|e| !returned_ids.contains(&e.id))
876        .map(|e| UnreturnedExercise {
877            id: e.id,
878            name: e.name,
879        })
880        .collect();
881
882    let count = unreturned_exercises.len() as i32;
883    let has_unreturned = count > 0;
884
885    Ok(ChapterLockPreview {
886        has_unreturned_exercises: has_unreturned,
887        unreturned_exercises_count: count,
888        unreturned_exercises,
889    })
890}
891
892pub async fn get_previous_chapters_in_module(
893    conn: &mut PgConnection,
894    chapter_id: Uuid,
895) -> ModelResult<Vec<DatabaseChapter>> {
896    let chapter = get_chapter(conn, chapter_id).await?;
897    let previous_chapters = sqlx::query_as!(
898        DatabaseChapter,
899        r#"
900SELECT *
901FROM chapters
902WHERE course_module_id = $1
903  AND chapter_number < $2
904  AND deleted_at IS NULL
905ORDER BY chapter_number ASC
906        "#,
907        chapter.course_module_id,
908        chapter.chapter_number
909    )
910    .fetch_all(conn)
911    .await?;
912    Ok(previous_chapters)
913}
914
915pub async fn move_chapter_exercises_to_manual_review(
916    conn: &mut PgConnection,
917    chapter_id: Uuid,
918    user_id: Uuid,
919    course_id: Uuid,
920) -> ModelResult<()> {
921    use crate::CourseOrExamId;
922    use crate::exercises;
923    use crate::user_exercise_states::{self, ReviewingStage};
924
925    let exercises = exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
926
927    for exercise in exercises {
928        let user_exercise_state_result =
929            user_exercise_states::get_users_current_by_exercise(conn, user_id, &exercise).await;
930
931        if let Ok(user_exercise_state) = user_exercise_state_result
932            && user_exercise_state.reviewing_stage != ReviewingStage::WaitingForManualGrading
933            && user_exercise_state.reviewing_stage != ReviewingStage::ReviewedAndLocked
934            && user_exercise_state.selected_exercise_slide_id.is_some()
935        {
936            let course_or_exam_id = CourseOrExamId::Course(course_id);
937            user_exercise_states::update_reviewing_stage(
938                conn,
939                user_id,
940                course_or_exam_id,
941                exercise.id,
942                ReviewingStage::WaitingForManualGrading,
943            )
944            .await?;
945        }
946    }
947
948    Ok(())
949}
950
951/// Unlocks the first chapter(s) with exercises in the base module (order_number == 0) for a user.
952/// Also unlocks any chapters without exercises that come before the first chapter with exercises.
953pub async fn unlock_first_chapters_for_user(
954    conn: &mut PgConnection,
955    user_id: Uuid,
956    course_id: Uuid,
957) -> ModelResult<Vec<Uuid>> {
958    use crate::{course_modules, exercises, user_chapter_locking_statuses};
959
960    let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
961    let base_module = all_modules
962        .into_iter()
963        .find(|m| m.order_number == 0)
964        .ok_or_else(|| {
965            ModelError::new(
966                ModelErrorType::NotFound,
967                "Base module not found".to_string(),
968                None,
969            )
970        })?;
971
972    let module_chapter_ids = get_for_module(conn, base_module.id).await?;
973    let mut module_chapters = course_chapters(conn, course_id)
974        .await?
975        .into_iter()
976        .filter(|c| module_chapter_ids.contains(&c.id))
977        .collect::<Vec<_>>();
978    module_chapters.sort_by_key(|c| c.chapter_number);
979
980    let mut chapters_to_unlock = Vec::new();
981
982    for chapter in &module_chapters {
983        let exercises = exercises::get_exercises_by_chapter_id(conn, chapter.id).await?;
984        let has_exercises = !exercises.is_empty();
985
986        if has_exercises {
987            chapters_to_unlock.push(chapter.id);
988            break;
989        } else {
990            chapters_to_unlock.push(chapter.id);
991        }
992    }
993
994    for chapter_id in &chapters_to_unlock {
995        user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
996            .await?;
997    }
998
999    Ok(chapters_to_unlock)
1000}
1001
1002/// Unlocks the next chapter(s) for a user after they complete a chapter.
1003/// If the completed chapter is the last in a base module (order_number == 0), unlocks the first chapter
1004/// of all additional modules (order_number != 0). Otherwise, unlocks the next chapter in the same module.
1005/// Note: If a module has no chapters with exercises, all chapters in that module will be unlocked.
1006/// This is intentional to allow progression through content-only chapters.
1007pub async fn unlock_next_chapters_for_user(
1008    conn: &mut PgConnection,
1009    user_id: Uuid,
1010    chapter_id: Uuid,
1011    course_id: Uuid,
1012) -> ModelResult<Vec<Uuid>> {
1013    use crate::{course_modules, exercises, user_chapter_locking_statuses};
1014
1015    let completed_chapter = get_chapter(conn, chapter_id).await?;
1016    let module = course_modules::get_by_id(conn, completed_chapter.course_module_id).await?;
1017
1018    let module_chapters = get_for_module(conn, completed_chapter.course_module_id).await?;
1019    let mut all_module_chapters = course_chapters(conn, course_id)
1020        .await?
1021        .into_iter()
1022        .filter(|c| module_chapters.contains(&c.id))
1023        .collect::<Vec<_>>();
1024    all_module_chapters.sort_by_key(|c| c.chapter_number);
1025
1026    let mut chapters_to_unlock = Vec::new();
1027
1028    let is_base_module = module.order_number == 0;
1029
1030    let course = courses::get_course(conn, course_id).await?;
1031    let mut all_module_chapters_completed = true;
1032    for chapter in &all_module_chapters {
1033        let status = user_chapter_locking_statuses::get_or_init_status(
1034            conn,
1035            user_id,
1036            chapter.id,
1037            Some(course_id),
1038            Some(course.chapter_locking_enabled),
1039        )
1040        .await?;
1041        if !matches!(
1042            status,
1043            Some(user_chapter_locking_statuses::ChapterLockingStatus::CompletedAndLocked)
1044        ) {
1045            all_module_chapters_completed = false;
1046            break;
1047        }
1048    }
1049
1050    if is_base_module && all_module_chapters_completed {
1051        let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
1052        let additional_modules: Vec<_> = all_modules
1053            .into_iter()
1054            .filter(|m| m.order_number != 0)
1055            .collect();
1056
1057        let mut all_additional_module_chapter_ids = Vec::new();
1058        for additional_module in &additional_modules {
1059            let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1060            all_additional_module_chapter_ids.extend(module_chapter_ids);
1061        }
1062
1063        let all_exercises = if !all_additional_module_chapter_ids.is_empty() {
1064            exercises::get_exercises_by_chapter_ids(conn, &all_additional_module_chapter_ids)
1065                .await?
1066        } else {
1067            Vec::new()
1068        };
1069
1070        let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1071            .into_iter()
1072            .fold(std::collections::HashMap::new(), |mut acc, ex| {
1073                if let Some(ch_id) = ex.chapter_id {
1074                    acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1075                }
1076                acc
1077            });
1078
1079        for additional_module in additional_modules {
1080            let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1081            let mut module_chapters = course_chapters(conn, course_id)
1082                .await?
1083                .into_iter()
1084                .filter(|c| module_chapter_ids.contains(&c.id))
1085                .collect::<Vec<_>>();
1086            module_chapters.sort_by_key(|c| c.chapter_number);
1087
1088            for chapter in &module_chapters {
1089                let has_exercises = exercises_by_chapter
1090                    .get(&chapter.id)
1091                    .map(|exs| !exs.is_empty())
1092                    .unwrap_or(false);
1093
1094                if has_exercises {
1095                    chapters_to_unlock.push(chapter.id);
1096                    break;
1097                } else {
1098                    chapters_to_unlock.push(chapter.id);
1099                }
1100            }
1101        }
1102    } else {
1103        let module_chapter_ids = get_for_module(conn, completed_chapter.course_module_id).await?;
1104        let mut module_chapters = course_chapters(conn, course_id)
1105            .await?
1106            .into_iter()
1107            .filter(|c| module_chapter_ids.contains(&c.id))
1108            .collect::<Vec<_>>();
1109        module_chapters.sort_by_key(|c| c.chapter_number);
1110        let mut found_completed = false;
1111        let mut candidate_chapter_ids = Vec::new();
1112
1113        for chapter in &module_chapters {
1114            if chapter.id == completed_chapter.id {
1115                found_completed = true;
1116                continue;
1117            }
1118
1119            if !found_completed {
1120                continue;
1121            }
1122
1123            candidate_chapter_ids.push(chapter.id);
1124        }
1125
1126        let all_exercises = if !candidate_chapter_ids.is_empty() {
1127            exercises::get_exercises_by_chapter_ids(conn, &candidate_chapter_ids).await?
1128        } else {
1129            Vec::new()
1130        };
1131
1132        let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1133            .into_iter()
1134            .fold(std::collections::HashMap::new(), |mut acc, ex| {
1135                if let Some(ch_id) = ex.chapter_id {
1136                    acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1137                }
1138                acc
1139            });
1140
1141        for chapter_id in candidate_chapter_ids {
1142            let has_exercises = exercises_by_chapter
1143                .get(&chapter_id)
1144                .map(|exs| !exs.is_empty())
1145                .unwrap_or(false);
1146
1147            if has_exercises {
1148                chapters_to_unlock.push(chapter_id);
1149                break;
1150            } else {
1151                chapters_to_unlock.push(chapter_id);
1152            }
1153        }
1154    }
1155
1156    for chapter_id in &chapters_to_unlock {
1157        user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1158            .await?;
1159    }
1160
1161    Ok(chapters_to_unlock)
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167
1168    mod constraints {
1169        use super::*;
1170        use crate::{courses::NewCourse, library, test_helper::*};
1171
1172        #[tokio::test]
1173        async fn cannot_create_chapter_for_different_course_than_its_module() {
1174            insert_data!(:tx, :user, :org, course: course_1, instance: _instance, :course_module);
1175            let course_2 = library::content_management::create_new_course(
1176                tx.as_mut(),
1177                PKeyPolicy::Generate,
1178                NewCourse {
1179                    name: "".to_string(),
1180                    slug: "course-2".to_string(),
1181                    organization_id: org,
1182                    language_code: "en".to_string(),
1183                    teacher_in_charge_name: "Teacher".to_string(),
1184                    teacher_in_charge_email: "teacher@example.com".to_string(),
1185                    description: "".to_string(),
1186                    is_draft: false,
1187                    is_test_mode: false,
1188                    is_unlisted: false,
1189                    copy_user_permissions: false,
1190                    is_joinable_by_code_only: false,
1191                    join_code: None,
1192                    ask_marketing_consent: false,
1193                    flagged_answers_threshold: Some(3),
1194                    can_add_chatbot: false,
1195                },
1196                user,
1197                |_, _, _| unimplemented!(),
1198                |_| unimplemented!(),
1199            )
1200            .await
1201            .unwrap()
1202            .0
1203            .id;
1204            let chapter_result_2 = insert(
1205                tx.as_mut(),
1206                PKeyPolicy::Generate,
1207                &NewChapter {
1208                    name: "Chapter of second course".to_string(),
1209                    color: None,
1210                    course_id: course_2,
1211                    chapter_number: 0,
1212                    front_page_id: None,
1213                    opens_at: None,
1214                    deadline: None,
1215                    course_module_id: Some(course_module.id),
1216                },
1217            )
1218            .await;
1219            assert!(
1220                chapter_result_2.is_err(),
1221                "Expected chapter creation to fail when course module belongs to a different course."
1222            );
1223        }
1224    }
1225}