Skip to main content

headless_lms_models/
chapters.rs

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