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#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
327
328pub struct ChapterWithStatus {
329    pub id: Uuid,
330    pub created_at: DateTime<Utc>,
331    pub updated_at: DateTime<Utc>,
332    pub name: String,
333    pub color: Option<String>,
334    pub course_id: Uuid,
335    pub deleted_at: Option<DateTime<Utc>>,
336    pub chapter_number: i32,
337    pub front_page_id: Option<Uuid>,
338    pub opens_at: Option<DateTime<Utc>>,
339    pub deadline: Option<DateTime<Utc>>,
340    pub status: ChapterStatus,
341    pub chapter_image_url: Option<String>,
342    pub course_module_id: Uuid,
343    pub exercise_deadline_override_count: i64,
344    pub exercise_deadline_override_distinct_count: i64,
345    pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
346}
347
348#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, Default)]
349
350pub struct ChapterExerciseDeadlineOverrideSummary {
351    pub earliest_exercise_deadline_override: Option<DateTime<Utc>>,
352    pub exercise_deadline_override_count: i64,
353    pub exercise_deadline_override_distinct_count: i64,
354}
355
356impl ChapterWithStatus {
357    pub fn from_database_chapter_timestamp_and_image_url(
358        database_chapter: DatabaseChapter,
359        timestamp: DateTime<Utc>,
360        chapter_image_url: Option<String>,
361        exercise_deadline_overrides: Option<ChapterExerciseDeadlineOverrideSummary>,
362    ) -> Self {
363        let open = database_chapter
364            .opens_at
365            .map(|o| o <= timestamp)
366            .unwrap_or(true);
367        let status = if open {
368            ChapterStatus::Open
369        } else {
370            ChapterStatus::Closed
371        };
372        let exercise_deadline_overrides = exercise_deadline_overrides.unwrap_or_default();
373        ChapterWithStatus {
374            id: database_chapter.id,
375            created_at: database_chapter.created_at,
376            updated_at: database_chapter.updated_at,
377            name: database_chapter.name,
378            color: database_chapter.color,
379            course_id: database_chapter.course_id,
380            deleted_at: database_chapter.deleted_at,
381            chapter_number: database_chapter.chapter_number,
382            front_page_id: database_chapter.front_page_id,
383            opens_at: database_chapter.opens_at,
384            deadline: database_chapter.deadline,
385            status,
386            chapter_image_url,
387            course_module_id: database_chapter.course_module_id,
388            exercise_deadline_override_count: exercise_deadline_overrides
389                .exercise_deadline_override_count,
390            exercise_deadline_override_distinct_count: exercise_deadline_overrides
391                .exercise_deadline_override_distinct_count,
392            earliest_exercise_deadline_override: exercise_deadline_overrides
393                .earliest_exercise_deadline_override,
394        }
395    }
396}
397
398#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, ToSchema)]
399
400pub struct UserCourseInstanceChapterProgress {
401    pub score_given: f32,
402    pub score_maximum: i32,
403    pub total_exercises: Option<u32>,
404    pub attempted_exercises: Option<u32>,
405}
406
407pub async fn get_course_chapters(
408    conn: &mut PgConnection,
409    course_id: Uuid,
410) -> ModelResult<Vec<DatabaseChapter>> {
411    let chapters = sqlx::query_as!(
412        DatabaseChapter,
413        r#"
414SELECT *
415FROM chapters
416WHERE course_id = $1
417  AND deleted_at IS NULL;
418"#,
419        course_id
420    )
421    .fetch_all(conn)
422    .await?;
423    Ok(chapters)
424}
425
426pub async fn exercise_deadline_overrides_by_chapter_for_course(
427    conn: &mut PgConnection,
428    course_id: Uuid,
429) -> ModelResult<HashMap<Uuid, ChapterExerciseDeadlineOverrideSummary>> {
430    let rows = sqlx::query!(
431        r#"
432SELECT
433  e.chapter_id,
434  MIN(COALESCE(e.deadline, c.deadline)) FILTER (
435    WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
436  ) AS earliest_exercise_deadline_override,
437  COUNT(*) FILTER (
438    WHERE e.deadline IS NOT NULL
439      AND (c.deadline IS NULL OR e.deadline <> c.deadline)
440  ) AS exercise_deadline_override_count,
441  COUNT(DISTINCT COALESCE(e.deadline, c.deadline)) FILTER (
442    WHERE COALESCE(e.deadline, c.deadline) IS NOT NULL
443  ) AS exercise_deadline_override_distinct_count
444FROM exercises e
445JOIN chapters c ON c.id = e.chapter_id
446WHERE c.course_id = $1
447  AND c.deleted_at IS NULL
448  AND e.deleted_at IS NULL
449GROUP BY e.chapter_id, c.deadline
450        "#,
451        course_id
452    )
453    .fetch_all(conn)
454    .await?;
455
456    let mut summaries = HashMap::new();
457    for row in rows {
458        if let Some(chapter_id) = row.chapter_id {
459            summaries.insert(
460                chapter_id,
461                ChapterExerciseDeadlineOverrideSummary {
462                    earliest_exercise_deadline_override: row.earliest_exercise_deadline_override,
463                    exercise_deadline_override_count: row
464                        .exercise_deadline_override_count
465                        .unwrap_or(0),
466                    exercise_deadline_override_distinct_count: row
467                        .exercise_deadline_override_distinct_count
468                        .unwrap_or(0),
469                },
470            );
471        }
472    }
473    Ok(summaries)
474}
475
476pub async fn course_instance_chapters(
477    conn: &mut PgConnection,
478    course_instance_id: Uuid,
479) -> ModelResult<Vec<DatabaseChapter>> {
480    let chapters = sqlx::query_as!(
481        DatabaseChapter,
482        r#"
483SELECT id,
484  created_at,
485  updated_at,
486  name,
487  color,
488  course_id,
489  deleted_at,
490  chapter_image_path,
491  chapter_number,
492  front_page_id,
493  opens_at,
494  copied_from,
495  deadline,
496  course_module_id
497FROM chapters
498WHERE course_id = (SELECT course_id FROM course_instances WHERE id = $1)
499  AND deleted_at IS NULL;
500"#,
501        course_instance_id
502    )
503    .fetch_all(conn)
504    .await?;
505    Ok(chapters)
506}
507
508pub async fn delete_chapter(
509    conn: &mut PgConnection,
510    chapter_id: Uuid,
511) -> ModelResult<DatabaseChapter> {
512    let mut tx = conn.begin().await?;
513    let deleted = sqlx::query_as!(
514        DatabaseChapter,
515        r#"
516UPDATE chapters
517SET deleted_at = now()
518WHERE id = $1
519AND deleted_at IS NULL
520RETURNING *;
521"#,
522        chapter_id
523    )
524    .fetch_one(&mut *tx)
525    .await?;
526    // We'll also delete all the pages and exercises so that they don't conflict with future chapters
527    sqlx::query!(
528        "UPDATE pages SET deleted_at = now() WHERE chapter_id = $1 AND deleted_at IS NULL;",
529        chapter_id
530    )
531    .execute(&mut *tx)
532    .await?;
533    sqlx::query!(
534        "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));",
535        chapter_id
536    )
537    .execute(&mut *tx).await?;
538    sqlx::query!(
539        "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);",
540        chapter_id
541    )
542    .execute(&mut *tx).await?;
543    sqlx::query!(
544        "UPDATE exercises SET deleted_at = now() WHERE deleted_at IS NULL AND chapter_id = $1;",
545        chapter_id
546    )
547    .execute(&mut *tx)
548    .await?;
549    tx.commit().await?;
550    Ok(deleted)
551}
552
553pub async fn get_user_course_instance_chapter_progress(
554    conn: &mut PgConnection,
555    course_instance_id: Uuid,
556    chapter_id: Uuid,
557    user_id: Uuid,
558) -> ModelResult<UserCourseInstanceChapterProgress> {
559    let course_instance =
560        crate::course_instances::get_course_instance(conn, course_instance_id).await?;
561    let mut exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
562
563    let exercise_ids: Vec<Uuid> = exercises.iter_mut().map(|e| e.id).collect();
564    let score_maximum: i32 = exercises.into_iter().map(|e| e.score_maximum).sum();
565
566    let user_chapter_metrics = crate::user_exercise_states::get_user_course_chapter_metrics(
567        conn,
568        course_instance.course_id,
569        &exercise_ids,
570        user_id,
571    )
572    .await?;
573
574    let result = UserCourseInstanceChapterProgress {
575        score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
576            user_chapter_metrics.score_given,
577        ),
578        score_maximum,
579        total_exercises: Some(TryInto::try_into(exercise_ids.len())).transpose()?,
580        attempted_exercises: user_chapter_metrics
581            .attempted_exercises
582            .map(TryInto::try_into)
583            .transpose()?,
584    };
585    Ok(result)
586}
587
588pub async fn get_chapter_by_page_id(
589    conn: &mut PgConnection,
590    page_id: Uuid,
591) -> ModelResult<DatabaseChapter> {
592    let chapter = sqlx::query_as!(
593        DatabaseChapter,
594        "
595SELECT c.*
596FROM chapters c,
597  pages p
598WHERE c.id = p.chapter_id
599  AND p.id = $1
600  AND c.deleted_at IS NULL
601    ",
602        page_id
603    )
604    .fetch_one(conn)
605    .await?;
606
607    Ok(chapter)
608}
609
610pub async fn get_chapter_info_by_page_metadata(
611    conn: &mut PgConnection,
612    current_page_metadata: &PageMetadata,
613) -> ModelResult<ChapterInfo> {
614    let chapter_page = sqlx::query_as!(
615        ChapterInfo,
616        "
617        SELECT
618            c.id as chapter_id,
619            c.name as chapter_name,
620            c.front_page_id as chapter_front_page_id
621        FROM chapters c
622        WHERE c.id = $1
623        AND c.course_id = $2
624            AND c.deleted_at IS NULL;
625        ",
626        current_page_metadata.chapter_id,
627        current_page_metadata.course_id
628    )
629    .fetch_one(conn)
630    .await?;
631
632    Ok(chapter_page)
633}
634
635pub async fn set_module(
636    conn: &mut PgConnection,
637    chapter_id: Uuid,
638    module_id: Uuid,
639) -> ModelResult<()> {
640    sqlx::query!(
641        "
642UPDATE chapters
643SET course_module_id = $2
644WHERE id = $1
645",
646        chapter_id,
647        module_id
648    )
649    .execute(conn)
650    .await?;
651    Ok(())
652}
653
654pub async fn get_for_module(conn: &mut PgConnection, module_id: Uuid) -> ModelResult<Vec<Uuid>> {
655    let res = sqlx::query!(
656        "
657SELECT id
658FROM chapters
659WHERE course_module_id = $1
660AND deleted_at IS NULL
661",
662        module_id
663    )
664    .map(|c| c.id)
665    .fetch_all(conn)
666    .await?;
667    Ok(res)
668}
669
670#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
671
672pub struct UserChapterProgress {
673    pub user_id: Uuid,
674    pub chapter_id: Uuid,
675    pub chapter_number: i32,
676    pub chapter_name: String,
677    pub points_obtained: f64,
678    pub exercises_attempted: i64,
679}
680
681#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
682
683pub struct ChapterAvailability {
684    pub chapter_id: Uuid,
685    pub chapter_number: i32,
686    pub chapter_name: String,
687    pub exercises_available: i64,
688    pub points_available: i64,
689}
690
691#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
692
693pub struct CourseUserInfo {
694    pub first_name: Option<String>,
695    pub last_name: Option<String>,
696    pub user_id: Uuid,
697    pub email: Option<String>,
698    pub course_instance: Option<String>,
699}
700
701/// Per-user chapter progress for a course. When `user_ids` is `Some`, only those users are included;
702/// when `None`, every user with progress in the course is returned.
703pub async fn fetch_user_chapter_progress(
704    conn: &mut PgConnection,
705    course_id: Uuid,
706    user_ids: Option<&[Uuid]>,
707) -> ModelResult<Vec<UserChapterProgress>> {
708    let rows = sqlx::query_as!(
709        UserChapterProgress,
710        r#"
711WITH base AS (
712  SELECT ues.user_id,
713    ex.chapter_id,
714    ues.exercise_id,
715    COALESCE(ues.score_given, 0)::double precision AS points
716  FROM user_exercise_states ues
717    JOIN exercises ex ON ex.id = ues.exercise_id
718  WHERE ues.course_id = $1
719    AND ($2::uuid[] IS NULL OR ues.user_id = ANY($2::uuid[]))
720    AND ues.deleted_at IS NULL
721    AND ex.deleted_at IS NULL
722)
723SELECT b.user_id AS user_id,
724  c.id AS chapter_id,
725  c.chapter_number AS chapter_number,
726  c.name AS chapter_name,
727  COALESCE(SUM(b.points), 0)::double precision AS "points_obtained!",
728  COALESCE(COUNT(DISTINCT b.exercise_id), 0)::bigint AS "exercises_attempted!"
729FROM base b
730  JOIN chapters c ON c.id = b.chapter_id
731GROUP BY b.user_id,
732  c.id,
733  c.chapter_number,
734  c.name
735ORDER BY b.user_id,
736  c.chapter_number
737        "#,
738        course_id,
739        user_ids as Option<&[Uuid]>
740    )
741    .fetch_all(&mut *conn)
742    .await?;
743
744    Ok(rows)
745}
746
747pub async fn fetch_chapter_availability(
748    conn: &mut PgConnection,
749    course_id: Uuid,
750) -> ModelResult<Vec<ChapterAvailability>> {
751    let rows = sqlx::query_as!(
752        ChapterAvailability,
753        r#"
754SELECT c.id AS chapter_id,
755  c.chapter_number AS chapter_number,
756  c.name AS chapter_name,
757  COALESCE(COUNT(ex.id), 0)::bigint AS "exercises_available!",
758  COALESCE(COUNT(ex.id), 0)::bigint AS "points_available!"
759FROM chapters c
760  JOIN exercises ex ON ex.chapter_id = c.id
761WHERE c.course_id = $1
762  AND c.deleted_at IS NULL
763  AND ex.deleted_at IS NULL
764GROUP BY c.id,
765  c.chapter_number,
766  c.name
767ORDER BY c.chapter_number
768        "#,
769        course_id
770    )
771    .fetch_all(conn)
772    .await?;
773
774    Ok(rows)
775}
776
777pub async fn fetch_course_users(
778    conn: &mut PgConnection,
779    course_id: Uuid,
780) -> ModelResult<Vec<CourseUserInfo>> {
781    let rows_raw = sqlx::query!(
782        r#"
783    SELECT
784        ud.first_name,
785        ud.last_name,
786        u.id AS user_id,
787        ud.email AS "email?",
788        ci.name AS "course_instance?"
789    FROM course_instance_enrollments AS cie
790    JOIN users              AS u  ON u.id = cie.user_id
791    LEFT JOIN user_details  AS ud ON ud.user_id = u.id
792    JOIN course_instances   AS ci ON ci.id = cie.course_instance_id
793    WHERE cie.course_id = $1
794        AND cie.deleted_at IS NULL
795    ORDER BY 1, user_id
796    "#,
797        course_id
798    )
799    .fetch_all(conn)
800    .await?;
801
802    let rows = rows_raw
803        .into_iter()
804        .map(|r| {
805            let first_name = r
806                .first_name
807                .map(|f| f.trim().to_string())
808                .filter(|f| !f.is_empty());
809            let last_name = r
810                .last_name
811                .map(|l| l.trim().to_string())
812                .filter(|l| !l.is_empty());
813
814            CourseUserInfo {
815                first_name,
816                last_name,
817                user_id: r.user_id,
818                email: r.email,
819                course_instance: r.course_instance,
820            }
821        })
822        .collect();
823
824    Ok(rows)
825}
826
827#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
828
829pub struct UnreturnedExercise {
830    pub id: Uuid,
831    pub name: String,
832}
833
834#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
835
836pub struct ChapterLockPreview {
837    pub has_unreturned_exercises: bool,
838    pub unreturned_exercises_count: i32,
839    pub unreturned_exercises: Vec<UnreturnedExercise>,
840}
841
842pub async fn get_chapter_lock_preview(
843    conn: &mut PgConnection,
844    chapter_id: Uuid,
845    user_id: Uuid,
846    course_id: Uuid,
847) -> ModelResult<ChapterLockPreview> {
848    let exercises = crate::exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
849
850    if exercises.is_empty() {
851        return Ok(ChapterLockPreview {
852            has_unreturned_exercises: false,
853            unreturned_exercises_count: 0,
854            unreturned_exercises: Vec::new(),
855        });
856    }
857
858    let exercise_ids: Vec<Uuid> = exercises.iter().map(|e| e.id).collect();
859
860    let returned_exercise_ids =
861        crate::user_exercise_states::get_returned_exercise_ids_for_user_and_course(
862            conn,
863            &exercise_ids,
864            user_id,
865            course_id,
866        )
867        .await?;
868
869    let returned_ids: std::collections::HashSet<Uuid> = returned_exercise_ids.into_iter().collect();
870
871    let unreturned_exercises: Vec<UnreturnedExercise> = exercises
872        .into_iter()
873        .filter(|e| !returned_ids.contains(&e.id))
874        .map(|e| UnreturnedExercise {
875            id: e.id,
876            name: e.name,
877        })
878        .collect();
879
880    let count = unreturned_exercises.len() as i32;
881    let has_unreturned = count > 0;
882
883    Ok(ChapterLockPreview {
884        has_unreturned_exercises: has_unreturned,
885        unreturned_exercises_count: count,
886        unreturned_exercises,
887    })
888}
889
890pub async fn get_previous_chapters_in_module(
891    conn: &mut PgConnection,
892    chapter_id: Uuid,
893) -> ModelResult<Vec<DatabaseChapter>> {
894    let chapter = get_chapter(conn, chapter_id).await?;
895    let previous_chapters = sqlx::query_as!(
896        DatabaseChapter,
897        r#"
898SELECT *
899FROM chapters
900WHERE course_module_id = $1
901  AND chapter_number < $2
902  AND deleted_at IS NULL
903ORDER BY chapter_number ASC
904        "#,
905        chapter.course_module_id,
906        chapter.chapter_number
907    )
908    .fetch_all(conn)
909    .await?;
910    Ok(previous_chapters)
911}
912
913pub async fn move_chapter_exercises_to_manual_review(
914    conn: &mut PgConnection,
915    chapter_id: Uuid,
916    user_id: Uuid,
917    course_id: Uuid,
918) -> ModelResult<()> {
919    let exercises = exercises::get_exercises_by_chapter_id(conn, chapter_id).await?;
920
921    // Same predicate as the repair in migration 20260806122511, so a locked state and a repaired
922    // one classify an answer identically.
923    let exercise_ids: Vec<Uuid> = exercises.iter().map(|e| e.id).collect();
924    let answered_ids: std::collections::HashSet<Uuid> =
925        exercise_slide_submissions::get_exercise_ids_with_submissions_for_user(
926            conn,
927            &exercise_ids,
928            user_id,
929            course_id,
930        )
931        .await?
932        .into_iter()
933        .collect();
934
935    for exercise in exercises {
936        let user_exercise_state_result =
937            user_exercise_states::get_users_current_by_exercise(conn, user_id, &exercise).await;
938
939        let user_exercise_state = match user_exercise_state_result {
940            Ok(state) => state,
941            Err(e) => {
942                if matches!(
943                    e.error_type(),
944                    ModelErrorType::PreconditionFailed | ModelErrorType::RecordNotFound
945                ) {
946                    continue;
947                }
948                return Err(e);
949            }
950        };
951        if user_exercise_state.reviewing_stage == ReviewingStage::WaitingForManualGrading
952            || user_exercise_state.reviewing_stage == ReviewingStage::ReviewedAndLocked
953            || user_exercise_state.reviewing_stage == ReviewingStage::Locked
954            || user_exercise_state.selected_exercise_slide_id.is_none()
955        {
956            continue;
957        }
958
959        if !answered_ids.contains(&exercise.id) {
960            user_exercise_states::update_reviewing_stage(
961                conn,
962                user_id,
963                CourseOrExamId::Course(course_id),
964                exercise.id,
965                ReviewingStage::NotAnsweredAndLocked,
966            )
967            .await?;
968            continue;
969        }
970
971        if exercise.needs_peer_review || exercise.needs_self_review {
972            user_exercise_states::update_reviewing_stage(
973                conn,
974                user_id,
975                CourseOrExamId::Course(course_id),
976                exercise.id,
977                ReviewingStage::WaitingForManualGrading,
978            )
979            .await?;
980            continue;
981        }
982
983        if !exercise.teacher_reviews_answer_after_locking
984            && user_exercise_state.grading_progress == GradingProgress::FullyGraded
985        {
986            user_exercise_states::update_reviewing_stage(
987                conn,
988                user_id,
989                CourseOrExamId::Course(course_id),
990                exercise.id,
991                ReviewingStage::Locked,
992            )
993            .await?;
994            user_exercise_state_updater::update_user_exercise_state(conn, user_exercise_state.id)
995                .await?;
996            continue;
997        }
998
999        user_exercise_states::update_reviewing_stage(
1000            conn,
1001            user_id,
1002            CourseOrExamId::Course(course_id),
1003            exercise.id,
1004            ReviewingStage::WaitingForManualGrading,
1005        )
1006        .await?;
1007    }
1008
1009    Ok(())
1010}
1011
1012/// Unlocks the first chapter(s) with exercises in the base module (order_number == 0) for a user.
1013/// Also unlocks any chapters without exercises that come before the first chapter with exercises.
1014pub async fn unlock_first_chapters_for_user(
1015    conn: &mut PgConnection,
1016    user_id: Uuid,
1017    course_id: Uuid,
1018) -> ModelResult<Vec<Uuid>> {
1019    use crate::{course_modules, exercises, user_chapter_locking_statuses};
1020
1021    let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
1022    let base_module = all_modules
1023        .into_iter()
1024        .find(|m| m.order_number == 0)
1025        .ok_or_else(|| {
1026            ModelError::new(
1027                ModelErrorType::NotFound,
1028                "Base module not found".to_string(),
1029                None,
1030            )
1031        })?;
1032
1033    let module_chapter_ids = get_for_module(conn, base_module.id).await?;
1034    let mut module_chapters = get_course_chapters(conn, course_id)
1035        .await?
1036        .into_iter()
1037        .filter(|c| module_chapter_ids.contains(&c.id))
1038        .collect::<Vec<_>>();
1039    module_chapters.sort_by_key(|c| c.chapter_number);
1040
1041    let mut chapters_to_unlock = Vec::new();
1042
1043    for chapter in &module_chapters {
1044        let exercises = exercises::get_exercises_by_chapter_id(conn, chapter.id).await?;
1045        let has_exercises = !exercises.is_empty();
1046
1047        if has_exercises {
1048            chapters_to_unlock.push(chapter.id);
1049            break;
1050        } else {
1051            chapters_to_unlock.push(chapter.id);
1052        }
1053    }
1054
1055    for chapter_id in &chapters_to_unlock {
1056        user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1057            .await?;
1058    }
1059
1060    Ok(chapters_to_unlock)
1061}
1062
1063/// Unlocks the next chapter(s) for a user after they complete a chapter.
1064/// If the completed chapter is the last in a base module (order_number == 0), unlocks the first chapter
1065/// of all additional modules (order_number != 0). Otherwise, unlocks the next chapter in the same module.
1066/// Note: If a module has no chapters with exercises, all chapters in that module will be unlocked.
1067/// This is intentional to allow progression through content-only chapters.
1068pub async fn unlock_next_chapters_for_user(
1069    conn: &mut PgConnection,
1070    user_id: Uuid,
1071    chapter_id: Uuid,
1072    course_id: Uuid,
1073) -> ModelResult<Vec<Uuid>> {
1074    use crate::{course_modules, exercises, user_chapter_locking_statuses};
1075
1076    let completed_chapter = get_chapter(conn, chapter_id).await?;
1077    let module = course_modules::get_by_id(conn, completed_chapter.course_module_id).await?;
1078
1079    let module_chapters = get_for_module(conn, completed_chapter.course_module_id).await?;
1080    let mut all_module_chapters = get_course_chapters(conn, course_id)
1081        .await?
1082        .into_iter()
1083        .filter(|c| module_chapters.contains(&c.id))
1084        .collect::<Vec<_>>();
1085    all_module_chapters.sort_by_key(|c| c.chapter_number);
1086
1087    let mut chapters_to_unlock = Vec::new();
1088
1089    let is_base_module = module.order_number == 0;
1090
1091    let course = courses::get_course(conn, course_id).await?;
1092    let mut all_module_chapters_completed = true;
1093    for chapter in &all_module_chapters {
1094        let status = user_chapter_locking_statuses::get_or_init_status(
1095            conn,
1096            user_id,
1097            chapter.id,
1098            Some(course_id),
1099            Some(course.chapter_locking_enabled),
1100        )
1101        .await?;
1102        if !matches!(
1103            status,
1104            Some(user_chapter_locking_statuses::ChapterLockingStatus::CompletedAndLocked)
1105        ) {
1106            all_module_chapters_completed = false;
1107            break;
1108        }
1109    }
1110
1111    if is_base_module && all_module_chapters_completed {
1112        let all_modules = course_modules::get_by_course_id(conn, course_id).await?;
1113        let additional_modules: Vec<_> = all_modules
1114            .into_iter()
1115            .filter(|m| m.order_number != 0)
1116            .collect();
1117
1118        let mut all_additional_module_chapter_ids = Vec::new();
1119        for additional_module in &additional_modules {
1120            let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1121            all_additional_module_chapter_ids.extend(module_chapter_ids);
1122        }
1123
1124        let all_exercises = if !all_additional_module_chapter_ids.is_empty() {
1125            exercises::get_exercises_by_chapter_ids(conn, &all_additional_module_chapter_ids)
1126                .await?
1127        } else {
1128            Vec::new()
1129        };
1130
1131        let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1132            .into_iter()
1133            .fold(std::collections::HashMap::new(), |mut acc, ex| {
1134                if let Some(ch_id) = ex.chapter_id {
1135                    acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1136                }
1137                acc
1138            });
1139
1140        for additional_module in additional_modules {
1141            let module_chapter_ids = get_for_module(conn, additional_module.id).await?;
1142            let mut module_chapters = get_course_chapters(conn, course_id)
1143                .await?
1144                .into_iter()
1145                .filter(|c| module_chapter_ids.contains(&c.id))
1146                .collect::<Vec<_>>();
1147            module_chapters.sort_by_key(|c| c.chapter_number);
1148
1149            for chapter in &module_chapters {
1150                let has_exercises = exercises_by_chapter
1151                    .get(&chapter.id)
1152                    .map(|exs| !exs.is_empty())
1153                    .unwrap_or(false);
1154
1155                if has_exercises {
1156                    chapters_to_unlock.push(chapter.id);
1157                    break;
1158                } else {
1159                    chapters_to_unlock.push(chapter.id);
1160                }
1161            }
1162        }
1163    } else {
1164        let module_chapter_ids = get_for_module(conn, completed_chapter.course_module_id).await?;
1165        let mut module_chapters = get_course_chapters(conn, course_id)
1166            .await?
1167            .into_iter()
1168            .filter(|c| module_chapter_ids.contains(&c.id))
1169            .collect::<Vec<_>>();
1170        module_chapters.sort_by_key(|c| c.chapter_number);
1171        let mut found_completed = false;
1172        let mut candidate_chapter_ids = Vec::new();
1173
1174        for chapter in &module_chapters {
1175            if chapter.id == completed_chapter.id {
1176                found_completed = true;
1177                continue;
1178            }
1179
1180            if !found_completed {
1181                continue;
1182            }
1183
1184            candidate_chapter_ids.push(chapter.id);
1185        }
1186
1187        let all_exercises = if !candidate_chapter_ids.is_empty() {
1188            exercises::get_exercises_by_chapter_ids(conn, &candidate_chapter_ids).await?
1189        } else {
1190            Vec::new()
1191        };
1192
1193        let exercises_by_chapter: std::collections::HashMap<Uuid, Vec<_>> = all_exercises
1194            .into_iter()
1195            .fold(std::collections::HashMap::new(), |mut acc, ex| {
1196                if let Some(ch_id) = ex.chapter_id {
1197                    acc.entry(ch_id).or_insert_with(Vec::new).push(ex);
1198                }
1199                acc
1200            });
1201
1202        for chapter_id in candidate_chapter_ids {
1203            let has_exercises = exercises_by_chapter
1204                .get(&chapter_id)
1205                .map(|exs| !exs.is_empty())
1206                .unwrap_or(false);
1207
1208            if has_exercises {
1209                chapters_to_unlock.push(chapter_id);
1210                break;
1211            } else {
1212                chapters_to_unlock.push(chapter_id);
1213            }
1214        }
1215    }
1216
1217    for chapter_id in &chapters_to_unlock {
1218        user_chapter_locking_statuses::unlock_chapter(conn, user_id, *chapter_id, course_id)
1219            .await?;
1220    }
1221
1222    Ok(chapters_to_unlock)
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228
1229    mod move_chapter_exercises_to_manual_review {
1230        use super::*;
1231        use crate::{
1232            exercises::ActivityProgress,
1233            test_helper::*,
1234            user_exercise_slide_states,
1235            user_exercise_states::{self, UserExerciseStateUpdate},
1236        };
1237
1238        #[tokio::test]
1239        async fn fully_graded_auto_review_exercise_becomes_locked() {
1240            insert_data!(
1241                :tx,
1242                :user,
1243                :org,
1244                :course,
1245                instance: _instance,
1246                :course_module,
1247                :chapter,
1248                :page,
1249                :exercise,
1250                :slide
1251            );
1252
1253            exercises::update_teacher_reviews_answer_after_locking(tx.as_mut(), exercise, false)
1254                .await
1255                .unwrap();
1256
1257            user_exercise_states::upsert_selected_exercise_slide_id(
1258                tx.as_mut(),
1259                user,
1260                exercise,
1261                Some(course),
1262                None,
1263                Some(slide),
1264            )
1265            .await
1266            .unwrap();
1267
1268            let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
1269                tx.as_mut(),
1270                user,
1271                exercise,
1272                Some(course),
1273                None,
1274            )
1275            .await
1276            .unwrap();
1277
1278            exercise_slide_submissions::insert_exercise_slide_submission(
1279                tx.as_mut(),
1280                exercise_slide_submissions::NewExerciseSlideSubmission {
1281                    exercise_slide_id: slide,
1282                    course_id: Some(course),
1283                    exam_id: None,
1284                    user_id: user,
1285                    exercise_id: exercise,
1286                    user_points_update_strategy:
1287                        crate::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
1288                },
1289            )
1290            .await
1291            .unwrap();
1292
1293            let user_exercise_slide_state =
1294                user_exercise_slide_states::get_or_insert_by_unique_index(
1295                    tx.as_mut(),
1296                    user_exercise_state.id,
1297                    slide,
1298                )
1299                .await
1300                .unwrap();
1301            user_exercise_slide_states::update(
1302                tx.as_mut(),
1303                user_exercise_slide_state.id,
1304                Some(1.0),
1305                GradingProgress::FullyGraded,
1306            )
1307            .await
1308            .unwrap();
1309
1310            user_exercise_states::update(
1311                tx.as_mut(),
1312                UserExerciseStateUpdate {
1313                    id: user_exercise_state.id,
1314                    score_given: Some(1.0),
1315                    activity_progress: ActivityProgress::Completed,
1316                    reviewing_stage: ReviewingStage::NotStarted,
1317                    grading_progress: GradingProgress::FullyGraded,
1318                },
1319            )
1320            .await
1321            .unwrap();
1322
1323            move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1324                .await
1325                .unwrap();
1326
1327            let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1328            let user_exercise_state =
1329                user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1330                    .await
1331                    .unwrap();
1332            assert_eq!(user_exercise_state.reviewing_stage, ReviewingStage::Locked);
1333        }
1334
1335        #[tokio::test]
1336        async fn never_returned_exercise_becomes_not_answered_and_locked() {
1337            insert_data!(
1338                :tx,
1339                :user,
1340                :org,
1341                :course,
1342                instance: _instance,
1343                :course_module,
1344                :chapter,
1345                :page,
1346                :exercise,
1347                :slide
1348            );
1349
1350            // A selected slide with no submission is what merely viewing an exercise leaves behind.
1351            user_exercise_states::upsert_selected_exercise_slide_id(
1352                tx.as_mut(),
1353                user,
1354                exercise,
1355                Some(course),
1356                None,
1357                Some(slide),
1358            )
1359            .await
1360            .unwrap();
1361
1362            user_exercise_states::get_or_create_user_exercise_state(
1363                tx.as_mut(),
1364                user,
1365                exercise,
1366                Some(course),
1367                None,
1368            )
1369            .await
1370            .unwrap();
1371
1372            move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1373                .await
1374                .unwrap();
1375
1376            let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1377            let user_exercise_state =
1378                user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1379                    .await
1380                    .unwrap();
1381            assert_eq!(
1382                user_exercise_state.reviewing_stage,
1383                ReviewingStage::NotAnsweredAndLocked
1384            );
1385        }
1386
1387        #[tokio::test]
1388        async fn submitted_peer_review_exercise_still_goes_to_manual_grading() {
1389            insert_data!(
1390                :tx,
1391                :user,
1392                :org,
1393                :course,
1394                instance: _instance,
1395                :course_module,
1396                :chapter,
1397                :page,
1398                :exercise,
1399                :slide
1400            );
1401
1402            exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
1403                tx.as_mut(),
1404                exercise,
1405                true,
1406                false,
1407                false,
1408            )
1409            .await
1410            .unwrap();
1411
1412            user_exercise_states::upsert_selected_exercise_slide_id(
1413                tx.as_mut(),
1414                user,
1415                exercise,
1416                Some(course),
1417                None,
1418                Some(slide),
1419            )
1420            .await
1421            .unwrap();
1422
1423            user_exercise_states::get_or_create_user_exercise_state(
1424                tx.as_mut(),
1425                user,
1426                exercise,
1427                Some(course),
1428                None,
1429            )
1430            .await
1431            .unwrap();
1432
1433            // A submitted peer review answer sits at activity_progress InProgress until the reviews
1434            // are done, so answered-ness has to be decided by the submission, not that column.
1435            exercise_slide_submissions::insert_exercise_slide_submission(
1436                tx.as_mut(),
1437                exercise_slide_submissions::NewExerciseSlideSubmission {
1438                    exercise_slide_id: slide,
1439                    course_id: Some(course),
1440                    exam_id: None,
1441                    user_id: user,
1442                    exercise_id: exercise,
1443                    user_points_update_strategy:
1444                        crate::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
1445                },
1446            )
1447            .await
1448            .unwrap();
1449
1450            move_chapter_exercises_to_manual_review(tx.as_mut(), chapter, user, course)
1451                .await
1452                .unwrap();
1453
1454            let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
1455            let user_exercise_state =
1456                user_exercise_states::get_users_current_by_exercise(tx.as_mut(), user, &exercise)
1457                    .await
1458                    .unwrap();
1459            assert_eq!(
1460                user_exercise_state.reviewing_stage,
1461                ReviewingStage::WaitingForManualGrading
1462            );
1463        }
1464    }
1465
1466    mod constraints {
1467        use super::*;
1468        use crate::{courses::NewCourse, library, test_helper::*};
1469
1470        #[tokio::test]
1471        async fn cannot_create_chapter_for_different_course_than_its_module() {
1472            insert_data!(:tx, :user, :org, course: course_1, instance: _instance, :course_module);
1473            let course_2 = library::content_management::create_new_course(
1474                tx.as_mut(),
1475                PKeyPolicy::Generate,
1476                NewCourse {
1477                    name: "".to_string(),
1478                    slug: "course-2".to_string(),
1479                    organization_id: org,
1480                    language_code: "en".to_string(),
1481                    teacher_in_charge_name: "Teacher".to_string(),
1482                    teacher_in_charge_email: "teacher@example.com".to_string(),
1483                    description: "".to_string(),
1484                    is_draft: false,
1485                    is_test_mode: false,
1486                    is_unlisted: false,
1487                    copy_user_permissions: false,
1488                    is_joinable_by_code_only: false,
1489                    join_code: None,
1490                    ask_marketing_consent: false,
1491                    flagged_answers_threshold: Some(3),
1492                    can_add_chatbot: false,
1493                },
1494                user,
1495                |_, _, _| unimplemented!(),
1496                |_| unimplemented!(),
1497            )
1498            .await
1499            .unwrap()
1500            .0
1501            .id;
1502            let chapter_result_2 = insert(
1503                tx.as_mut(),
1504                PKeyPolicy::Generate,
1505                &NewChapter {
1506                    name: "Chapter of second course".to_string(),
1507                    color: None,
1508                    course_id: course_2,
1509                    chapter_number: 0,
1510                    front_page_id: None,
1511                    opens_at: None,
1512                    deadline: None,
1513                    course_module_id: Some(course_module.id),
1514                },
1515            )
1516            .await;
1517            assert!(
1518                chapter_result_2.is_err(),
1519                "Expected chapter creation to fail when course module belongs to a different course."
1520            );
1521        }
1522    }
1523}