headless_lms_models/
exercises.rs

1use derive_more::Display;
2use futures::future::BoxFuture;
3use itertools::Itertools;
4use tracing::info;
5use url::Url;
6use utoipa::ToSchema;
7
8use crate::{
9    exams, exercise_reset_logs,
10    exercise_service_info::ExerciseServiceInfoApi,
11    exercise_slide_submissions::{
12        ExerciseSlideSubmission, get_exercise_slide_submission_counts_for_exercise_user,
13    },
14    exercise_slides::{self, CourseMaterialExerciseSlide},
15    exercise_tasks,
16    peer_or_self_review_configs::CourseMaterialPeerOrSelfReviewConfig,
17    peer_or_self_review_question_submissions::PeerOrSelfReviewQuestionSubmission,
18    peer_or_self_review_questions::PeerOrSelfReviewQuestion,
19    peer_or_self_review_submissions::PeerOrSelfReviewSubmissionWithSubmissionOwner,
20    peer_review_queue_entries::PeerReviewQueueEntry,
21    prelude::*,
22    teacher_grading_decisions::{TeacherDecisionType, TeacherGradingDecision},
23    user_chapter_locking_statuses,
24    user_course_exercise_service_variables::UserCourseExerciseServiceVariable,
25    user_course_settings,
26    user_exercise_states::{self, ReviewingStage, UserExerciseState},
27};
28use std::collections::{HashMap, HashSet};
29
30#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
31
32pub struct Exercise {
33    pub id: Uuid,
34    pub created_at: DateTime<Utc>,
35    pub updated_at: DateTime<Utc>,
36    pub name: String,
37    pub course_id: Option<Uuid>,
38    pub exam_id: Option<Uuid>,
39    pub page_id: Uuid,
40    pub chapter_id: Option<Uuid>,
41    pub deadline: Option<DateTime<Utc>>,
42    pub deleted_at: Option<DateTime<Utc>>,
43    pub score_maximum: i32,
44    pub order_number: i32,
45    pub copied_from: Option<Uuid>,
46    pub max_tries_per_slide: Option<i32>,
47    pub limit_number_of_tries: bool,
48    pub needs_peer_review: bool,
49    pub needs_self_review: bool,
50    pub use_course_default_peer_or_self_review_config: bool,
51    pub exercise_language_group_id: Option<Uuid>,
52    pub teacher_reviews_answer_after_locking: bool,
53}
54
55impl Exercise {
56    pub fn get_course_id(&self) -> ModelResult<Uuid> {
57        self.course_id.ok_or_else(|| {
58            ModelError::new(
59                ModelErrorType::Generic,
60                "Exercise is not related to a course.".to_string(),
61                None,
62            )
63        })
64    }
65}
66
67#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
68
69pub struct ExerciseGradingStatus {
70    pub exercise_id: Uuid,
71    pub exercise_name: String,
72    pub score_maximum: i32,
73    pub score_given: Option<f32>,
74    pub teacher_decision: Option<TeacherDecisionType>,
75    pub submission_id: Uuid,
76    pub updated_at: DateTime<Utc>,
77}
78
79#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
80
81pub struct ExerciseStatusSummaryForUser {
82    pub exercise: Exercise,
83    pub user_exercise_state: Option<UserExerciseState>,
84    pub exercise_slide_submissions: Vec<ExerciseSlideSubmission>,
85    pub given_peer_or_self_review_submissions: Vec<PeerOrSelfReviewSubmissionWithSubmissionOwner>,
86    pub given_peer_or_self_review_question_submissions: Vec<PeerOrSelfReviewQuestionSubmission>,
87    pub received_peer_or_self_review_submissions:
88        Vec<PeerOrSelfReviewSubmissionWithSubmissionOwner>,
89    pub received_peer_or_self_review_question_submissions: Vec<PeerOrSelfReviewQuestionSubmission>,
90    pub peer_review_queue_entry: Option<PeerReviewQueueEntry>,
91    pub teacher_grading_decision: Option<TeacherGradingDecision>,
92    pub peer_or_self_review_questions: Vec<PeerOrSelfReviewQuestion>,
93}
94
95#[derive(Debug, Serialize, Deserialize, ToSchema)]
96
97pub struct CourseMaterialExercise {
98    pub exercise: Exercise,
99    pub can_post_submission: bool,
100    pub current_exercise_slide: CourseMaterialExerciseSlide,
101    /// None for logged out users.
102    pub exercise_status: Option<ExerciseStatus>,
103    pub exercise_slide_submission_counts: HashMap<Uuid, i64>,
104    pub peer_or_self_review_config: Option<CourseMaterialPeerOrSelfReviewConfig>,
105    pub previous_exercise_slide_submission: Option<ExerciseSlideSubmission>,
106    pub user_course_instance_exercise_service_variables: Vec<UserCourseExerciseServiceVariable>,
107    pub should_show_reset_message: Option<String>,
108}
109
110impl CourseMaterialExercise {
111    pub fn clear_grading_information(&mut self) {
112        self.exercise_status = None;
113        self.current_exercise_slide
114            .exercise_tasks
115            .iter_mut()
116            .for_each(|task| {
117                task.model_solution_spec = None;
118                task.previous_submission_grading = None;
119            });
120    }
121
122    pub fn clear_model_solution_specs(&mut self) {
123        self.current_exercise_slide
124            .exercise_tasks
125            .iter_mut()
126            .for_each(|task| {
127                task.model_solution_spec = None;
128            });
129    }
130}
131
132/**
133Indicates what is the user's completion status for a exercise.
134
135As close as possible to LTI's activity progress for compatibility: <https://www.imsglobal.org/spec/lti-ags/v2p0#activityprogress>.
136*/
137#[derive(
138    Debug,
139    Serialize,
140    Deserialize,
141    PartialEq,
142    Eq,
143    Clone,
144    Copy,
145    Default,
146    Display,
147    sqlx::Type,
148    ToSchema,
149)]
150#[sqlx(type_name = "activity_progress", rename_all = "kebab-case")]
151pub enum ActivityProgress {
152    /// The user has not started the activity, or the activity has been reset for that student.
153    #[default]
154    Initialized,
155    /// The activity associated with the exercise has been started by the user to which the result relates.
156    Started,
157    /// The activity is being drafted and is available for comment.
158    InProgress,
159    /// The activity has been submitted at least once by the user but the user is still able make further submissions.
160    Submitted,
161    /// The user has completed the activity associated with the exercise.
162    Completed,
163}
164
165/**
166
167Tells what's the status of the grading progress for a user and exercise.
168
169As close as possible LTI's grading progress for compatibility: <https://www.imsglobal.org/spec/lti-ags/v2p0#gradingprogress>
170*/
171#[derive(
172    Clone,
173    Copy,
174    Debug,
175    Deserialize,
176    Eq,
177    Serialize,
178    Ord,
179    PartialEq,
180    PartialOrd,
181    Display,
182    sqlx::Type,
183    ToSchema,
184)]
185#[sqlx(type_name = "grading_progress", rename_all = "kebab-case")]
186pub enum GradingProgress {
187    /// The grading could not complete.
188    Failed,
189    /// There is no grading process occurring; for example, the student has not yet made any submission.
190    NotReady,
191    /// Final Grade is pending, and it does require human intervention; if a Score value is present, it indicates the current value is partial and may be updated during the manual grading.
192    PendingManual,
193    /// Final Grade is pending, but does not require manual intervention; if a Score value is present, it indicates the current value is partial and may be updated.
194    Pending,
195    /// The grading process is completed; the score value, if any, represents the current Final Grade;
196    FullyGraded,
197}
198
199impl GradingProgress {
200    pub fn is_complete(self) -> bool {
201        self == Self::FullyGraded || self == Self::Failed
202    }
203}
204
205#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
206
207pub struct ExerciseStatus {
208    // None when grading has not completed yet. Max score can be found from the associated exercise.
209    pub score_given: Option<f32>,
210    pub activity_progress: ActivityProgress,
211    pub grading_progress: GradingProgress,
212    pub reviewing_stage: ReviewingStage,
213}
214
215#[allow(clippy::too_many_arguments)]
216pub async fn insert(
217    conn: &mut PgConnection,
218    pkey_policy: PKeyPolicy<Uuid>,
219    course_id: Uuid,
220    name: &str,
221    page_id: Uuid,
222    chapter_id: Uuid,
223    order_number: i32,
224) -> ModelResult<Uuid> {
225    let course = crate::courses::get_course(conn, course_id).await?;
226    let exercise_language_group_id = crate::exercise_language_groups::insert(
227        conn,
228        PKeyPolicy::Generate,
229        course.course_language_group_id,
230    )
231    .await?;
232
233    let res = sqlx::query!(
234        "
235INSERT INTO exercises (
236    id,
237    course_id,
238    name,
239    page_id,
240    chapter_id,
241    order_number,
242    exercise_language_group_id
243  )
244VALUES ($1, $2, $3, $4, $5, $6, $7)
245RETURNING id
246        ",
247        pkey_policy.into_uuid(),
248        course_id,
249        name,
250        page_id,
251        chapter_id,
252        order_number,
253        exercise_language_group_id,
254    )
255    .fetch_one(conn)
256    .await?;
257    Ok(res.id)
258}
259
260pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
261    let exercise = sqlx::query_as!(
262        Exercise,
263        "
264SELECT *
265FROM exercises
266WHERE id = $1
267",
268        id
269    )
270    .fetch_one(conn)
271    .await?;
272    Ok(exercise)
273}
274
275pub async fn get_exercise_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
276    let exercise = sqlx::query_as!(Exercise, "SELECT * FROM exercises WHERE id = $1;", id)
277        .fetch_one(conn)
278        .await?;
279    Ok(exercise)
280}
281
282pub async fn get_exercises_by_course_id(
283    conn: &mut PgConnection,
284    course_id: Uuid,
285) -> ModelResult<Vec<Exercise>> {
286    let exercises = sqlx::query_as!(
287        Exercise,
288        r#"
289SELECT *
290FROM exercises
291WHERE course_id = $1
292  AND deleted_at IS NULL
293"#,
294        course_id
295    )
296    .fetch_all(&mut *conn)
297    .await?;
298    Ok(exercises)
299}
300
301pub async fn get_exercise_submissions_and_status_by_course_instance_id(
302    conn: &mut PgConnection,
303    course_instance_id: Uuid,
304    user_id: Uuid,
305) -> ModelResult<Vec<ExerciseGradingStatus>> {
306    let exercises = sqlx::query_as!(
307        ExerciseGradingStatus,
308        r#"
309        SELECT
310        e.id as exercise_id,
311        e.name as exercise_name,
312        e.score_maximum,
313        ues.score_given,
314        tgd.teacher_decision as "teacher_decision: _",
315        ess.id as submission_id,
316        ess.updated_at
317        FROM exercises e
318        LEFT JOIN user_exercise_states ues on e.id = ues.exercise_id
319        LEFT JOIN teacher_grading_decisions tgd on tgd.user_exercise_state_id = ues.id
320        LEFT JOIN exercise_slide_submissions ess on e.id = ess.exercise_id
321        WHERE e.course_id = (
322            SELECT course_id
323            FROM course_instances
324            WHERE id = $1
325          )
326          AND e.deleted_at IS NULL
327          AND ess.user_id = $2
328          AND ues.user_id = $2
329        ORDER BY e.order_number ASC;
330"#,
331        course_instance_id,
332        user_id
333    )
334    .fetch_all(conn)
335    .await?;
336    Ok(exercises)
337}
338
339pub async fn get_exercises_by_chapter_id(
340    conn: &mut PgConnection,
341    chapter_id: Uuid,
342) -> ModelResult<Vec<Exercise>> {
343    let exercises = sqlx::query_as!(
344        Exercise,
345        r#"
346SELECT *
347FROM exercises
348WHERE chapter_id = $1
349  AND deleted_at IS NULL
350"#,
351        chapter_id
352    )
353    .fetch_all(&mut *conn)
354    .await?;
355    Ok(exercises)
356}
357
358pub async fn get_exercises_by_chapter_ids(
359    conn: &mut PgConnection,
360    chapter_ids: &[Uuid],
361) -> ModelResult<Vec<Exercise>> {
362    if chapter_ids.is_empty() {
363        return Ok(Vec::new());
364    }
365    let exercises = sqlx::query_as!(
366        Exercise,
367        r#"
368SELECT *
369FROM exercises
370WHERE chapter_id = ANY($1)
371  AND deleted_at IS NULL
372"#,
373        chapter_ids as &[Uuid]
374    )
375    .fetch_all(&mut *conn)
376    .await?;
377    Ok(exercises)
378}
379
380pub async fn get_exercises_by_page_id(
381    conn: &mut PgConnection,
382    page_id: Uuid,
383) -> ModelResult<Vec<Exercise>> {
384    let exercises = sqlx::query_as!(
385        Exercise,
386        r#"
387SELECT *
388  FROM exercises
389WHERE page_id = $1
390  AND deleted_at IS NULL;
391"#,
392        page_id,
393    )
394    .fetch_all(&mut *conn)
395    .await?;
396    Ok(exercises)
397}
398
399pub async fn get_exercises_by_exam_id(
400    conn: &mut PgConnection,
401    exam_id: Uuid,
402) -> ModelResult<Vec<Exercise>> {
403    let exercises = sqlx::query_as!(
404        Exercise,
405        r#"
406SELECT *
407FROM exercises
408WHERE exam_id = $1
409  AND deleted_at IS NULL
410"#,
411        exam_id,
412    )
413    .fetch_all(&mut *conn)
414    .await?;
415    Ok(exercises)
416}
417
418pub async fn get_course_or_exam_id(
419    conn: &mut PgConnection,
420    id: Uuid,
421) -> ModelResult<CourseOrExamId> {
422    let res = sqlx::query!(
423        "
424SELECT course_id,
425  exam_id
426FROM exercises
427WHERE id = $1
428",
429        id
430    )
431    .fetch_one(conn)
432    .await?;
433    CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
434}
435
436pub async fn get_course_material_exercise(
437    conn: &mut PgConnection,
438    user_id: Option<Uuid>,
439    exercise_id: Uuid,
440    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
441) -> ModelResult<CourseMaterialExercise> {
442    let mut exercise = get_by_id(conn, exercise_id).await?;
443    if exercise.deadline.is_none()
444        && let Some(chapter_id) = exercise.chapter_id
445    {
446        let chapter = crate::chapters::get_chapter(conn, chapter_id).await?;
447        exercise.deadline = chapter.deadline;
448    }
449    let (current_exercise_slide, instance_or_exam_id) =
450        get_or_select_exercise_slide(&mut *conn, user_id, &exercise, fetch_service_info).await?;
451    info!(
452        "Current exercise slide id: {:#?}",
453        current_exercise_slide.id
454    );
455
456    let user_exercise_state = match (user_id, instance_or_exam_id) {
457        (Some(user_id), Some(course_or_exam_id)) => {
458            user_exercise_states::get_user_exercise_state_if_exists(
459                conn,
460                user_id,
461                exercise.id,
462                course_or_exam_id,
463            )
464            .await?
465        }
466        _ => None,
467    };
468
469    let can_post_submission =
470        determine_can_post_submission(&mut *conn, user_id, &exercise, &user_exercise_state).await?;
471
472    let previous_exercise_slide_submission = match user_id {
473        Some(user_id) => {
474            crate::exercise_slide_submissions::try_to_get_users_latest_exercise_slide_submission(
475                conn,
476                current_exercise_slide.id,
477                user_id,
478            )
479            .await?
480        }
481        _ => None,
482    };
483
484    let exercise_status = user_exercise_state.map(|user_exercise_state| ExerciseStatus {
485        score_given: user_exercise_state.score_given,
486        activity_progress: user_exercise_state.activity_progress,
487        grading_progress: user_exercise_state.grading_progress,
488        reviewing_stage: user_exercise_state.reviewing_stage,
489    });
490
491    let exercise_slide_submission_counts = if let Some(user_id) = user_id {
492        if let Some(cioreid) = instance_or_exam_id {
493            get_exercise_slide_submission_counts_for_exercise_user(
494                conn,
495                exercise_id,
496                cioreid,
497                user_id,
498            )
499            .await?
500        } else {
501            HashMap::new()
502        }
503    } else {
504        HashMap::new()
505    };
506
507    let peer_or_self_review_config = if let Some(course_id) = exercise.course_id {
508        if exercise.needs_peer_review || exercise.needs_self_review {
509            let prc = crate::peer_or_self_review_configs::get_by_exercise_or_course_id(
510                conn, &exercise, course_id,
511            )
512            .await
513            .optional()?;
514            prc.map(|prc| CourseMaterialPeerOrSelfReviewConfig {
515                id: prc.id,
516                course_id: prc.course_id,
517                exercise_id: prc.exercise_id,
518                peer_reviews_to_give: prc.peer_reviews_to_give,
519                peer_reviews_to_receive: prc.peer_reviews_to_receive,
520            })
521        } else {
522            None
523        }
524    } else {
525        None
526    };
527
528    let user_course_instance_exercise_service_variables = match (user_id, instance_or_exam_id) {
529        (Some(user_id), Some(course_or_exam_id)) => {
530            Some(crate::user_course_exercise_service_variables::get_all_variables_for_user_and_course_or_exam(conn, user_id, course_or_exam_id).await?)
531        }
532        _ => None,
533    }.unwrap_or_default();
534
535    let should_show_reset_message = if let Some(user_id) = user_id {
536        crate::exercise_reset_logs::user_should_see_reset_message_for_exercise(
537            conn,
538            user_id,
539            exercise_id,
540        )
541        .await?
542    } else {
543        None
544    };
545
546    Ok(CourseMaterialExercise {
547        exercise,
548        can_post_submission,
549        current_exercise_slide,
550        exercise_status,
551        exercise_slide_submission_counts,
552        peer_or_self_review_config,
553        user_course_instance_exercise_service_variables,
554        previous_exercise_slide_submission,
555        should_show_reset_message,
556    })
557}
558
559async fn determine_can_post_submission(
560    conn: &mut PgConnection,
561    user_id: Option<Uuid>,
562    exercise: &Exercise,
563    user_exercise_state: &Option<UserExerciseState>,
564) -> Result<bool, ModelError> {
565    if let Some(user_exercise_state) = user_exercise_state {
566        // Once the user has started peer review or self review, they cannot no longer answer the exercise because they have already seen a model solution in the review instructions and they have seen submissions from other users.
567        if user_exercise_state.reviewing_stage != ReviewingStage::NotStarted {
568            return Ok(false);
569        }
570    }
571
572    let can_post_submission = if let Some(user_id) = user_id {
573        if let Some(exam_id) = exercise.exam_id {
574            exams::verify_exam_submission_can_be_made(conn, exam_id, user_id).await?
575        } else {
576            true
577        }
578    } else {
579        false
580    };
581    Ok(can_post_submission)
582}
583
584pub async fn get_or_select_exercise_slide(
585    conn: &mut PgConnection,
586    user_id: Option<Uuid>,
587    exercise: &Exercise,
588    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
589) -> ModelResult<(CourseMaterialExerciseSlide, Option<CourseOrExamId>)> {
590    match (user_id, exercise.course_id, exercise.exam_id) {
591        (None, ..) => {
592            // No signed in user. Show random exercise without model solution.
593            let random_slide =
594                exercise_slides::get_random_exercise_slide_for_exercise(conn, exercise.id).await?;
595            let random_slide_tasks = exercise_tasks::get_course_material_exercise_tasks(
596                conn,
597                random_slide.id,
598                None,
599                fetch_service_info,
600            )
601            .await?;
602            Ok((
603                CourseMaterialExerciseSlide {
604                    id: random_slide.id,
605                    exercise_tasks: random_slide_tasks,
606                },
607                None,
608            ))
609        }
610        (Some(user_id), Some(course_id), None) => {
611            // signed in, course exercise
612            let user_course_settings = user_course_settings::get_user_course_settings_by_course_id(
613                conn, user_id, course_id,
614            )
615            .await?;
616            match user_course_settings {
617                Some(settings) if settings.current_course_id == course_id => {
618                    // User is enrolled on an instance of the given course.
619                    let course_or_exam_id: CourseOrExamId = exercise.try_into()?;
620                    let tasks =
621                        exercise_tasks::get_or_select_user_exercise_slide_for_course_or_exam(
622                            conn,
623                            user_id,
624                            exercise.id,
625                            course_or_exam_id,
626                            fetch_service_info,
627                        )
628                        .await?;
629                    Ok((tasks, Some(CourseOrExamId::Course(course_id))))
630                }
631                Some(_) => {
632                    // User is enrolled on a different language version of the course. Show exercise
633                    // slide based on their latest enrollment or a random one.
634                    let exercise_tasks =
635                        exercise_tasks::get_existing_users_exercise_slide_for_course(
636                            conn,
637                            user_id,
638                            exercise.id,
639                            course_id,
640                            &fetch_service_info,
641                        )
642                        .await?;
643                    if let Some(exercise_tasks) = exercise_tasks {
644                        Ok((exercise_tasks, Some(CourseOrExamId::Course(course_id))))
645                    } else {
646                        // no exercise task has been chosen for the user
647                        let random_slide = exercise_slides::get_random_exercise_slide_for_exercise(
648                            conn,
649                            exercise.id,
650                        )
651                        .await?;
652                        let random_tasks = exercise_tasks::get_course_material_exercise_tasks(
653                            conn,
654                            random_slide.id,
655                            Some(user_id),
656                            &fetch_service_info,
657                        )
658                        .await?;
659
660                        Ok((
661                            CourseMaterialExerciseSlide {
662                                id: random_slide.id,
663                                exercise_tasks: random_tasks,
664                            },
665                            None,
666                        ))
667                    }
668                }
669                None => {
670                    // User is not enrolled on any course version. This is not a valid scenario because
671                    // tasks are based on a specific instance.
672                    Err(ModelError::new(
673                        ModelErrorType::PreconditionFailed,
674                        "User must be enrolled to the course".to_string(),
675                        None,
676                    ))
677                }
678            }
679        }
680        (Some(user_id), _, Some(exam_id)) => {
681            info!("selecting exam task");
682            // signed in, exam exercise
683            let tasks = exercise_tasks::get_or_select_user_exercise_slide_for_course_or_exam(
684                conn,
685                user_id,
686                exercise.id,
687                CourseOrExamId::Exam(exam_id),
688                fetch_service_info,
689            )
690            .await?;
691            info!("selecting exam task {:#?}", tasks);
692            Ok((tasks, Some(CourseOrExamId::Exam(exam_id))))
693        }
694        (Some(_), ..) => Err(ModelError::new(
695            ModelErrorType::Generic,
696            "The selected exercise is not attached to any course or exam".to_string(),
697            None,
698        )),
699    }
700}
701
702pub async fn delete_exercises_by_page_id(
703    conn: &mut PgConnection,
704    page_id: Uuid,
705) -> ModelResult<Vec<Uuid>> {
706    let deleted_ids = sqlx::query!(
707        "
708UPDATE exercises
709SET deleted_at = now()
710WHERE page_id = $1
711AND deleted_at IS NULL
712RETURNING id;
713        ",
714        page_id
715    )
716    .fetch_all(conn)
717    .await?
718    .into_iter()
719    .map(|x| x.id)
720    .collect();
721    Ok(deleted_ids)
722}
723
724pub async fn update_teacher_reviews_answer_after_locking(
725    conn: &mut PgConnection,
726    exercise_id: Uuid,
727    teacher_reviews_answer_after_locking: bool,
728) -> ModelResult<Exercise> {
729    let exercise = sqlx::query_as!(
730        Exercise,
731        r#"
732UPDATE exercises
733SET teacher_reviews_answer_after_locking = $2
734WHERE id = $1
735  AND deleted_at IS NULL
736RETURNING *
737        "#,
738        exercise_id,
739        teacher_reviews_answer_after_locking
740    )
741    .fetch_one(conn)
742    .await?;
743
744    Ok(exercise)
745}
746
747pub async fn set_exercise_to_use_exercise_specific_peer_or_self_review_config(
748    conn: &mut PgConnection,
749    exercise_id: Uuid,
750    needs_peer_review: bool,
751    needs_self_review: bool,
752    use_course_default_peer_or_self_review_config: bool,
753) -> ModelResult<Uuid> {
754    let id = sqlx::query!(
755        "
756UPDATE exercises
757SET use_course_default_peer_or_self_review_config = $1,
758  needs_peer_review = $2,
759  needs_self_review = $3
760WHERE id = $4
761RETURNING id;
762        ",
763        use_course_default_peer_or_self_review_config,
764        needs_peer_review,
765        needs_self_review,
766        exercise_id
767    )
768    .fetch_one(conn)
769    .await?;
770
771    Ok(id.id)
772}
773
774pub async fn get_all_exercise_statuses_by_user_id_and_course_id(
775    conn: &mut PgConnection,
776    course_id: Uuid,
777    user_id: Uuid,
778) -> ModelResult<Vec<ExerciseStatusSummaryForUser>> {
779    let course_or_exam_id = CourseOrExamId::Course(course_id);
780    // Load all the data for this user from all the exercises to memory, and group most of them to HashMaps by exercise id
781    let exercises = crate::exercises::get_exercises_by_course_id(&mut *conn, course_id).await?;
782    let mut user_exercise_states =
783        crate::user_exercise_states::get_all_for_user_and_course_or_exam(
784            &mut *conn,
785            user_id,
786            course_or_exam_id,
787        )
788        .await?
789        .into_iter()
790        .map(|ues| (ues.exercise_id, ues))
791        .collect::<HashMap<_, _>>();
792    let mut exercise_slide_submissions =
793        crate::exercise_slide_submissions::get_users_all_submissions_for_course_or_exam(
794            &mut *conn,
795            user_id,
796            course_or_exam_id,
797        )
798        .await?
799        .into_iter()
800        .into_group_map_by(|o| o.exercise_id);
801    let mut given_peer_or_self_review_submissions = crate::peer_or_self_review_submissions::get_all_given_peer_or_self_review_submissions_for_user_and_course(&mut *conn, user_id, course_id).await?.into_iter()
802        .into_group_map_by(|o| o.exercise_id);
803    let given_submission_ids: Vec<Uuid> = given_peer_or_self_review_submissions
804        .values()
805        .flatten()
806        .map(|prs| prs.exercise_slide_submission_id)
807        .collect();
808    let submission_owner_user_ids =
809        crate::exercise_slide_submissions::get_user_ids_by_submission_ids(
810            &mut *conn,
811            &given_submission_ids,
812        )
813        .await?;
814    let mut received_peer_or_self_review_submissions = crate::peer_or_self_review_submissions::get_all_received_peer_or_self_review_submissions_for_user_and_course(&mut *conn, user_id, course_id).await?.into_iter()
815        .into_group_map_by(|o| o.exercise_id);
816    let given_peer_or_self_review_submission_ids = given_peer_or_self_review_submissions
817        .values()
818        .flatten()
819        .map(|x| x.id)
820        .collect::<Vec<_>>();
821    let mut given_peer_or_self_review_question_submissions = crate::peer_or_self_review_question_submissions::get_question_submissions_from_from_peer_or_self_review_submission_ids(&mut *conn, &given_peer_or_self_review_submission_ids).await?
822        .into_iter()
823        .into_group_map_by(|o| {
824            let peer_review_submission = given_peer_or_self_review_submissions.clone().into_iter()
825                .find(|(_exercise_id, prs)| prs.iter().any(|p| p.id == o.peer_or_self_review_submission_id))
826                .unwrap_or_else(|| (Uuid::nil(), vec![]));
827            peer_review_submission.0
828    });
829    let received_peer_or_self_review_submission_ids = received_peer_or_self_review_submissions
830        .values()
831        .flatten()
832        .map(|x| x.id)
833        .collect::<Vec<_>>();
834    let mut received_peer_or_self_review_question_submissions = crate::peer_or_self_review_question_submissions::get_question_submissions_from_from_peer_or_self_review_submission_ids(&mut *conn, &received_peer_or_self_review_submission_ids).await?.into_iter()
835    .into_group_map_by(|o| {
836        let peer_review_submission = received_peer_or_self_review_submissions.clone().into_iter()
837            .find(|(_exercise_id, prs)| prs.iter().any(|p| p.id == o.peer_or_self_review_submission_id))
838            .unwrap_or_else(|| (Uuid::nil(), vec![]));
839        peer_review_submission.0
840    });
841    let mut peer_review_queue_entries =
842        crate::peer_review_queue_entries::get_all_by_user_and_course_id(
843            &mut *conn, user_id, course_id,
844        )
845        .await?
846        .into_iter()
847        .map(|x| (x.exercise_id, x))
848        .collect::<HashMap<_, _>>();
849    let mut teacher_grading_decisions = crate::teacher_grading_decisions::get_all_latest_grading_decisions_by_user_id_and_course_id(&mut *conn, user_id, course_id).await?.into_iter()
850    .filter_map(|tgd| {
851        let user_exercise_state = user_exercise_states.clone().into_iter()
852            .find(|(_exercise_id, ues)|  ues.id == tgd.user_exercise_state_id)?;
853        Some((user_exercise_state.0, tgd))
854    }).collect::<HashMap<_, _>>();
855    let all_peer_or_self_review_question_ids = given_peer_or_self_review_question_submissions
856        .iter()
857        .chain(received_peer_or_self_review_question_submissions.iter())
858        .flat_map(|(_exercise_id, prqs)| prqs.iter().map(|p| p.peer_or_self_review_question_id))
859        .collect::<Vec<_>>();
860    let all_peer_or_self_review_questions = crate::peer_or_self_review_questions::get_by_ids(
861        &mut *conn,
862        &all_peer_or_self_review_question_ids,
863    )
864    .await?;
865
866    // Map all the data for all the exercises to be summaries of the data for each exercise.
867    //
868    // Since all data is in hashmaps grouped by exercise id, and we iterate though every
869    // exercise id exactly once, we can just remove the data for the exercise from the
870    // hashmaps and avoid extra copying.
871    let res = exercises
872        .into_iter()
873        .map(|exercise| {
874            let user_exercise_state = user_exercise_states.remove(&exercise.id);
875            let exercise_slide_submissions = exercise_slide_submissions
876                .remove(&exercise.id)
877                .unwrap_or_default();
878            let given_peer_or_self_review_submissions = given_peer_or_self_review_submissions
879                .remove(&exercise.id)
880                .unwrap_or_default()
881                .into_iter()
882                .map(|prs| {
883                    let submission_owner_user_id = submission_owner_user_ids
884                        .get(&prs.exercise_slide_submission_id)
885                        .copied();
886                    PeerOrSelfReviewSubmissionWithSubmissionOwner {
887                        submission: prs,
888                        submission_owner_user_id,
889                    }
890                })
891                .collect();
892            let received_peer_or_self_review_submissions = received_peer_or_self_review_submissions
893                .remove(&exercise.id)
894                .unwrap_or_default()
895                .into_iter()
896                .map(|prs| PeerOrSelfReviewSubmissionWithSubmissionOwner {
897                    submission: prs,
898                    submission_owner_user_id: None,
899                })
900                .collect();
901            let given_peer_or_self_review_question_submissions =
902                given_peer_or_self_review_question_submissions
903                    .remove(&exercise.id)
904                    .unwrap_or_default();
905            let received_peer_or_self_review_question_submissions =
906                received_peer_or_self_review_question_submissions
907                    .remove(&exercise.id)
908                    .unwrap_or_default();
909            let peer_review_queue_entry = peer_review_queue_entries.remove(&exercise.id);
910            let teacher_grading_decision = teacher_grading_decisions.remove(&exercise.id);
911            let peer_or_self_review_question_ids = given_peer_or_self_review_question_submissions
912                .iter()
913                .chain(received_peer_or_self_review_question_submissions.iter())
914                .map(|prqs| prqs.peer_or_self_review_question_id)
915                .unique()
916                .collect::<Vec<_>>();
917            let peer_or_self_review_questions = all_peer_or_self_review_questions
918                .iter()
919                .filter(|prq| peer_or_self_review_question_ids.contains(&prq.id))
920                .cloned()
921                .collect::<Vec<_>>();
922            ExerciseStatusSummaryForUser {
923                exercise,
924                user_exercise_state,
925                exercise_slide_submissions,
926                given_peer_or_self_review_submissions,
927                received_peer_or_self_review_submissions,
928                given_peer_or_self_review_question_submissions,
929                received_peer_or_self_review_question_submissions,
930                peer_review_queue_entry,
931                teacher_grading_decision,
932                peer_or_self_review_questions,
933            }
934        })
935        .collect::<Vec<_>>();
936    Ok(res)
937}
938
939pub async fn get_exercises_by_module_containing_exercise_type(
940    conn: &mut PgConnection,
941    exercise_type: &str,
942    course_module_id: Uuid,
943) -> ModelResult<Vec<Exercise>> {
944    let res: Vec<Exercise> = sqlx::query_as!(
945        Exercise,
946        r#"
947SELECT DISTINCT(ex.*)
948FROM exercises ex
949  JOIN exercise_slides slides ON ex.id = slides.exercise_id
950  JOIN exercise_tasks tasks ON slides.id = tasks.exercise_slide_id
951  JOIN chapters c ON ex.chapter_id = c.id
952where tasks.exercise_type = $1
953  AND c.course_module_id = $2
954  AND ex.deleted_at IS NULL
955  AND tasks.deleted_at IS NULL
956  and c.deleted_at IS NULL
957  and slides.deleted_at IS NULL
958        "#,
959        exercise_type,
960        course_module_id
961    )
962    .fetch_all(conn)
963    .await?;
964    Ok(res)
965}
966
967/// Collects user_ids and related exercise_ids according to given filters
968pub async fn collect_user_ids_and_exercise_ids_for_reset(
969    conn: &mut PgConnection,
970    user_ids: &[Uuid],
971    exercise_ids: &[Uuid],
972    threshold: Option<f64>,
973    reset_all_below_max: bool,
974    reset_only_locked_reviews: bool,
975) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
976    let results = sqlx::query!(
977        r#"
978SELECT DISTINCT ues.user_id,
979  ues.exercise_id
980FROM user_exercise_states ues
981  LEFT JOIN exercises e ON ues.exercise_id = e.id
982WHERE ues.user_id = ANY($1)
983  AND ues.exercise_id = ANY($2)
984  AND ues.deleted_at IS NULL
985  AND (
986    $3 = FALSE
987    OR ues.score_given < e.score_maximum
988  )
989  AND (
990    $4::FLOAT IS NULL
991    OR ues.score_given < $4::FLOAT
992  )
993  AND (
994    $5 = FALSE
995    OR ues.reviewing_stage = 'reviewed_and_locked'
996  )
997            "#,
998        user_ids,
999        exercise_ids,
1000        reset_all_below_max,
1001        threshold,
1002        reset_only_locked_reviews
1003    )
1004    .fetch_all(&mut *conn)
1005    .await?;
1006
1007    let mut user_exercise_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
1008    for row in &results {
1009        user_exercise_map
1010            .entry(row.user_id)
1011            .or_default()
1012            .push(row.exercise_id);
1013    }
1014
1015    Ok(user_exercise_map.into_iter().collect())
1016}
1017
1018/// Resolves affected chapter ids for exercise resets in a course.
1019async fn get_chapter_ids_for_exercises_in_course(
1020    conn: &mut PgConnection,
1021    exercise_ids: &[Uuid],
1022    course_id: Uuid,
1023) -> ModelResult<Vec<Uuid>> {
1024    let mut chapter_ids = HashSet::new();
1025    for exercise_id in exercise_ids {
1026        let exercise = get_exercise_by_id(conn, *exercise_id).await?;
1027        if exercise.course_id == Some(course_id)
1028            && let Some(chapter_id) = exercise.chapter_id
1029        {
1030            chapter_ids.insert(chapter_id);
1031        }
1032    }
1033    Ok(chapter_ids.into_iter().collect())
1034}
1035
1036/// Resets all related tables for selected users and related exercises.
1037pub async fn reset_exercises_for_selected_users(
1038    conn: &mut PgConnection,
1039    users_and_exercises: &[(Uuid, Vec<Uuid>)],
1040    reset_by: Option<Uuid>,
1041    course_id: Uuid,
1042    reason: Option<String>,
1043) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1044    let mut successful_resets = Vec::new();
1045    let mut tx = conn.begin().await?;
1046    for (user_id, exercise_ids) in users_and_exercises {
1047        sqlx::query!(
1048            r#"
1049UPDATE exercise_slide_submissions
1050SET deleted_at = NOW()
1051WHERE user_id = $1
1052  AND exercise_id = ANY($2)
1053  AND deleted_at IS NULL
1054            "#,
1055            user_id,
1056            exercise_ids
1057        )
1058        .execute(&mut *tx)
1059        .await?;
1060
1061        sqlx::query!(
1062            r#"
1063UPDATE exercise_task_submissions
1064SET deleted_at = NOW()
1065WHERE exercise_slide_submission_id IN (
1066    SELECT id
1067    FROM exercise_slide_submissions
1068    WHERE user_id = $1
1069      AND exercise_id = ANY($2)
1070  )
1071  AND deleted_at IS NULL
1072            "#,
1073            user_id,
1074            exercise_ids
1075        )
1076        .execute(&mut *tx)
1077        .await?;
1078
1079        sqlx::query!(
1080            r#"
1081UPDATE peer_review_queue_entries
1082SET deleted_at = NOW()
1083WHERE user_id = $1
1084  AND exercise_id = ANY($2)
1085  AND deleted_at IS NULL
1086            "#,
1087            user_id,
1088            exercise_ids
1089        )
1090        .execute(&mut *tx)
1091        .await?;
1092
1093        sqlx::query!(
1094            r#"
1095UPDATE exercise_task_gradings
1096SET deleted_at = NOW()
1097WHERE exercise_task_submission_id IN (
1098    SELECT id
1099    FROM exercise_task_submissions
1100    WHERE exercise_slide_submission_id IN (
1101        SELECT id
1102        FROM exercise_slide_submissions
1103        WHERE user_id = $1
1104          AND exercise_id = ANY($2)
1105      )
1106  )
1107  AND deleted_at IS NULL
1108            "#,
1109            user_id,
1110            exercise_ids
1111        )
1112        .execute(&mut *tx)
1113        .await?;
1114
1115        sqlx::query!(
1116            r#"
1117UPDATE user_exercise_states
1118SET deleted_at = NOW()
1119WHERE user_id = $1
1120  AND exercise_id = ANY($2)
1121  AND deleted_at IS NULL
1122            "#,
1123            user_id,
1124            exercise_ids
1125        )
1126        .execute(&mut *tx)
1127        .await?;
1128
1129        sqlx::query!(
1130            r#"
1131UPDATE user_exercise_task_states
1132SET deleted_at = NOW()
1133WHERE user_exercise_slide_state_id IN (
1134    SELECT id
1135    FROM user_exercise_slide_states
1136    WHERE user_exercise_state_id IN (
1137        SELECT id
1138        FROM user_exercise_states
1139        WHERE user_id = $1
1140          AND exercise_id = ANY($2)
1141      )
1142  )
1143  AND deleted_at IS NULL
1144            "#,
1145            user_id,
1146            exercise_ids
1147        )
1148        .execute(&mut *tx)
1149        .await?;
1150
1151        sqlx::query!(
1152            r#"
1153UPDATE user_exercise_slide_states
1154SET deleted_at = NOW()
1155WHERE user_exercise_state_id IN (
1156    SELECT id
1157    FROM user_exercise_states
1158    WHERE user_id = $1
1159      AND exercise_id = ANY($2)
1160  )
1161  AND deleted_at IS NULL
1162            "#,
1163            user_id,
1164            exercise_ids
1165        )
1166        .execute(&mut *tx)
1167        .await?;
1168
1169        sqlx::query!(
1170            r#"
1171UPDATE teacher_grading_decisions
1172SET deleted_at = NOW()
1173WHERE user_exercise_state_id IN (
1174    SELECT id
1175    FROM user_exercise_states
1176    WHERE user_id = $1
1177      AND exercise_id = ANY($2)
1178  )
1179  AND deleted_at IS NULL
1180            "#,
1181            user_id,
1182            exercise_ids
1183        )
1184        .execute(&mut *tx)
1185        .await?;
1186
1187        // Adds a log of a reset exercise for a user
1188        exercise_reset_logs::log_exercise_reset(
1189            &mut tx,
1190            reset_by,
1191            *user_id,
1192            exercise_ids,
1193            course_id,
1194            reason.clone(),
1195        )
1196        .await?;
1197
1198        let chapter_ids =
1199            get_chapter_ids_for_exercises_in_course(&mut tx, exercise_ids, course_id).await?;
1200        // Keep chapters that are still in their initial `not_unlocked_yet` state untouched.
1201        user_chapter_locking_statuses::unlock_chapters_for_user(
1202            &mut tx,
1203            *user_id,
1204            course_id,
1205            &chapter_ids,
1206        )
1207        .await?;
1208
1209        successful_resets.push((*user_id, exercise_ids.to_vec()));
1210    }
1211    tx.commit().await?;
1212    Ok(successful_resets)
1213}
1214
1215#[cfg(test)]
1216mod test {
1217    use super::*;
1218    use crate::{
1219        chapters,
1220        course_instance_enrollments::{self, NewCourseInstanceEnrollment},
1221        courses,
1222        exercise_service_info::{self, PathInfo},
1223        exercise_services::{self, ExerciseServiceNewOrUpdate},
1224        test_helper::Conn,
1225        test_helper::*,
1226        user_chapter_locking_statuses::{self, ChapterLockingStatus},
1227        user_exercise_states,
1228    };
1229    use chrono::TimeZone;
1230    use sqlx::PgConnection;
1231
1232    async fn insert_exercise_service_with_info(tx: &mut PgConnection) {
1233        let exercise_service = exercise_services::insert_exercise_service(
1234            tx,
1235            &ExerciseServiceNewOrUpdate {
1236                name: "text-exercise".to_string(),
1237                slug: TEST_HELPER_EXERCISE_SERVICE_NAME.to_string(),
1238                public_url: "https://example.com".to_string(),
1239                internal_url: None,
1240                max_reprocessing_submissions_at_once: 1,
1241            },
1242        )
1243        .await
1244        .unwrap();
1245        exercise_service_info::insert(
1246            tx,
1247            &PathInfo {
1248                exercise_service_id: exercise_service.id,
1249                user_interface_iframe_path: "/iframe".to_string(),
1250                grade_endpoint_path: "/grade".to_string(),
1251                public_spec_endpoint_path: "/public-spec".to_string(),
1252                model_solution_spec_endpoint_path: "test-only-empty-path".to_string(),
1253                has_custom_view: false,
1254            },
1255        )
1256        .await
1257        .unwrap();
1258    }
1259
1260    #[tokio::test]
1261    async fn selects_course_material_exercise_for_enrolled_student() {
1262        insert_data!(
1263            :tx,
1264            user: user_id,
1265            org: _organization_id,
1266            course: course_id,
1267            instance: course_instance,
1268            :course_module,
1269            chapter: chapter_id,
1270            page: _page_id,
1271            exercise: exercise_id,
1272            slide: exercise_slide_id,
1273            task: exercise_task_id
1274        );
1275        insert_exercise_service_with_info(tx.as_mut()).await;
1276        course_instance_enrollments::insert_enrollment_and_set_as_current(
1277            tx.as_mut(),
1278            NewCourseInstanceEnrollment {
1279                course_id,
1280                course_instance_id: course_instance.id,
1281                user_id,
1282            },
1283        )
1284        .await
1285        .unwrap();
1286
1287        let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
1288            tx.as_mut(),
1289            user_id,
1290            exercise_id,
1291            CourseOrExamId::Course(course_id),
1292        )
1293        .await
1294        .unwrap();
1295        assert!(user_exercise_state.is_none());
1296
1297        let exercise = get_course_material_exercise(
1298            tx.as_mut(),
1299            Some(user_id),
1300            exercise_id,
1301            |_| unimplemented!(),
1302        )
1303        .await
1304        .unwrap();
1305        assert_eq!(
1306            exercise
1307                .current_exercise_slide
1308                .exercise_tasks
1309                .first()
1310                .unwrap()
1311                .id,
1312            exercise_task_id
1313        );
1314
1315        let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
1316            tx.as_mut(),
1317            user_id,
1318            exercise_id,
1319            CourseOrExamId::Course(course_id),
1320        )
1321        .await
1322        .unwrap();
1323        assert_eq!(
1324            user_exercise_state
1325                .unwrap()
1326                .selected_exercise_slide_id
1327                .unwrap(),
1328            exercise_slide_id
1329        );
1330    }
1331
1332    #[tokio::test]
1333    async fn course_material_exercise_inherits_chapter_deadline() {
1334        insert_data!(
1335            :tx,
1336            user: user_id,
1337            org: organization_id,
1338            course: course_id,
1339            instance: course_instance,
1340            :course_module,
1341            chapter: chapter_id,
1342            page: page_id,
1343            exercise: exercise_id,
1344            slide: exercise_slide_id,
1345            task: _exercise_task_id
1346        );
1347        insert_exercise_service_with_info(tx.as_mut()).await;
1348        course_instance_enrollments::insert_enrollment_and_set_as_current(
1349            tx.as_mut(),
1350            NewCourseInstanceEnrollment {
1351                course_id,
1352                course_instance_id: course_instance.id,
1353                user_id,
1354            },
1355        )
1356        .await
1357        .unwrap();
1358
1359        let chapter_deadline = Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap();
1360        let chapter = chapters::get_chapter(tx.as_mut(), chapter_id)
1361            .await
1362            .unwrap();
1363        chapters::update_chapter(
1364            tx.as_mut(),
1365            chapter_id,
1366            chapters::ChapterUpdate {
1367                name: chapter.name,
1368                color: chapter.color,
1369                front_page_id: chapter.front_page_id,
1370                deadline: Some(chapter_deadline),
1371                opens_at: chapter.opens_at,
1372                course_module_id: Some(chapter.course_module_id),
1373            },
1374        )
1375        .await
1376        .unwrap();
1377
1378        let exercise = get_course_material_exercise(
1379            tx.as_mut(),
1380            Some(user_id),
1381            exercise_id,
1382            |_| unimplemented!(),
1383        )
1384        .await
1385        .unwrap();
1386
1387        assert_eq!(exercise.exercise.deadline, Some(chapter_deadline));
1388    }
1389
1390    #[tokio::test]
1391    async fn resetting_exercise_unlocks_its_chapter_for_user() {
1392        insert_data!(
1393            :tx,
1394            user: user_id,
1395            org: _organization_id,
1396            course: course_id,
1397            instance: _course_instance,
1398            :course_module,
1399            chapter: chapter_id,
1400            page: _page_id,
1401            exercise: exercise_id,
1402            slide: _exercise_slide_id,
1403            task: _exercise_task_id
1404        );
1405
1406        let existing_course = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1407        courses::update_course(
1408            tx.as_mut(),
1409            course_id,
1410            courses::CourseUpdate {
1411                name: existing_course.name,
1412                description: existing_course.description,
1413                is_draft: existing_course.is_draft,
1414                is_test_mode: existing_course.is_test_mode,
1415                can_add_chatbot: existing_course.can_add_chatbot,
1416                is_unlisted: existing_course.is_unlisted,
1417                is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
1418                ask_marketing_consent: existing_course.ask_marketing_consent,
1419                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
1420                flagged_answers_skip_manual_review_and_allow_retry: existing_course
1421                    .flagged_answers_skip_manual_review_and_allow_retry,
1422                closed_at: existing_course.closed_at,
1423                closed_additional_message: existing_course.closed_additional_message,
1424                closed_course_successor_id: existing_course.closed_course_successor_id,
1425                chapter_locking_enabled: true,
1426            },
1427        )
1428        .await
1429        .unwrap();
1430
1431        user_chapter_locking_statuses::complete_and_lock_chapter(
1432            tx.as_mut(),
1433            user_id,
1434            chapter_id,
1435            course_id,
1436        )
1437        .await
1438        .unwrap();
1439
1440        reset_exercises_for_selected_users(
1441            tx.as_mut(),
1442            &[(user_id, vec![exercise_id])],
1443            Some(user_id),
1444            course_id,
1445            Some("test-reset".to_string()),
1446        )
1447        .await
1448        .unwrap();
1449
1450        let status = user_chapter_locking_statuses::get_or_init_status(
1451            tx.as_mut(),
1452            user_id,
1453            chapter_id,
1454            Some(course_id),
1455            Some(true),
1456        )
1457        .await
1458        .unwrap();
1459
1460        assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
1461    }
1462}