headless_lms_models/
chapters.rs

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