Skip to main content

headless_lms_models/
chapters.rs

1use std::{collections::HashMap, path::PathBuf};
2
3use crate::CourseOrExamId;
4use crate::exercise_slide_submissions;
5use crate::exercises;
6use crate::exercises::GradingProgress;
7use crate::library::user_exercise_state_updater;
8use crate::user_exercise_states::{self, ReviewingStage};
9use crate::{
10    course_modules, courses,
11    pages::{PageMetadata, PageWithExercises},
12    prelude::*,
13};
14use headless_lms_base::config::ApplicationConfiguration;
15use headless_lms_utils::{
16    file_store::FileStore, numbers::option_f32_to_f32_two_decimals_with_none_as_zero,
17};
18use utoipa::ToSchema;
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
21
22pub struct DatabaseChapter {
23    pub id: Uuid,
24    pub created_at: DateTime<Utc>,
25    pub updated_at: DateTime<Utc>,
26    pub name: String,
27    pub color: Option<String>,
28    pub course_id: Uuid,
29    pub deleted_at: Option<DateTime<Utc>>,
30    pub chapter_image_path: Option<String>,
31    pub chapter_number: i32,
32    pub front_page_id: Option<Uuid>,
33    pub opens_at: Option<DateTime<Utc>>,
34    pub deadline: Option<DateTime<Utc>>,
35    pub copied_from: Option<Uuid>,
36    pub course_module_id: Uuid,
37}
38
39impl DatabaseChapter {
40    /// True if the chapter is currently open or was open and is now closed.
41    pub fn has_opened(&self) -> bool {
42        self.opens_at
43            .map(|opens_at| opens_at < Utc::now())
44            .unwrap_or(true)
45    }
46}
47
48#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
49
50pub struct Chapter {
51    pub id: Uuid,
52    pub created_at: DateTime<Utc>,
53    pub updated_at: DateTime<Utc>,
54    pub name: String,
55    pub color: Option<String>,
56    pub course_id: Uuid,
57    pub deleted_at: Option<DateTime<Utc>>,
58    pub chapter_image_url: Option<String>,
59    pub chapter_number: i32,
60    pub front_page_id: Option<Uuid>,
61    pub opens_at: Option<DateTime<Utc>>,
62    pub deadline: Option<DateTime<Utc>>,
63    pub copied_from: Option<Uuid>,
64    pub course_module_id: Uuid,
65}
66
67impl Chapter {
68    pub fn from_database_chapter(
69        chapter: &DatabaseChapter,
70        file_store: &dyn FileStore,
71        app_conf: &ApplicationConfiguration,
72    ) -> Self {
73        let chapter_image_url = chapter.chapter_image_path.as_ref().map(|image| {
74            let path = PathBuf::from(image);
75            file_store.get_download_url(path.as_path(), app_conf)
76        });
77        Self {
78            id: chapter.id,
79            created_at: chapter.created_at,
80            updated_at: chapter.updated_at,
81            name: chapter.name.clone(),
82            color: chapter.color.clone(),
83            course_id: chapter.course_id,
84            deleted_at: chapter.deleted_at,
85            chapter_image_url,
86            chapter_number: chapter.chapter_number,
87            front_page_id: chapter.front_page_id,
88            opens_at: chapter.opens_at,
89            copied_from: chapter.copied_from,
90            deadline: chapter.deadline,
91            course_module_id: chapter.course_module_id,
92        }
93    }
94}
95
96#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
97#[serde(rename_all = "snake_case")]
98#[derive(Default)]
99pub enum ChapterStatus {
100    Open,
101    #[default]
102    Closed,
103}
104
105#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
106pub struct ChapterPagesWithExercises {
107    pub id: Uuid,
108    pub created_at: DateTime<Utc>,
109    pub updated_at: DateTime<Utc>,
110    pub name: String,
111    pub course_id: Uuid,
112    pub deleted_at: Option<DateTime<Utc>>,
113    pub chapter_number: i32,
114    pub pages: Vec<PageWithExercises>,
115}
116
117// Represents the subset of page fields that are required to create a new course.
118#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
119
120pub struct NewChapter {
121    pub name: String,
122    pub color: Option<String>,
123    pub course_id: Uuid,
124    pub chapter_number: i32,
125    pub front_page_id: Option<Uuid>,
126    pub opens_at: Option<DateTime<Utc>>,
127    pub deadline: Option<DateTime<Utc>>,
128    /// If undefined when creating a chapter, will use the course default one.
129    /// CHANGE TO NON NULL WHEN FRONTEND MODULE EDITING IMPLEMENTED
130    pub course_module_id: Option<Uuid>,
131}
132
133#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
134
135pub struct ChapterUpdate {
136    pub name: String,
137    pub color: Option<String>,
138    pub front_page_id: Option<Uuid>,
139    pub deadline: Option<DateTime<Utc>>,
140    pub opens_at: Option<DateTime<Utc>>,
141    /// CHANGE TO NON NULL WHEN FRONTEND MODULE EDITING IMPLEMENTED
142    pub course_module_id: Option<Uuid>,
143}
144
145pub struct ChapterInfo {
146    pub chapter_id: Uuid,
147    pub chapter_name: String,
148    pub chapter_front_page_id: Option<Uuid>,
149}
150
151pub async fn insert(
152    conn: &mut PgConnection,
153    pkey_policy: PKeyPolicy<Uuid>,
154    new_chapter: &NewChapter,
155) -> ModelResult<Uuid> {
156    // Refactor notice: At the moment frontend can optionally decide which module the new chapter
157    // belongs to. However, chapters should be grouped in a way that all chapters in the same
158    // module have consecutive order numbers. Hence this issue should be resolved first. Ideally
159    // this bit was not needed at all.
160    // ---------- ----------
161    let course_module_id = if let Some(course_module_id) = new_chapter.course_module_id {
162        course_module_id
163    } else {
164        let module = course_modules::get_default_by_course_id(conn, new_chapter.course_id).await?;
165        module.id
166    };
167    // ---------- ----------
168    let res = sqlx::query!(
169        r"
170INSERT INTO chapters(
171    id,
172    name,
173    color,
174    course_id,
175    chapter_number,
176    deadline,
177    opens_at,
178    course_module_id
179  )
180VALUES($1, $2, $3, $4, $5, $6, $7, $8)
181RETURNING id
182        ",
183        pkey_policy.into_uuid(),
184        new_chapter.name,
185        new_chapter.color,
186        new_chapter.course_id,
187        new_chapter.chapter_number,
188        new_chapter.deadline,
189        new_chapter.opens_at,
190        course_module_id,
191    )
192    .fetch_one(conn)
193    .await?;
194    Ok(res.id)
195}
196
197pub async fn set_front_page(
198    conn: &mut PgConnection,
199    chapter_id: Uuid,
200    front_page_id: Uuid,
201) -> ModelResult<()> {
202    sqlx::query!(
203        "UPDATE chapters SET front_page_id = $1 WHERE id = $2",
204        front_page_id,
205        chapter_id
206    )
207    .execute(conn)
208    .await?;
209    Ok(())
210}
211
212pub async fn set_opens_at(
213    conn: &mut PgConnection,
214    chapter_id: Uuid,
215    opens_at: DateTime<Utc>,
216) -> ModelResult<()> {
217    sqlx::query!(
218        "UPDATE chapters SET opens_at = $1 WHERE id = $2",
219        opens_at,
220        chapter_id,
221    )
222    .execute(conn)
223    .await?;
224    Ok(())
225}
226
227/// Checks the opens_at field for the chapter and compares it to the current time. If null, the chapter is always open.
228pub async fn is_open(conn: &mut PgConnection, chapter_id: Uuid) -> ModelResult<bool> {
229    let res = sqlx::query!(
230        r#"
231SELECT opens_at
232FROM chapters
233WHERE id = $1
234"#,
235        chapter_id
236    )
237    .fetch_one(conn)
238    .await?;
239    let open = res.opens_at.map(|o| o <= Utc::now()).unwrap_or(true);
240    Ok(open)
241}
242
243pub async fn get_chapter(
244    conn: &mut PgConnection,
245    chapter_id: Uuid,
246) -> ModelResult<DatabaseChapter> {
247    let chapter = sqlx::query_as!(
248        DatabaseChapter,
249        "
250SELECT *
251from chapters
252where id = $1 AND deleted_at IS NULL;",
253        chapter_id,
254    )
255    .fetch_optional(conn)
256    .await?;
257    chapter.ok_or_else(|| {
258        ModelError::new(
259            ModelErrorType::NotFound,
260            format!(
261                "Chapter with id {} not found or has been deleted",
262                chapter_id
263            ),
264            None,
265        )
266    })
267}
268
269pub async fn get_course_id(conn: &mut PgConnection, chapter_id: Uuid) -> ModelResult<Uuid> {
270    let course_id = sqlx::query!("SELECT course_id from chapters where id = $1", chapter_id)
271        .fetch_one(conn)
272        .await?
273        .course_id;
274    Ok(course_id)
275}
276
277pub async fn update_chapter(
278    conn: &mut PgConnection,
279    chapter_id: Uuid,
280    chapter_update: ChapterUpdate,
281) -> ModelResult<DatabaseChapter> {
282    let res = sqlx::query_as!(
283        DatabaseChapter,
284        r#"
285UPDATE chapters
286SET name = $2,
287  deadline = $3,
288  opens_at = $4,
289  course_module_id = $5,
290  color = $6
291WHERE id = $1
292RETURNING *;
293    "#,
294        chapter_id,
295        chapter_update.name,
296        chapter_update.deadline,
297        chapter_update.opens_at,
298        chapter_update.course_module_id,
299        chapter_update.color,
300    )
301    .fetch_one(conn)
302    .await?;
303    Ok(res)
304}
305
306pub async fn update_chapter_image_path(
307    conn: &mut PgConnection,
308    chapter_id: Uuid,
309    chapter_image_path: Option<String>,
310) -> ModelResult<DatabaseChapter> {
311    let updated_chapter = sqlx::query_as!(
312        DatabaseChapter,
313        "
314UPDATE chapters
315SET chapter_image_path = $1
316WHERE id = $2
317RETURNING *;",
318        chapter_image_path,
319        chapter_id
320    )
321    .fetch_one(conn)
322    .await?;
323    Ok(updated_chapter)
324}
325
326/// Whether a live chapter still uses this image path.
327///
328/// Course copies share images with their source, so check this before deleting the blob.
329pub async fn chapter_image_path_is_referenced(
330    conn: &mut PgConnection,
331    chapter_image_path: &str,
332) -> ModelResult<bool> {
333    let referenced = sqlx::query_scalar!(
334        r#"
335SELECT EXISTS(
336  SELECT 1 FROM chapters WHERE chapter_image_path = $1 AND deleted_at IS NULL
337) AS "exists!"
338        "#,
339        chapter_image_path
340    )
341    .fetch_one(conn)
342    .await?;
343    Ok(referenced)
344}
345
346#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
347
348pub struct ChapterWithStatus {
349    pub id: Uuid,
350    pub created_at: DateTime<Utc>,
351    pub updated_at: DateTime<Utc>,
352    pub name: String,
353    pub color: Option<String>,
354    pub course_id: Uuid,
355    pub deleted_at: Option<DateTime<Utc>>,
356    pub chapter_number: i32,
357    pub front_page_id: Option<Uuid>,
358    pub opens_at: Option<DateTime<Utc>>,
359    pub deadline: Option<DateTime<Utc>>,
360    pub status: ChapterStatus,
361    pub chapter_image_url: Option<String>,
362    pub course_module_id: Uuid,
363    pub exercise_deadline_override_count: i64,
364    pub exercise_deadline_override_distinct_count: i64,
365    pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
366}
367
368#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, Default)]
369
370pub struct ChapterExerciseDeadlineOverrideSummary {
371    pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
372    pub exercise_deadline_override_count: i64,
373    pub exercise_deadline_override_distinct_count: i64,
374}
375
376impl ChapterWithStatus {
377    pub fn from_database_chapter_timestamp_and_image_url(
378        database_chapter: DatabaseChapter,
379        timestamp: DateTime<Utc>,
380        chapter_image_url: Option<String>,
381        exercise_deadline_overrides: Option<ChapterExerciseDeadlineOverrideSummary>,
382    ) -> Self {
383        let open = database_chapter
384            .opens_at
385            .map(|o| o <= timestamp)
386            .unwrap_or(true);
387        let status = if open {
388            ChapterStatus::Open
389        } else {
390            ChapterStatus::Closed
391        };
392        let exercise_deadline_overrides = exercise_deadline_overrides.unwrap_or_default();
393        ChapterWithStatus {
394            id: database_chapter.id,
395            created_at: database_chapter.created_at,
396            updated_at: database_chapter.updated_at,
397            name: database_chapter.name,
398            color: database_chapter.color,
399            course_id: database_chapter.course_id,
400            deleted_at: database_chapter.deleted_at,
401            chapter_number: database_chapter.chapter_number,
402            front_page_id: database_chapter.front_page_id,
403            opens_at: database_chapter.opens_at,
404            deadline: database_chapter.deadline,
405            status,
406            chapter_image_url,
407            course_module_id: database_chapter.course_module_id,
408            exercise_deadline_override_count: exercise_deadline_overrides
409                .exercise_deadline_override_count,
410            exercise_deadline_override_distinct_count: exercise_deadline_overrides
411                .exercise_deadline_override_distinct_count,
412            earliest_exercise_deadline_override: exercise_deadline_overrides
413                .earliest_exercise_deadline_override,
414        }
415    }
416}
417
418#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
419
420pub struct UserCourseInstanceChapterProgress {
421    pub score_given: f32,
422    pub score_maximum: i32,
423    pub total_exercises: Option<u32>,
424    pub attempted_exercises: Option<u32>,
425}
426
427pub async fn get_course_chapters(
428    conn: &mut PgConnection,
429    course_id: Uuid,
430) -> ModelResult<Vec<DatabaseChapter>> {
431    let chapters = sqlx::query_as!(
432        DatabaseChapter,
433        r#"
434SELECT *
435FROM chapters
436WHERE course_id = $1
437  AND deleted_at IS NULL;
438"#,
439        course_id
440    )
441    .fetch_all(conn)
442    .await?;
443    Ok(chapters)
444}
445
446pub async fn exercise_deadline_overrides_by_chapter_for_course(
447    conn: &mut PgConnection,
448    course_id: Uuid,
449) -> ModelResult<HashMap<Uuid, ChapterExerciseDeadlineOverrideSummary>> {
450    let rows = sqlx::query!(
451        r#"
452SELECT
453  e.chapter_id,
454  MIN(COALESCE(e.deadline, c.deadline)) FILTER (
455    WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
456  ) AS earliest_exercise_deadline_override,
457  COUNT(*) FILTER (
458    WHERE e.deadline IS NOT NULL
459      AND (c.deadline IS NULL OR e.deadline <> c.deadline)
460  ) AS exercise_deadline_override_count,
461  COUNT(DISTINCT COALESCE(e.deadline, c.deadline)) FILTER (
462    WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
463  ) AS exercise_deadline_override_distinct_count
464FROM exercises e
465JOIN chapters c ON c.id = e.chapter_id
466WHERE c.course_id = $1
467  AND c.deleted_at IS NULL
468  AND e.deleted_at IS NULL
469GROUP BY e.chapter_id, c.deadline
470        "#,
471        course_id
472    )
473    .fetch_all(conn)
474    .await?;
475
476    let mut summaries = HashMap::new();
477    for row in rows {
478        if let Some(chapter_id) = row.chapter_id {
479            summaries.insert(
480                chapter_id,
481                ChapterExerciseDeadlineOverrideSummary {
482                    earliest_exercise_deadline_override: row.earliest_exercise_deadline_override,
483                    exercise_deadline_override_count: row
484                        .exercise_deadline_override_count
485                        .unwrap_or(0),
486                    exercise_deadline_override_distinct_count: row
487                        .exercise_deadline_override_distinct_count
488                        .unwrap_or(0),
489                },
490            );
491        }
492    }
493    Ok(summaries)
494}
495
496pub async fn course_instance_chapters(
497    conn: &mut PgConnection,
498    course_instance_id: Uuid,
499) -> ModelResult<Vec<DatabaseChapter>> {
500    let chapters = sqlx::query_as!(
501        DatabaseChapter,
502        r#"
503SELECT id,
504  created_at,
505  updated_at,
506  name,
507  color,
508  course_id,
509  deleted_at,
510  chapter_image_path,
511  chapter_number,
512  front_page_id,
513  opens_at,
514  copied_from,
515  deadline,
516  course_module_id
517FROM chapters
518WHERE course_id = (SELECT course_id FROM course_instances WHERE id = $1)
519  AND deleted_at IS NULL;
520"#,
521        course_instance_id
522    )
523    .fetch_all(conn)
524    .await?;
525    Ok(chapters)
526}
527
528pub async fn delete_chapter(
529    conn: &mut PgConnection,
530    chapter_id: Uuid,
531) -> ModelResult<DatabaseChapter> {
532    let mut tx = conn.begin().await?;
533    let deleted = sqlx::query_as!(
534        DatabaseChapter,
535        r#"
536UPDATE chapters
537SET deleted_at = now()
538WHERE id = $1
539AND deleted_at IS NULL
540RETURNING *;
541"#,
542        chapter_id
543    )
544    .fetch_one(&mut *tx)
545    .await?;
546    // We'll also delete all the pages and exercises so that they don't conflict with future chapters
547    sqlx::query!(
548        "UPDATE pages SET deleted_at = now() WHERE chapter_id = $1 AND deleted_at IS NULL;",
549        chapter_id
550    )
551    .execute(&mut *tx)
552    .await?;
553    // Covers exercises by page_id too, not just chapter_id, in case the two ever disagree.
554    sqlx::query!(
555        "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 deleted_at IS NULL AND (chapter_id = $1 OR page_id IN (SELECT id FROM pages WHERE chapter_id = $1))));",
556        chapter_id
557    )
558    .execute(&mut *tx).await?;
559    sqlx::query!(
560        "UPDATE exercise_slides SET deleted_at = now() WHERE deleted_at IS NULL AND exercise_id IN (SELECT id FROM exercises WHERE deleted_at IS NULL AND (chapter_id = $1 OR page_id IN (SELECT id FROM pages WHERE chapter_id = $1)));",
561        chapter_id
562    )
563    .execute(&mut *tx).await?;
564    sqlx::query!(
565        "UPDATE exercises SET deleted_at = now() WHERE deleted_at IS NULL AND (chapter_id = $1 OR page_id IN (SELECT id FROM pages WHERE chapter_id = $1));",
566        chapter_id
567    )
568    .execute(&mut *tx)
569    .await?;
570    tx.commit().await?;
571    Ok(deleted)
572}
573
574pub async fn get_user_course_instance_chapter_progress(
575    conn: &mut PgConnection,
576    course_instance_id: Uuid,
577    chapter_id: Uuid,
578    user_id: Uuid,
579) -> ModelResult<UserCourseInstanceChapterProgress> {
580    let course_instance =
581        crate::course_instances::get_course_instance(conn, course_instance_id).await?;
582    let mut exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
583
584    let exercise_ids: Vec<Uuid> = exercises.iter_mut().map(|e| e.id).collect();
585    let score_maximum: i32 = exercises.into_iter().map(|e| e.score_maximum).sum();
586
587    let user_chapter_metrics = crate::user_exercise_states::get_user_course_chapter_metrics(
588        conn,
589        course_instance.course_id,
590        &exercise_ids,
591        user_id,
592    )
593    .await?;
594
595    let result = UserCourseInstanceChapterProgress {
596        score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
597            user_chapter_metrics.score_given,
598        ),
599        score_maximum,
600        total_exercises: Some(TryInto::try_into(exercise_ids.len())).transpose()?,
601        attempted_exercises: user_chapter_metrics
602            .attempted_exercises
603            .map(TryInto::try_into)
604            .transpose()?,
605    };
606    Ok(result)
607}
608
609pub async fn get_chapter_by_page_id(
610    conn: &mut PgConnection,
611    page_id: Uuid,
612) -> ModelResult<DatabaseChapter> {
613    let chapter = sqlx::query_as!(
614        DatabaseChapter,
615        "
616SELECT c.*
617FROM chapters c,
618  pages p
619WHERE c.id = p.chapter_id
620  AND p.id = $1
621  AND c.deleted_at IS NULL
622    ",
623        page_id
624    )
625    .fetch_one(conn)
626    .await?;
627
628    Ok(chapter)
629}
630
631pub async fn get_chapter_info_by_page_metadata(
632    conn: &mut PgConnection,
633    current_page_metadata: &PageMetadata,
634) -> ModelResult<ChapterInfo> {
635    let chapter_page = sqlx::query_as!(
636        ChapterInfo,
637        "
638        SELECT
639            c.id as chapter_id,
640            c.name as chapter_name,
641            c.front_page_id as chapter_front_page_id
642        FROM chapters c
643        WHERE c.id = $1
644        AND c.course_id = $2
645            AND c.deleted_at IS NULL;
646        ",
647        current_page_metadata.chapter_id,
648        current_page_metadata.course_id
649    )
650    .fetch_one(conn)
651    .await?;
652
653    Ok(chapter_page)
654}
655
656pub async fn set_module(
657    conn: &mut PgConnection,
658    chapter_id: Uuid,
659    module_id: Uuid,
660) -> ModelResult<()> {
661    sqlx::query!(
662        "
663UPDATE chapters
664SET course_module_id = $2
665WHERE id = $1
666",
667        chapter_id,
668        module_id
669    )
670    .execute(conn)
671    .await?;
672    Ok(())
673}
674
675pub async fn get_for_module(conn: &mut PgConnection, module_id: Uuid) -> ModelResult<Vec<Uuid>> {
676    let res = sqlx::query!(
677        "
678SELECT id
679FROM chapters
680WHERE course_module_id = $1
681AND deleted_at IS NULL
682",
683        module_id
684    )
685    .map(|c| c.id)
686    .fetch_all(conn)
687    .await?;
688    Ok(res)
689}
690
691#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
692
693pub struct UserChapterProgress {
694    pub user_id: Uuid,
695    pub chapter_id: Uuid,
696    pub chapter_number: i32,
697    pub chapter_name: String,
698    pub points_obtained: f64,
699    pub exercises_attempted: i64,
700}
701
702#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
703
704pub struct ChapterAvailability {
705    pub chapter_id: Uuid,
706    pub chapter_number: i32,
707    pub chapter_name: String,
708    pub exercises_available: i64,
709    pub points_available: i64,
710}
711
712#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
713
714pub struct CourseUserInfo {
715    pub first_name: Option<String>,
716    pub last_name: Option<String>,
717    pub user_id: Uuid,
718    pub email: Option<String>,
719    pub course_instance: Option<String>,
720}
721
722/// Per-user chapter progress for a course. When `user_ids` is `Some`, only those users are included;
723/// when `None`, every user with progress in the course is returned.
724pub async fn fetch_user_chapter_progress(
725    conn: &mut PgConnection,
726    course_id: Uuid,
727    user_ids: Option<&[Uuid]>,
728) -> ModelResult<Vec<UserChapterProgress>> {
729    let rows = sqlx::query_as!(
730        UserChapterProgress,
731        r#"
732WITH base AS (
733  SELECT ues.user_id,
734    ex.chapter_id,
735    ues.exercise_id,
736    COALESCE(ues.score_given, 0)::double precision AS points
737  FROM user_exercise_states ues
738    JOIN exercises ex ON ex.id = ues.exercise_id
739  WHERE ues.course_id = $1
740    AND ($2::uuid[] IS NULL OR ues.user_id = ANY($2::uuid[]))
741    AND ues.deleted_at IS NULL
742    AND ex.deleted_at IS NULL
743)
744SELECT b.user_id AS user_id,
745  c.id AS chapter_id,
746  c.chapter_number AS chapter_number,
747  c.name AS chapter_name,
748  COALESCE(SUM(b.points), 0)::double precision AS "points_obtained!",
749  COALESCE(COUNT(DISTINCT b.exercise_id), 0)::bigint AS "exercises_attempted!"
750FROM base b
751  JOIN chapters c ON c.id = b.chapter_id
752GROUP BY b.user_id,
753  c.id,
754  c.chapter_number,
755  c.name
756ORDER BY b.user_id,
757  c.chapter_number
758        "#,
759        course_id,
760        user_ids as Option<&[Uuid]>
761    )
762    .fetch_all(&mut *conn)
763    .await?;
764
765    Ok(rows)
766}
767
768pub async fn fetch_chapter_availability(
769    conn: &mut PgConnection,
770    course_id: Uuid,
771) -> ModelResult<Vec<ChapterAvailability>> {
772    let rows = sqlx::query_as!(
773        ChapterAvailability,
774        r#"
775SELECT c.id AS chapter_id,
776  c.chapter_number AS chapter_number,
777  c.name AS chapter_name,
778  COALESCE(COUNT(ex.id), 0)::bigint AS "exercises_available!",
779  COALESCE(COUNT(ex.id), 0)::bigint AS "points_available!"
780FROM chapters c
781  JOIN exercises ex ON ex.chapter_id = c.id
782WHERE c.course_id = $1
783  AND c.deleted_at IS NULL
784  AND ex.deleted_at IS NULL
785GROUP BY c.id,
786  c.chapter_number,
787  c.name
788ORDER BY c.chapter_number
789        "#,
790        course_id
791    )
792    .fetch_all(conn)
793    .await?;
794
795    Ok(rows)
796}
797
798pub async fn fetch_course_users(
799    conn: &mut PgConnection,
800    course_id: Uuid,
801) -> ModelResult<Vec<CourseUserInfo>> {
802    let rows_raw = sqlx::query!(
803        r#"
804    SELECT
805        ud.first_name,
806        ud.last_name,
807        u.id AS user_id,
808        ud.email AS "email?",
809        ci.name AS "course_instance?"
810    FROM course_instance_enrollments AS cie
811    JOIN users              AS u  ON u.id = cie.user_id
812    LEFT JOIN user_details  AS ud ON ud.user_id = u.id
813    JOIN course_instances   AS ci ON ci.id = cie.course_instance_id
814    WHERE cie.course_id = $1
815        AND cie.deleted_at IS NULL
816    ORDER BY 1, user_id
817    "#,
818        course_id
819    )
820    .fetch_all(conn)
821    .await?;
822
823    let rows = rows_raw
824        .into_iter()
825        .map(|r| {
826            let first_name = r
827                .first_name
828                .map(|f| f.trim().to_string())
829                .filter(|f| !f.is_empty());
830            let last_name = r
831                .last_name
832                .map(|l| l.trim().to_string())
833                .filter(|l| !l.is_empty());
834
835            CourseUserInfo {
836                first_name,
837                last_name,
838                user_id: r.user_id,
839                email: r.email,
840                course_instance: r.course_instance,
841            }
842        })
843        .collect();
844
845    Ok(rows)
846}
847
848#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
849
850pub struct UnreturnedExercise {
851    pub id: Uuid,
852    pub name: String,
853}
854
855#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
856
857pub struct ChapterLockPreview {
858    pub has_unreturned_exercises: bool,
859    pub unreturned_exercises_count: i32,
860    pub unreturned_exercises: Vec<UnreturnedExercise>,
861}
862
863pub async fn get_chapter_lock_preview(
864    conn: &mut PgConnection,
865    chapter_id: Uuid,
866    user_id: Uuid,
867    course_id: Uuid,
868) -> ModelResult<ChapterLockPreview> {
869    let exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
870
871    if exercises.is_empty() {
872        return Ok(ChapterLockPreview {
873            has_unreturned_exercises: false,
874            unreturned_exercises_count: 0,
875            unreturned_exercises: Vec::new(),
876        });
877    }
878
879    let exercise_ids: Vec<Uuid> = exercises.iter().map(|e| e.id).collect();
880
881    let returned_exercise_ids =
882        crate::user_exercise_states::get_returned_exercise_ids_for_user_and_course(
883            conn,
884            &exercise_ids,
885            user_id,
886            course_id,
887        )
888        .await?;
889
890    let returned_ids: std::collections::HashSet<Uuid> = returned_exercise_ids.into_iter().collect();
891
892    let unreturned_exercises: Vec<UnreturnedExercise> = exercises
893        .into_iter()
894        .filter(|e| !returned_ids.contains(&e.id))
895        .map(|e| UnreturnedExercise {
896            id: e.id,
897            name: e.name,
898        })
899        .collect();
900
901    let count = unreturned_exercises.len() as i32;
902    let has_unreturned = count > 0;
903
904    Ok(ChapterLockPreview {
905        has_unreturned_exercises: has_unreturned,
906        unreturned_exercises_count: count,
907        unreturned_exercises,
908    })
909}
910
911pub async fn get_previous_chapters_in_module(
912    conn: &mut PgConnection,
913    chapter_id: Uuid,
914) -> ModelResult<Vec<DatabaseChapter>> {
915    let chapter = get_chapter(conn, chapter_id).await?;
916    let previous_chapters = sqlx::query_as!(
917        DatabaseChapter,
918        r#"
919SELECT *
920FROM chapters
921WHERE course_module_id = $1
922  AND chapter_number < $2
923  AND deleted_at IS NULL
924ORDER BY chapter_number ASC
925        "#,
926        chapter.course_module_id,
927        chapter.chapter_number
928    )
929    .fetch_all(conn)
930    .await?;
931    Ok(previous_chapters)
932}
933
934pub async fn move_chapter_exercises_to_manual_review(
935    conn: &mut PgConnection,
936    chapter_id: Uuid,
937    user_id: Uuid,
938    course_id: Uuid,
939) -> ModelResult<()> {
940    let exercises = exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
941
942    // Same predicate as the repair in migration 20260806122511, so a locked state and a repaired
943    // one classify an answer identically.
944    let exercise_ids: Vec<Uuid> = exercises.iter().map(|e| e.id).collect();
945    let answered_ids: std::collections::HashSet<Uuid> =
946        exercise_slide_submissions::get_exercise_ids_with_submissions_for_user(
947            conn,
948            &exercise_ids,
949            user_id,
950            course_id,
951        )
952        .await?
953        .into_iter()
954        .collect();
955
956    for exercise in exercises {
957        let user_exercise_state_result =
958            user_exercise_states::get_users_current_by_exercise(conn, user_id, &exercise).await;
959
960        let user_exercise_state = match user_exercise_state_result {
961            Ok(state) => state,
962            Err(e) => {
963                if matches!(
964                    e.error_type(),
965                    ModelErrorType::PreconditionFailed | ModelErrorType::RecordNotFound
966                ) {
967                    continue;
968                }
969                return Err(e);
970            }
971        };
972        if user_exercise_state.reviewing_stage == ReviewingStage::WaitingForManualGrading
973            || user_exercise_state.reviewing_stage == ReviewingStage::ReviewedAndLocked
974            || user_exercise_state.reviewing_stage == ReviewingStage::Locked
975            || user_exercise_state.selected_exercise_slide_id.is_none()
976        {
977            continue;
978        }
979
980        if !answered_ids.contains(&exercise.id) {
981            user_exercise_states::update_reviewing_stage(
982                conn,
983                user_id,
984                CourseOrExamId::Course(course_id),
985                exercise.id,
986                ReviewingStage::NotAnsweredAndLocked,
987            )
988            .await?;
989            continue;
990        }
991
992        if exercise.needs_peer_review || exercise.needs_self_review {
993            user_exercise_states::update_reviewing_stage(
994                conn,
995                user_id,
996                CourseOrExamId::Course(course_id),
997                exercise.id,
998                ReviewingStage::WaitingForManualGrading,
999            )
1000            .await?;
1001            continue;
1002        }
1003
1004        if !exercise.teacher_reviews_answer_after_locking
1005            && user_exercise_state.grading_progress == GradingProgress::FullyGraded
1006        {
1007            user_exercise_states::update_reviewing_stage(
1008                conn,
1009                user_id,
1010                CourseOrExamId::Course(course_id),
1011                exercise.id,
1012                ReviewingStage::Locked,
1013            )
1014            .await?;
1015            user_exercise_state_updater::update_user_exercise_state(conn, user_exercise_state.id)
1016                .await?;
1017            continue;
1018        }
1019
1020        user_exercise_states::update_reviewing_stage(
1021            conn,
1022            user_id,
1023            CourseOrExamId::Course(course_id),
1024            exercise.id,
1025            ReviewingStage::WaitingForManualGrading,
1026        )
1027        .await?;
1028    }
1029
1030    Ok(())
1031}
1032
1033/// Unlocks the first chapter(s) with exercises in the base module (order_number == 0) for a user.
1034/// Also unlocks any chapters without exercises that come before the first chapter with exercises.
1035pub async fn unlock_first_chapters_for_user(
1036    conn: &mut PgConnection,
1037    user_id: Uuid,
1038    course_id: Uuid,
1039) -> ModelResult<Vec<Uuid>> {
1040    use crate::{course_modules, exercises, user_chapter_locking_statuses};
1041
1042    let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
1043    let base_module = all_modules
1044        .into_iter()
1045        .find(|m| m.order_number == 0)
1046        .ok_or_else(|| {
1047            ModelError::new(
1048                ModelErrorType::NotFound,
1049                "Base module not found".to_string(),
1050                None,
1051            )
1052        })?;
1053
1054    let module_chapter_ids = get_for_module(conn, base_module.id).await?;
1055    let mut module_chapters = get_course_chapters(conn, course_id)
1056        .await?
1057        .into_iter()
1058        .filter(|c| module_chapter_ids.contains(&c.id))
1059        .collect::<Vec<_>>();
1060    module_chapters.sort_by_key(|c| c.chapter_number);
1061
1062    let mut chapters_to_unlock = Vec::new();
1063
1064    for chapter in &module_chapters {
1065        let exercises = exercises::get_exercises_by_chapter_id(conn, chapter.id).await?;
1066        let has_exercises = !exercises.is_empty();
1067
1068        if has_exercises {
1069            chapters_to_unlock.push(chapter.id);
1070            break;
1071        } else {
1072            chapters_to_unlock.push(chapter.id);
1073        }
1074    }
1075
1076    for chapter_id in &chapters_to_unlock {
1077        user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1078            .await?;
1079    }
1080
1081    Ok(chapters_to_unlock)
1082}
1083
1084/// Unlocks the next chapter(s) for a user after they complete a chapter.
1085/// If the completed chapter is the last in a base module (order_number == 0), unlocks the first chapter
1086/// of all additional modules (order_number != 0). Otherwise, unlocks the next chapter in the same module.
1087/// Note: If a module has no chapters with exercises, all chapters in that module will be unlocked.
1088/// This is intentional to allow progression through content-only chapters.
1089pub async fn unlock_next_chapters_for_user(
1090    conn: &mut PgConnection,
1091    user_id: Uuid,
1092    chapter_id: Uuid,
1093    course_id: Uuid,
1094) -> ModelResult<Vec<Uuid>> {
1095    use crate::{course_modules, exercises, user_chapter_locking_statuses};
1096
1097    let completed_chapter = get_chapter(conn, chapter_id).await?;
1098    let module = course_modules::get_by_id(conn, completed_chapter.course_module_id).await?;
1099
1100    let module_chapters = get_for_module(conn, completed_chapter.course_module_id).await?;
1101    let mut all_module_chapters = get_course_chapters(conn, course_id)
1102        .await?
1103        .into_iter()
1104        .filter(|c| module_chapters.contains(&c.id))
1105        .collect::<Vec<_>>();
1106    all_module_chapters.sort_by_key(|c| c.chapter_number);
1107
1108    let mut chapters_to_unlock = Vec::new();
1109
1110    let is_base_module = module.order_number == 0;
1111
1112    let course = courses::get_course(conn, course_id).await?;
1113    let mut all_module_chapters_completed = true;
1114    for chapter in &all_module_chapters {
1115        let status = user_chapter_locking_statuses::get_or_init_status(
1116            conn,
1117            user_id,
1118            chapter.id,
1119            Some(course_id),
1120            Some(course.chapter_locking_enabled),
1121        )
1122        .await?;
1123        if !matches!(
1124            status,
1125            Some(user_chapter_locking_statuses::ChapterLockingStatus::CompletedAndLocked)
1126        ) {
1127            all_module_chapters_completed = false;
1128            break;
1129        }
1130    }
1131
1132    if is_base_module && all_module_chapters_completed {
1133        let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
1134        let additional_modules: Vec<_> = all_modules
1135            .into_iter()
1136            .filter(|m| m.order_number != 0)
1137            .collect();
1138
1139        let mut all_additional_module_chapter_ids = Vec::new();
1140        for additional_module in &additional_modules {
1141            let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1142            all_additional_module_chapter_ids.extend(module_chapter_ids);
1143        }
1144
1145        let all_exercises = if !all_additional_module_chapter_ids.is_empty() {
1146            exercises::get_exercises_by_chapter_ids(conn, &all_additional_module_chapter_ids)
1147                .await?
1148        } else {
1149            Vec::new()
1150        };
1151
1152        let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1153            .into_iter()
1154            .fold(std::collections::HashMap::new(), |mut acc, ex| {
1155                if let Some(ch_id) = ex.chapter_id {
1156                    acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1157                }
1158                acc
1159            });
1160
1161        for additional_module in additional_modules {
1162            let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1163            let mut module_chapters = get_course_chapters(conn, course_id)
1164                .await?
1165                .into_iter()
1166                .filter(|c| module_chapter_ids.contains(&c.id))
1167                .collect::<Vec<_>>();
1168            module_chapters.sort_by_key(|c| c.chapter_number);
1169
1170            for chapter in &module_chapters {
1171                let has_exercises = exercises_by_chapter
1172                    .get(&chapter.id)
1173                    .map(|exs| !exs.is_empty())
1174                    .unwrap_or(false);
1175
1176                if has_exercises {
1177                    chapters_to_unlock.push(chapter.id);
1178                    break;
1179                } else {
1180                    chapters_to_unlock.push(chapter.id);
1181                }
1182            }
1183        }
1184    } else {
1185        let module_chapter_ids = get_for_module(conn, completed_chapter.course_module_id).await?;
1186        let mut module_chapters = get_course_chapters(conn, course_id)
1187            .await?
1188            .into_iter()
1189            .filter(|c| module_chapter_ids.contains(&c.id))
1190            .collect::<Vec<_>>();
1191        module_chapters.sort_by_key(|c| c.chapter_number);
1192        let mut found_completed = false;
1193        let mut candidate_chapter_ids = Vec::new();
1194
1195        for chapter in &module_chapters {
1196            if chapter.id == completed_chapter.id {
1197                found_completed = true;
1198                continue;
1199            }
1200
1201            if !found_completed {
1202                continue;
1203            }
1204
1205            candidate_chapter_ids.push(chapter.id);
1206        }
1207
1208        let all_exercises = if !candidate_chapter_ids.is_empty() {
1209            exercises::get_exercises_by_chapter_ids(conn, &candidate_chapter_ids).await?
1210        } else {
1211            Vec::new()
1212        };
1213
1214        let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1215            .into_iter()
1216            .fold(std::collections::HashMap::new(), |mut acc, ex| {
1217                if let Some(ch_id) = ex.chapter_id {
1218                    acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1219                }
1220                acc
1221            });
1222
1223        for chapter_id in candidate_chapter_ids {
1224            let has_exercises = exercises_by_chapter
1225                .get(&chapter_id)
1226                .map(|exs| !exs.is_empty())
1227                .unwrap_or(false);
1228
1229            if has_exercises {
1230                chapters_to_unlock.push(chapter_id);
1231                break;
1232            } else {
1233                chapters_to_unlock.push(chapter_id);
1234            }
1235        }
1236    }
1237
1238    for chapter_id in &chapters_to_unlock {
1239        user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1240            .await?;
1241    }
1242
1243    Ok(chapters_to_unlock)
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248    use super::*;
1249
1250    mod move_chapter_exercises_to_manual_review {
1251        use super::*;
1252        use crate::{
1253            exercises::ActivityProgress,
1254            test_helper::*,
1255            user_exercise_slide_states,
1256            user_exercise_states::{self, UserExerciseStateUpdate},
1257        };
1258
1259        #[tokio::test]
1260        async fn fully_graded_auto_review_exercise_becomes_locked() {
1261            insert_data!(
1262                :tx,
1263                :user,
1264                :org,
1265                :course,
1266                instance: _instance,
1267                :course_module,
1268                :chapter,
1269                :page,
1270                :exercise,
1271                :slide
1272            );
1273
1274            exercises::update_teacher_reviews_answer_after_locking(tx.as_mut(), exercise, false)
1275                .await
1276                .unwrap();
1277
1278            user_exercise_states::upsert_selected_exercise_slide_id(
1279                tx.as_mut(),
1280                user,
1281                exercise,
1282                Some(course),
1283                None,
1284                Some(slide),
1285            )
1286            .await
1287            .unwrap();
1288
1289            let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
1290                tx.as_mut(),
1291                user,
1292                exercise,
1293                Some(course),
1294                None,
1295            )
1296            .await
1297            .unwrap();
1298
1299            exercise_slide_submissions::insert_exercise_slide_submission(
1300                tx.as_mut(),
1301                exercise_slide_submissions::NewExerciseSlideSubmission {
1302                    exercise_slide_id: slide,
1303                    course_id: Some(course),
1304                    exam_id: None,
1305                    user_id: user,
1306                    exercise_id: exercise,
1307                    user_points_update_strategy:
1308                        crate::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
1309                },
1310            )
1311            .await
1312            .unwrap();
1313
1314            let user_exercise_slide_state =
1315                user_exercise_slide_states::get_or_insert_by_unique_index(
1316                    tx.as_mut(),
1317                    user_exercise_state.id,
1318                    slide,
1319                )
1320                .await
1321                .unwrap();
1322            user_exercise_slide_states::update(
1323                tx.as_mut(),
1324                user_exercise_slide_state.id,
1325                Some(1.0),
1326                GradingProgress::FullyGraded,
1327            )
1328            .await
1329            .unwrap();
1330
1331            user_exercise_states::update(
1332                tx.as_mut(),
1333                UserExerciseStateUpdate {
1334                    id: user_exercise_state.id,
1335                    score_given: Some(1.0),
1336                    activity_progress: ActivityProgress::Completed,
1337                    reviewing_stage: ReviewingStage::NotStarted,
1338                    grading_progress: GradingProgress::FullyGraded,
1339                },
1340            )
1341            .await
1342            .unwrap();
1343
1344            move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1345                .await
1346                .unwrap();
1347
1348            let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1349            let user_exercise_state =
1350                user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1351                    .await
1352                    .unwrap();
1353            assert_eq!(user_exercise_state.reviewing_stage, ReviewingStage::Locked);
1354        }
1355
1356        #[tokio::test]
1357        async fn never_returned_exercise_becomes_not_answered_and_locked() {
1358            insert_data!(
1359                :tx,
1360                :user,
1361                :org,
1362                :course,
1363                instance: _instance,
1364                :course_module,
1365                :chapter,
1366                :page,
1367                :exercise,
1368                :slide
1369            );
1370
1371            // A selected slide with no submission is what merely viewing an exercise leaves behind.
1372            user_exercise_states::upsert_selected_exercise_slide_id(
1373                tx.as_mut(),
1374                user,
1375                exercise,
1376                Some(course),
1377                None,
1378                Some(slide),
1379            )
1380            .await
1381            .unwrap();
1382
1383            user_exercise_states::get_or_create_user_exercise_state(
1384                tx.as_mut(),
1385                user,
1386                exercise,
1387                Some(course),
1388                None,
1389            )
1390            .await
1391            .unwrap();
1392
1393            move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1394                .await
1395                .unwrap();
1396
1397            let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1398            let user_exercise_state =
1399                user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1400                    .await
1401                    .unwrap();
1402            assert_eq!(
1403                user_exercise_state.reviewing_stage,
1404                ReviewingStage::NotAnsweredAndLocked
1405            );
1406        }
1407
1408        #[tokio::test]
1409        async fn submitted_peer_review_exercise_still_goes_to_manual_grading() {
1410            insert_data!(
1411                :tx,
1412                :user,
1413                :org,
1414                :course,
1415                instance: _instance,
1416                :course_module,
1417                :chapter,
1418                :page,
1419                :exercise,
1420                :slide
1421            );
1422
1423            exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
1424                tx.as_mut(),
1425                exercise,
1426                true,
1427                false,
1428                false,
1429            )
1430            .await
1431            .unwrap();
1432
1433            user_exercise_states::upsert_selected_exercise_slide_id(
1434                tx.as_mut(),
1435                user,
1436                exercise,
1437                Some(course),
1438                None,
1439                Some(slide),
1440            )
1441            .await
1442            .unwrap();
1443
1444            user_exercise_states::get_or_create_user_exercise_state(
1445                tx.as_mut(),
1446                user,
1447                exercise,
1448                Some(course),
1449                None,
1450            )
1451            .await
1452            .unwrap();
1453
1454            // A submitted peer review answer sits at activity_progress InProgress until the reviews
1455            // are done, so answered-ness has to be decided by the submission, not that column.
1456            exercise_slide_submissions::insert_exercise_slide_submission(
1457                tx.as_mut(),
1458                exercise_slide_submissions::NewExerciseSlideSubmission {
1459                    exercise_slide_id: slide,
1460                    course_id: Some(course),
1461                    exam_id: None,
1462                    user_id: user,
1463                    exercise_id: exercise,
1464                    user_points_update_strategy:
1465                        crate::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
1466                },
1467            )
1468            .await
1469            .unwrap();
1470
1471            move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1472                .await
1473                .unwrap();
1474
1475            let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1476            let user_exercise_state =
1477                user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1478                    .await
1479                    .unwrap();
1480            assert_eq!(
1481                user_exercise_state.reviewing_stage,
1482                ReviewingStage::WaitingForManualGrading
1483            );
1484        }
1485    }
1486
1487    mod constraints {
1488        use super::*;
1489        use crate::{courses::NewCourse, library, test_helper::*};
1490
1491        #[tokio::test]
1492        async fn cannot_create_chapter_for_different_course_than_its_module() {
1493            let app_config =
1494                init_app_conf().expect("Application Configuration initialization failed");
1495            insert_data!(:tx, :user, :org, course: course_1, instance: _instance, :course_module);
1496            let course_2 = library::content_management::create_new_course(
1497                tx.as_mut(),
1498                &app_config,
1499                PKeyPolicy::Generate,
1500                NewCourse {
1501                    name: "".to_string(),
1502                    slug: "course-2".to_string(),
1503                    organization_id: org,
1504                    language_code: "en".to_string(),
1505                    teacher_in_charge_name: "Teacher".to_string(),
1506                    teacher_in_charge_email: "teacher@example.com".to_string(),
1507                    description: "".to_string(),
1508                    is_draft: false,
1509                    is_test_mode: false,
1510                    is_unlisted: false,
1511                    copy_user_permissions: false,
1512                    is_joinable_by_code_only: false,
1513                    join_code: None,
1514                    ask_marketing_consent: false,
1515                    flagged_answers_threshold: Some(3),
1516                    can_add_chatbot: false,
1517                },
1518                user,
1519                |_, _, _| unimplemented!(),
1520                |_| unimplemented!(),
1521            )
1522            .await
1523            .unwrap()
1524            .0
1525            .id;
1526            let chapter_result_2 = insert(
1527                tx.as_mut(),
1528                PKeyPolicy::Generate,
1529                &NewChapter {
1530                    name: "Chapter of second course".to_string(),
1531                    color: None,
1532                    course_id: course_2,
1533                    chapter_number: 0,
1534                    front_page_id: None,
1535                    opens_at: None,
1536                    deadline: None,
1537                    course_module_id: Some(course_module.id),
1538                },
1539            )
1540            .await;
1541            assert!(
1542                chapter_result_2.is_err(),
1543                "Expected chapter creation to fail when course module belongs to a different course."
1544            );
1545        }
1546    }
1547}