headless_lms_models/
chapters.rs

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