Skip to main content

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_non_deleted_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
276    let exercise = sqlx::query_as!(
277        Exercise,
278        "
279SELECT *
280FROM exercises
281WHERE id = $1
282  AND deleted_at IS NULL
283",
284        id
285    )
286    .fetch_one(conn)
287    .await?;
288    Ok(exercise)
289}
290
291pub async fn get_non_deleted_by_ids(
292    conn: &mut PgConnection,
293    ids: &[Uuid],
294) -> ModelResult<Vec<Exercise>> {
295    let exercises = sqlx::query_as!(
296        Exercise,
297        "
298SELECT *
299FROM exercises
300WHERE id = ANY($1)
301  AND deleted_at IS NULL
302",
303        ids
304    )
305    .fetch_all(conn)
306    .await?;
307    Ok(exercises)
308}
309
310pub async fn get_exercise_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
311    let exercise = sqlx::query_as!(Exercise, "SELECT * FROM exercises WHERE id = $1;", id)
312        .fetch_one(conn)
313        .await?;
314    Ok(exercise)
315}
316
317pub async fn get_exercises_by_course_id(
318    conn: &mut PgConnection,
319    course_id: Uuid,
320) -> ModelResult<Vec<Exercise>> {
321    let exercises = sqlx::query_as!(
322        Exercise,
323        r#"
324SELECT *
325FROM exercises
326WHERE course_id = $1
327  AND deleted_at IS NULL
328"#,
329        course_id
330    )
331    .fetch_all(&mut *conn)
332    .await?;
333    Ok(exercises)
334}
335
336pub async fn get_exercise_submissions_and_status_by_course_instance_id(
337    conn: &mut PgConnection,
338    course_instance_id: Uuid,
339    user_id: Uuid,
340) -> ModelResult<Vec<ExerciseGradingStatus>> {
341    let exercises = sqlx::query_as!(
342        ExerciseGradingStatus,
343        r#"
344        SELECT
345        e.id as exercise_id,
346        e.name as exercise_name,
347        e.score_maximum,
348        ues.score_given,
349        tgd.teacher_decision as "teacher_decision: _",
350        ess.id as submission_id,
351        ess.updated_at
352        FROM exercises e
353        LEFT JOIN user_exercise_states ues on e.id = ues.exercise_id
354        LEFT JOIN teacher_grading_decisions tgd on tgd.user_exercise_state_id = ues.id
355        LEFT JOIN exercise_slide_submissions ess on e.id = ess.exercise_id
356        WHERE e.course_id = (
357            SELECT course_id
358            FROM course_instances
359            WHERE id = $1
360          )
361          AND e.deleted_at IS NULL
362          AND ess.user_id = $2
363          AND ues.user_id = $2
364        ORDER BY e.order_number ASC;
365"#,
366        course_instance_id,
367        user_id
368    )
369    .fetch_all(conn)
370    .await?;
371    Ok(exercises)
372}
373
374pub async fn get_exercises_by_chapter_id(
375    conn: &mut PgConnection,
376    chapter_id: Uuid,
377) -> ModelResult<Vec<Exercise>> {
378    let exercises = sqlx::query_as!(
379        Exercise,
380        r#"
381SELECT *
382FROM exercises
383WHERE chapter_id = $1
384  AND deleted_at IS NULL
385"#,
386        chapter_id
387    )
388    .fetch_all(&mut *conn)
389    .await?;
390    Ok(exercises)
391}
392
393pub async fn get_exercises_by_chapter_ids(
394    conn: &mut PgConnection,
395    chapter_ids: &[Uuid],
396) -> ModelResult<Vec<Exercise>> {
397    if chapter_ids.is_empty() {
398        return Ok(Vec::new());
399    }
400    let exercises = sqlx::query_as!(
401        Exercise,
402        r#"
403SELECT *
404FROM exercises
405WHERE chapter_id = ANY($1)
406  AND deleted_at IS NULL
407"#,
408        chapter_ids as &[Uuid]
409    )
410    .fetch_all(&mut *conn)
411    .await?;
412    Ok(exercises)
413}
414
415pub async fn get_exercises_by_page_id(
416    conn: &mut PgConnection,
417    page_id: Uuid,
418) -> ModelResult<Vec<Exercise>> {
419    let exercises = sqlx::query_as!(
420        Exercise,
421        r#"
422SELECT *
423  FROM exercises
424WHERE page_id = $1
425  AND deleted_at IS NULL;
426"#,
427        page_id,
428    )
429    .fetch_all(&mut *conn)
430    .await?;
431    Ok(exercises)
432}
433
434pub async fn get_exercises_by_exam_id(
435    conn: &mut PgConnection,
436    exam_id: Uuid,
437) -> ModelResult<Vec<Exercise>> {
438    let exercises = sqlx::query_as!(
439        Exercise,
440        r#"
441SELECT *
442FROM exercises
443WHERE exam_id = $1
444  AND deleted_at IS NULL
445"#,
446        exam_id,
447    )
448    .fetch_all(&mut *conn)
449    .await?;
450    Ok(exercises)
451}
452
453pub async fn get_course_or_exam_id(
454    conn: &mut PgConnection,
455    id: Uuid,
456) -> ModelResult<CourseOrExamId> {
457    let res = sqlx::query!(
458        "
459SELECT course_id,
460  exam_id
461FROM exercises
462WHERE id = $1
463",
464        id
465    )
466    .fetch_one(conn)
467    .await?;
468    CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
469}
470
471pub async fn get_course_material_exercise(
472    conn: &mut PgConnection,
473    user_id: Option<Uuid>,
474    exercise_id: Uuid,
475    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
476) -> ModelResult<CourseMaterialExercise> {
477    let mut exercise = get_by_id(conn, exercise_id).await?;
478    if exercise.deadline.is_none()
479        && let Some(chapter_id) = exercise.chapter_id
480    {
481        let chapter = crate::chapters::get_chapter(conn, chapter_id).await?;
482        exercise.deadline = chapter.deadline;
483    }
484    let (current_exercise_slide, instance_or_exam_id) =
485        get_or_select_exercise_slide(&mut *conn, user_id, &exercise, fetch_service_info).await?;
486    info!(
487        "Current exercise slide id: {:#?}",
488        current_exercise_slide.id
489    );
490
491    let user_exercise_state = match (user_id, instance_or_exam_id) {
492        (Some(user_id), Some(course_or_exam_id)) => {
493            user_exercise_states::get_user_exercise_state_if_exists(
494                conn,
495                user_id,
496                exercise.id,
497                course_or_exam_id,
498            )
499            .await?
500        }
501        _ => None,
502    };
503
504    let can_post_submission =
505        determine_can_post_submission(&mut *conn, user_id, &exercise, &user_exercise_state).await?;
506
507    let previous_exercise_slide_submission = match user_id {
508        Some(user_id) => {
509            crate::exercise_slide_submissions::try_to_get_users_latest_exercise_slide_submission(
510                conn,
511                current_exercise_slide.id,
512                user_id,
513            )
514            .await?
515        }
516        _ => None,
517    };
518
519    let exercise_status = user_exercise_state.map(|user_exercise_state| ExerciseStatus {
520        score_given: user_exercise_state.score_given,
521        activity_progress: user_exercise_state.activity_progress,
522        grading_progress: user_exercise_state.grading_progress,
523        reviewing_stage: user_exercise_state.reviewing_stage,
524    });
525
526    let exercise_slide_submission_counts = if let Some(user_id) = user_id {
527        if let Some(cioreid) = instance_or_exam_id {
528            get_exercise_slide_submission_counts_for_exercise_user(
529                conn,
530                exercise_id,
531                cioreid,
532                user_id,
533            )
534            .await?
535        } else {
536            HashMap::new()
537        }
538    } else {
539        HashMap::new()
540    };
541
542    let peer_or_self_review_config = if let Some(course_id) = exercise.course_id {
543        if exercise.needs_peer_review || exercise.needs_self_review {
544            let prc = crate::peer_or_self_review_configs::get_by_exercise_or_course_id(
545                conn, &exercise, course_id,
546            )
547            .await
548            .optional()?;
549            prc.map(|prc| CourseMaterialPeerOrSelfReviewConfig {
550                id: prc.id,
551                course_id: prc.course_id,
552                exercise_id: prc.exercise_id,
553                peer_reviews_to_give: prc.peer_reviews_to_give,
554                peer_reviews_to_receive: prc.peer_reviews_to_receive,
555            })
556        } else {
557            None
558        }
559    } else {
560        None
561    };
562
563    let user_course_instance_exercise_service_variables = match (user_id, instance_or_exam_id) {
564        (Some(user_id), Some(course_or_exam_id)) => {
565            Some(crate::user_course_exercise_service_variables::get_all_variables_for_user_and_course_or_exam(conn, user_id, course_or_exam_id).await?)
566        }
567        _ => None,
568    }.unwrap_or_default();
569
570    let should_show_reset_message = if let Some(user_id) = user_id {
571        crate::exercise_reset_logs::user_should_see_reset_message_for_exercise(
572            conn,
573            user_id,
574            exercise_id,
575        )
576        .await?
577    } else {
578        None
579    };
580
581    Ok(CourseMaterialExercise {
582        exercise,
583        can_post_submission,
584        current_exercise_slide,
585        exercise_status,
586        exercise_slide_submission_counts,
587        peer_or_self_review_config,
588        user_course_instance_exercise_service_variables,
589        previous_exercise_slide_submission,
590        should_show_reset_message,
591    })
592}
593
594async fn determine_can_post_submission(
595    conn: &mut PgConnection,
596    user_id: Option<Uuid>,
597    exercise: &Exercise,
598    user_exercise_state: &Option<UserExerciseState>,
599) -> Result<bool, ModelError> {
600    if let Some(user_exercise_state) = user_exercise_state {
601        // 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.
602        if user_exercise_state.reviewing_stage != ReviewingStage::NotStarted {
603            return Ok(false);
604        }
605    }
606
607    let can_post_submission = if let Some(user_id) = user_id {
608        if let Some(exam_id) = exercise.exam_id {
609            exams::verify_exam_submission_can_be_made(conn, exam_id, user_id).await?
610        } else {
611            true
612        }
613    } else {
614        false
615    };
616    Ok(can_post_submission)
617}
618
619pub async fn get_or_select_exercise_slide(
620    conn: &mut PgConnection,
621    user_id: Option<Uuid>,
622    exercise: &Exercise,
623    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
624) -> ModelResult<(CourseMaterialExerciseSlide, Option<CourseOrExamId>)> {
625    match (user_id, exercise.course_id, exercise.exam_id) {
626        (None, ..) => {
627            // No signed in user. Show random exercise without model solution.
628            let random_slide =
629                exercise_slides::get_random_exercise_slide_for_exercise(conn, exercise.id).await?;
630            let random_slide_tasks = exercise_tasks::get_course_material_exercise_tasks(
631                conn,
632                random_slide.id,
633                None,
634                fetch_service_info,
635            )
636            .await?;
637            Ok((
638                CourseMaterialExerciseSlide {
639                    id: random_slide.id,
640                    exercise_tasks: random_slide_tasks,
641                },
642                None,
643            ))
644        }
645        (Some(user_id), Some(course_id), None) => {
646            // signed in, course exercise
647            let user_course_settings = user_course_settings::get_user_course_settings_by_course_id(
648                conn, user_id, course_id,
649            )
650            .await?;
651            match user_course_settings {
652                Some(settings) if settings.current_course_id == course_id => {
653                    // User is enrolled on an instance of the given course.
654                    let course_or_exam_id: CourseOrExamId = exercise.try_into()?;
655                    let tasks =
656                        exercise_tasks::get_or_select_user_exercise_slide_for_course_or_exam(
657                            conn,
658                            user_id,
659                            exercise.id,
660                            course_or_exam_id,
661                            fetch_service_info,
662                        )
663                        .await?;
664                    Ok((tasks, Some(CourseOrExamId::Course(course_id))))
665                }
666                Some(_) => {
667                    // User is enrolled on a different language version of the course. Show exercise
668                    // slide based on their latest enrollment or a random one.
669                    let exercise_tasks =
670                        exercise_tasks::get_existing_users_exercise_slide_for_course(
671                            conn,
672                            user_id,
673                            exercise.id,
674                            course_id,
675                            &fetch_service_info,
676                        )
677                        .await?;
678                    if let Some(exercise_tasks) = exercise_tasks {
679                        Ok((exercise_tasks, Some(CourseOrExamId::Course(course_id))))
680                    } else {
681                        // no exercise task has been chosen for the user
682                        let random_slide = exercise_slides::get_random_exercise_slide_for_exercise(
683                            conn,
684                            exercise.id,
685                        )
686                        .await?;
687                        let random_tasks = exercise_tasks::get_course_material_exercise_tasks(
688                            conn,
689                            random_slide.id,
690                            Some(user_id),
691                            &fetch_service_info,
692                        )
693                        .await?;
694
695                        Ok((
696                            CourseMaterialExerciseSlide {
697                                id: random_slide.id,
698                                exercise_tasks: random_tasks,
699                            },
700                            None,
701                        ))
702                    }
703                }
704                None => {
705                    // User is not enrolled on any course version. This is not a valid scenario because
706                    // tasks are based on a specific instance.
707                    Err(ModelError::new(
708                        ModelErrorType::PreconditionFailed,
709                        "User must be enrolled to the course".to_string(),
710                        None,
711                    ))
712                }
713            }
714        }
715        (Some(user_id), _, Some(exam_id)) => {
716            info!("selecting exam task");
717            // signed in, exam exercise
718            let tasks = exercise_tasks::get_or_select_user_exercise_slide_for_course_or_exam(
719                conn,
720                user_id,
721                exercise.id,
722                CourseOrExamId::Exam(exam_id),
723                fetch_service_info,
724            )
725            .await?;
726            info!("selecting exam task {:#?}", tasks);
727            Ok((tasks, Some(CourseOrExamId::Exam(exam_id))))
728        }
729        (Some(_), ..) => Err(ModelError::new(
730            ModelErrorType::Generic,
731            "The selected exercise is not attached to any course or exam".to_string(),
732            None,
733        )),
734    }
735}
736
737pub async fn delete_exercises_by_page_id(
738    conn: &mut PgConnection,
739    page_id: Uuid,
740) -> ModelResult<Vec<Uuid>> {
741    let deleted_ids = sqlx::query!(
742        "
743UPDATE exercises
744SET deleted_at = now()
745WHERE page_id = $1
746AND deleted_at IS NULL
747RETURNING id;
748        ",
749        page_id
750    )
751    .fetch_all(conn)
752    .await?
753    .into_iter()
754    .map(|x| x.id)
755    .collect();
756    Ok(deleted_ids)
757}
758
759pub async fn update_teacher_reviews_answer_after_locking(
760    conn: &mut PgConnection,
761    exercise_id: Uuid,
762    teacher_reviews_answer_after_locking: bool,
763) -> ModelResult<Exercise> {
764    let exercise = sqlx::query_as!(
765        Exercise,
766        r#"
767UPDATE exercises
768SET teacher_reviews_answer_after_locking = $2
769WHERE id = $1
770  AND deleted_at IS NULL
771RETURNING *
772        "#,
773        exercise_id,
774        teacher_reviews_answer_after_locking
775    )
776    .fetch_one(conn)
777    .await?;
778
779    Ok(exercise)
780}
781
782pub async fn set_exercise_to_use_exercise_specific_peer_or_self_review_config(
783    conn: &mut PgConnection,
784    exercise_id: Uuid,
785    needs_peer_review: bool,
786    needs_self_review: bool,
787    use_course_default_peer_or_self_review_config: bool,
788) -> ModelResult<Uuid> {
789    let id = sqlx::query!(
790        "
791UPDATE exercises
792SET use_course_default_peer_or_self_review_config = $1,
793  needs_peer_review = $2,
794  needs_self_review = $3
795WHERE id = $4
796RETURNING id;
797        ",
798        use_course_default_peer_or_self_review_config,
799        needs_peer_review,
800        needs_self_review,
801        exercise_id
802    )
803    .fetch_one(conn)
804    .await?;
805
806    Ok(id.id)
807}
808
809pub async fn get_all_exercise_statuses_by_user_id_and_course_id(
810    conn: &mut PgConnection,
811    course_id: Uuid,
812    user_id: Uuid,
813) -> ModelResult<Vec<ExerciseStatusSummaryForUser>> {
814    let course_or_exam_id = CourseOrExamId::Course(course_id);
815    // Load all the data for this user from all the exercises to memory, and group most of them to HashMaps by exercise id
816    let exercises = crate::exercises::get_exercises_by_course_id(&mut *conn, course_id).await?;
817    let mut user_exercise_states =
818        crate::user_exercise_states::get_all_for_user_and_course_or_exam(
819            &mut *conn,
820            user_id,
821            course_or_exam_id,
822        )
823        .await?
824        .into_iter()
825        .map(|ues| (ues.exercise_id, ues))
826        .collect::<HashMap<_, _>>();
827    let mut exercise_slide_submissions =
828        crate::exercise_slide_submissions::get_users_all_submissions_for_course_or_exam(
829            &mut *conn,
830            user_id,
831            course_or_exam_id,
832        )
833        .await?
834        .into_iter()
835        .into_group_map_by(|o| o.exercise_id);
836    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()
837        .into_group_map_by(|o| o.exercise_id);
838    let given_submission_ids: Vec<Uuid> = given_peer_or_self_review_submissions
839        .values()
840        .flatten()
841        .map(|prs| prs.exercise_slide_submission_id)
842        .collect();
843    let submission_owner_user_ids =
844        crate::exercise_slide_submissions::get_user_ids_by_submission_ids(
845            &mut *conn,
846            &given_submission_ids,
847        )
848        .await?;
849    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()
850        .into_group_map_by(|o| o.exercise_id);
851    let given_peer_or_self_review_submission_ids = given_peer_or_self_review_submissions
852        .values()
853        .flatten()
854        .map(|x| x.id)
855        .collect::<Vec<_>>();
856    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?
857        .into_iter()
858        .into_group_map_by(|o| {
859            let peer_review_submission = given_peer_or_self_review_submissions.clone().into_iter()
860                .find(|(_exercise_id, prs)| prs.iter().any(|p| p.id == o.peer_or_self_review_submission_id))
861                .unwrap_or_else(|| (Uuid::nil(), vec![]));
862            peer_review_submission.0
863    });
864    let received_peer_or_self_review_submission_ids = received_peer_or_self_review_submissions
865        .values()
866        .flatten()
867        .map(|x| x.id)
868        .collect::<Vec<_>>();
869    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()
870    .into_group_map_by(|o| {
871        let peer_review_submission = received_peer_or_self_review_submissions.clone().into_iter()
872            .find(|(_exercise_id, prs)| prs.iter().any(|p| p.id == o.peer_or_self_review_submission_id))
873            .unwrap_or_else(|| (Uuid::nil(), vec![]));
874        peer_review_submission.0
875    });
876    let mut peer_review_queue_entries =
877        crate::peer_review_queue_entries::get_all_by_user_and_course_id(
878            &mut *conn, user_id, course_id,
879        )
880        .await?
881        .into_iter()
882        .map(|x| (x.exercise_id, x))
883        .collect::<HashMap<_, _>>();
884    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()
885    .filter_map(|tgd| {
886        let user_exercise_state = user_exercise_states.clone().into_iter()
887            .find(|(_exercise_id, ues)|  ues.id == tgd.user_exercise_state_id)?;
888        Some((user_exercise_state.0, tgd))
889    }).collect::<HashMap<_, _>>();
890    let all_peer_or_self_review_question_ids = given_peer_or_self_review_question_submissions
891        .iter()
892        .chain(received_peer_or_self_review_question_submissions.iter())
893        .flat_map(|(_exercise_id, prqs)| prqs.iter().map(|p| p.peer_or_self_review_question_id))
894        .collect::<Vec<_>>();
895    let all_peer_or_self_review_questions = crate::peer_or_self_review_questions::get_by_ids(
896        &mut *conn,
897        &all_peer_or_self_review_question_ids,
898    )
899    .await?;
900
901    // Map all the data for all the exercises to be summaries of the data for each exercise.
902    //
903    // Since all data is in hashmaps grouped by exercise id, and we iterate though every
904    // exercise id exactly once, we can just remove the data for the exercise from the
905    // hashmaps and avoid extra copying.
906    let res = exercises
907        .into_iter()
908        .map(|exercise| {
909            let user_exercise_state = user_exercise_states.remove(&exercise.id);
910            let exercise_slide_submissions = exercise_slide_submissions
911                .remove(&exercise.id)
912                .unwrap_or_default();
913            let given_peer_or_self_review_submissions = given_peer_or_self_review_submissions
914                .remove(&exercise.id)
915                .unwrap_or_default()
916                .into_iter()
917                .map(|prs| {
918                    let submission_owner_user_id = submission_owner_user_ids
919                        .get(&prs.exercise_slide_submission_id)
920                        .copied();
921                    PeerOrSelfReviewSubmissionWithSubmissionOwner {
922                        submission: prs,
923                        submission_owner_user_id,
924                    }
925                })
926                .collect();
927            let received_peer_or_self_review_submissions = received_peer_or_self_review_submissions
928                .remove(&exercise.id)
929                .unwrap_or_default()
930                .into_iter()
931                .map(|prs| PeerOrSelfReviewSubmissionWithSubmissionOwner {
932                    submission: prs,
933                    submission_owner_user_id: None,
934                })
935                .collect();
936            let given_peer_or_self_review_question_submissions =
937                given_peer_or_self_review_question_submissions
938                    .remove(&exercise.id)
939                    .unwrap_or_default();
940            let received_peer_or_self_review_question_submissions =
941                received_peer_or_self_review_question_submissions
942                    .remove(&exercise.id)
943                    .unwrap_or_default();
944            let peer_review_queue_entry = peer_review_queue_entries.remove(&exercise.id);
945            let teacher_grading_decision = teacher_grading_decisions.remove(&exercise.id);
946            let peer_or_self_review_question_ids = given_peer_or_self_review_question_submissions
947                .iter()
948                .chain(received_peer_or_self_review_question_submissions.iter())
949                .map(|prqs| prqs.peer_or_self_review_question_id)
950                .unique()
951                .collect::<Vec<_>>();
952            let peer_or_self_review_questions = all_peer_or_self_review_questions
953                .iter()
954                .filter(|prq| peer_or_self_review_question_ids.contains(&prq.id))
955                .cloned()
956                .collect::<Vec<_>>();
957            ExerciseStatusSummaryForUser {
958                exercise,
959                user_exercise_state,
960                exercise_slide_submissions,
961                given_peer_or_self_review_submissions,
962                received_peer_or_self_review_submissions,
963                given_peer_or_self_review_question_submissions,
964                received_peer_or_self_review_question_submissions,
965                peer_review_queue_entry,
966                teacher_grading_decision,
967                peer_or_self_review_questions,
968            }
969        })
970        .collect::<Vec<_>>();
971    Ok(res)
972}
973
974pub async fn get_exercises_by_module_containing_exercise_type(
975    conn: &mut PgConnection,
976    exercise_type: &str,
977    course_module_id: Uuid,
978) -> ModelResult<Vec<Exercise>> {
979    let res: Vec<Exercise> = sqlx::query_as!(
980        Exercise,
981        r#"
982SELECT DISTINCT(ex.*)
983FROM exercises ex
984  JOIN exercise_slides slides ON ex.id = slides.exercise_id
985  JOIN exercise_tasks tasks ON slides.id = tasks.exercise_slide_id
986  JOIN chapters c ON ex.chapter_id = c.id
987where tasks.exercise_type = $1
988  AND c.course_module_id = $2
989  AND ex.deleted_at IS NULL
990  AND tasks.deleted_at IS NULL
991  and c.deleted_at IS NULL
992  and slides.deleted_at IS NULL
993        "#,
994        exercise_type,
995        course_module_id
996    )
997    .fetch_all(conn)
998    .await?;
999    Ok(res)
1000}
1001
1002/// Collects user_ids and related exercise_ids according to given filters
1003pub async fn collect_user_ids_and_exercise_ids_for_reset(
1004    conn: &mut PgConnection,
1005    user_ids: &[Uuid],
1006    exercise_ids: &[Uuid],
1007    threshold: Option<f64>,
1008    reset_all_below_max: bool,
1009    reset_only_locked_reviews: bool,
1010) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1011    let results = sqlx::query!(
1012        r#"
1013SELECT DISTINCT ues.user_id,
1014  ues.exercise_id
1015FROM user_exercise_states ues
1016  LEFT JOIN exercises e ON ues.exercise_id = e.id
1017WHERE ues.user_id = ANY($1)
1018  AND ues.exercise_id = ANY($2)
1019  AND ues.deleted_at IS NULL
1020  AND (
1021    $3 = FALSE
1022    OR ues.score_given < e.score_maximum
1023  )
1024  AND (
1025    $4::FLOAT IS NULL
1026    OR ues.score_given < $4::FLOAT
1027  )
1028  AND (
1029    $5 = FALSE
1030    OR ues.reviewing_stage = 'reviewed_and_locked'
1031  )
1032            "#,
1033        user_ids,
1034        exercise_ids,
1035        reset_all_below_max,
1036        threshold,
1037        reset_only_locked_reviews
1038    )
1039    .fetch_all(&mut *conn)
1040    .await?;
1041
1042    let mut user_exercise_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
1043    for row in &results {
1044        user_exercise_map
1045            .entry(row.user_id)
1046            .or_default()
1047            .push(row.exercise_id);
1048    }
1049
1050    Ok(user_exercise_map.into_iter().collect())
1051}
1052
1053/// Resolves affected chapter ids for exercise resets in a course.
1054async fn get_chapter_ids_for_exercises_in_course(
1055    conn: &mut PgConnection,
1056    exercise_ids: &[Uuid],
1057    course_id: Uuid,
1058) -> ModelResult<Vec<Uuid>> {
1059    let mut chapter_ids = HashSet::new();
1060    for exercise_id in exercise_ids {
1061        let exercise = get_exercise_by_id(conn, *exercise_id).await?;
1062        if exercise.course_id == Some(course_id)
1063            && let Some(chapter_id) = exercise.chapter_id
1064        {
1065            chapter_ids.insert(chapter_id);
1066        }
1067    }
1068    Ok(chapter_ids.into_iter().collect())
1069}
1070
1071/// Resets all related tables for selected users and related exercises.
1072pub async fn reset_exercises_for_selected_users(
1073    conn: &mut PgConnection,
1074    users_and_exercises: &[(Uuid, Vec<Uuid>)],
1075    reset_by: Option<Uuid>,
1076    course_id: Uuid,
1077    reason: Option<String>,
1078) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1079    let mut successful_resets = Vec::new();
1080    let mut tx = conn.begin().await?;
1081    for (user_id, exercise_ids) in users_and_exercises {
1082        sqlx::query!(
1083            r#"
1084UPDATE exercise_slide_submissions
1085SET deleted_at = NOW()
1086WHERE user_id = $1
1087  AND exercise_id = ANY($2)
1088  AND deleted_at IS NULL
1089            "#,
1090            user_id,
1091            exercise_ids
1092        )
1093        .execute(&mut *tx)
1094        .await?;
1095
1096        sqlx::query!(
1097            r#"
1098UPDATE exercise_task_submissions
1099SET deleted_at = NOW()
1100WHERE 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  AND deleted_at IS NULL
1107            "#,
1108            user_id,
1109            exercise_ids
1110        )
1111        .execute(&mut *tx)
1112        .await?;
1113
1114        sqlx::query!(
1115            r#"
1116UPDATE peer_review_queue_entries
1117SET deleted_at = NOW()
1118WHERE user_id = $1
1119  AND exercise_id = ANY($2)
1120  AND deleted_at IS NULL
1121            "#,
1122            user_id,
1123            exercise_ids
1124        )
1125        .execute(&mut *tx)
1126        .await?;
1127
1128        sqlx::query!(
1129            r#"
1130UPDATE exercise_task_gradings
1131SET deleted_at = NOW()
1132WHERE exercise_task_submission_id IN (
1133    SELECT id
1134    FROM exercise_task_submissions
1135    WHERE exercise_slide_submission_id IN (
1136        SELECT id
1137        FROM exercise_slide_submissions
1138        WHERE user_id = $1
1139          AND exercise_id = ANY($2)
1140      )
1141  )
1142  AND deleted_at IS NULL
1143            "#,
1144            user_id,
1145            exercise_ids
1146        )
1147        .execute(&mut *tx)
1148        .await?;
1149
1150        sqlx::query!(
1151            r#"
1152UPDATE user_exercise_states
1153SET deleted_at = NOW()
1154WHERE user_id = $1
1155  AND exercise_id = ANY($2)
1156  AND deleted_at IS NULL
1157            "#,
1158            user_id,
1159            exercise_ids
1160        )
1161        .execute(&mut *tx)
1162        .await?;
1163
1164        sqlx::query!(
1165            r#"
1166UPDATE user_exercise_task_states
1167SET deleted_at = NOW()
1168WHERE user_exercise_slide_state_id IN (
1169    SELECT id
1170    FROM user_exercise_slide_states
1171    WHERE user_exercise_state_id IN (
1172        SELECT id
1173        FROM user_exercise_states
1174        WHERE user_id = $1
1175          AND exercise_id = ANY($2)
1176      )
1177  )
1178  AND deleted_at IS NULL
1179            "#,
1180            user_id,
1181            exercise_ids
1182        )
1183        .execute(&mut *tx)
1184        .await?;
1185
1186        sqlx::query!(
1187            r#"
1188UPDATE user_exercise_slide_states
1189SET deleted_at = NOW()
1190WHERE user_exercise_state_id IN (
1191    SELECT id
1192    FROM user_exercise_states
1193    WHERE user_id = $1
1194      AND exercise_id = ANY($2)
1195  )
1196  AND deleted_at IS NULL
1197            "#,
1198            user_id,
1199            exercise_ids
1200        )
1201        .execute(&mut *tx)
1202        .await?;
1203
1204        sqlx::query!(
1205            r#"
1206UPDATE teacher_grading_decisions
1207SET deleted_at = NOW()
1208WHERE user_exercise_state_id IN (
1209    SELECT id
1210    FROM user_exercise_states
1211    WHERE user_id = $1
1212      AND exercise_id = ANY($2)
1213  )
1214  AND deleted_at IS NULL
1215            "#,
1216            user_id,
1217            exercise_ids
1218        )
1219        .execute(&mut *tx)
1220        .await?;
1221
1222        // Adds a log of a reset exercise for a user
1223        exercise_reset_logs::log_exercise_reset(
1224            &mut tx,
1225            reset_by,
1226            *user_id,
1227            exercise_ids,
1228            course_id,
1229            reason.clone(),
1230        )
1231        .await?;
1232
1233        let chapter_ids =
1234            get_chapter_ids_for_exercises_in_course(&mut tx, exercise_ids, course_id).await?;
1235        // Keep chapters that are still in their initial `not_unlocked_yet` state untouched.
1236        user_chapter_locking_statuses::unlock_chapters_for_user(
1237            &mut tx,
1238            *user_id,
1239            course_id,
1240            &chapter_ids,
1241        )
1242        .await?;
1243
1244        successful_resets.push((*user_id, exercise_ids.to_vec()));
1245    }
1246    tx.commit().await?;
1247    Ok(successful_resets)
1248}
1249
1250pub async fn reset_progress_by_course_id_user_ids_and_exercise_ids(
1251    conn: &mut PgConnection,
1252    course_id: Uuid,
1253    user_ids: &[Uuid],
1254    exercise_ids: &[Uuid],
1255    reset_by: Option<Uuid>,
1256    reason: Option<String>,
1257) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1258    let mut successful_resets = Vec::new();
1259    let mut tx = conn.begin().await?;
1260    let validated_exercise_ids = sqlx::query!(
1261        r#"
1262SELECT id
1263FROM exercises
1264WHERE id = ANY($1)
1265  AND course_id = $2
1266  AND deleted_at IS NULL
1267        "#,
1268        exercise_ids,
1269        course_id
1270    )
1271    .fetch_all(&mut *tx)
1272    .await?
1273    .into_iter()
1274    .map(|row| row.id)
1275    .collect::<Vec<_>>();
1276
1277    if validated_exercise_ids.is_empty() {
1278        tx.commit().await?;
1279        return Ok(successful_resets);
1280    }
1281
1282    for user_id in user_ids {
1283        sqlx::query!(
1284            r#"
1285UPDATE exercise_slide_submissions
1286SET deleted_at = NOW()
1287WHERE user_id = $1
1288  AND course_id = $2
1289  AND exercise_id = ANY($3)
1290  AND deleted_at IS NULL
1291            "#,
1292            user_id,
1293            course_id,
1294            &validated_exercise_ids
1295        )
1296        .execute(&mut *tx)
1297        .await?;
1298
1299        sqlx::query!(
1300            r#"
1301UPDATE exercise_task_submissions
1302SET deleted_at = NOW()
1303WHERE exercise_slide_submission_id IN (
1304    SELECT id
1305    FROM exercise_slide_submissions
1306    WHERE user_id = $1
1307      AND course_id = $2
1308      AND exercise_id = ANY($3)
1309  )
1310  AND deleted_at IS NULL
1311            "#,
1312            user_id,
1313            course_id,
1314            &validated_exercise_ids
1315        )
1316        .execute(&mut *tx)
1317        .await?;
1318
1319        sqlx::query!(
1320            r#"
1321UPDATE peer_review_queue_entries
1322SET deleted_at = NOW()
1323WHERE user_id = $1
1324  AND exercise_id = ANY($2)
1325  AND deleted_at IS NULL
1326            "#,
1327            user_id,
1328            &validated_exercise_ids
1329        )
1330        .execute(&mut *tx)
1331        .await?;
1332
1333        sqlx::query!(
1334            r#"
1335UPDATE exercise_task_gradings
1336SET deleted_at = NOW()
1337WHERE exercise_task_submission_id IN (
1338    SELECT ets.id
1339    FROM exercise_task_submissions ets
1340      JOIN exercise_slide_submissions ess
1341        ON ess.id = ets.exercise_slide_submission_id
1342    WHERE ess.user_id = $1
1343      AND ess.course_id = $2
1344      AND ess.exercise_id = ANY($3)
1345  )
1346  AND deleted_at IS NULL
1347            "#,
1348            user_id,
1349            course_id,
1350            &validated_exercise_ids
1351        )
1352        .execute(&mut *tx)
1353        .await?;
1354
1355        sqlx::query!(
1356            r#"
1357UPDATE teacher_grading_decisions
1358SET deleted_at = NOW()
1359WHERE user_exercise_state_id IN (
1360    SELECT id
1361    FROM user_exercise_states
1362    WHERE user_id = $1
1363      AND course_id = $2
1364      AND exercise_id = ANY($3)
1365      AND deleted_at IS NULL
1366  )
1367  AND deleted_at IS NULL
1368            "#,
1369            user_id,
1370            course_id,
1371            &validated_exercise_ids
1372        )
1373        .execute(&mut *tx)
1374        .await?;
1375
1376        sqlx::query!(
1377            r#"
1378UPDATE user_exercise_task_states
1379SET deleted_at = NOW()
1380WHERE user_exercise_slide_state_id IN (
1381    SELECT uess.id
1382    FROM user_exercise_slide_states uess
1383      JOIN user_exercise_states ues ON ues.id = uess.user_exercise_state_id
1384    WHERE ues.user_id = $1
1385      AND ues.course_id = $2
1386      AND ues.exercise_id = ANY($3)
1387  )
1388  AND deleted_at IS NULL
1389            "#,
1390            user_id,
1391            course_id,
1392            &validated_exercise_ids
1393        )
1394        .execute(&mut *tx)
1395        .await?;
1396
1397        sqlx::query!(
1398            r#"
1399UPDATE user_exercise_slide_states
1400SET deleted_at = NOW()
1401WHERE user_exercise_state_id IN (
1402    SELECT id
1403    FROM user_exercise_states
1404    WHERE user_id = $1
1405      AND course_id = $2
1406      AND exercise_id = ANY($3)
1407  )
1408  AND deleted_at IS NULL
1409            "#,
1410            user_id,
1411            course_id,
1412            &validated_exercise_ids
1413        )
1414        .execute(&mut *tx)
1415        .await?;
1416
1417        sqlx::query!(
1418            r#"
1419UPDATE user_exercise_states
1420SET deleted_at = NOW()
1421WHERE user_id = $1
1422  AND course_id = $2
1423  AND exercise_id = ANY($3)
1424  AND deleted_at IS NULL
1425            "#,
1426            user_id,
1427            course_id,
1428            &validated_exercise_ids
1429        )
1430        .execute(&mut *tx)
1431        .await?;
1432
1433        exercise_reset_logs::log_exercise_reset(
1434            &mut tx,
1435            reset_by,
1436            *user_id,
1437            &validated_exercise_ids,
1438            course_id,
1439            reason.clone(),
1440        )
1441        .await?;
1442
1443        let chapter_ids =
1444            get_chapter_ids_for_exercises_in_course(&mut tx, &validated_exercise_ids, course_id)
1445                .await?;
1446        user_chapter_locking_statuses::unlock_chapters_for_user(
1447            &mut tx,
1448            *user_id,
1449            course_id,
1450            &chapter_ids,
1451        )
1452        .await?;
1453
1454        successful_resets.push((*user_id, validated_exercise_ids.clone()));
1455    }
1456    tx.commit().await?;
1457    Ok(successful_resets)
1458}
1459
1460#[cfg(test)]
1461mod test {
1462    use super::*;
1463    use crate::{
1464        chapters,
1465        course_instance_enrollments::{self, NewCourseInstanceEnrollment},
1466        courses,
1467        exercise_service_info::{self, PathInfo},
1468        exercise_services::{self, ExerciseServiceNewOrUpdate},
1469        test_helper::Conn,
1470        test_helper::*,
1471        user_chapter_locking_statuses::{self, ChapterLockingStatus},
1472        user_exercise_states,
1473    };
1474    use chrono::TimeZone;
1475    use sqlx::PgConnection;
1476
1477    async fn insert_exercise_service_with_info(tx: &mut PgConnection) {
1478        let exercise_service = exercise_services::insert_exercise_service(
1479            tx,
1480            &ExerciseServiceNewOrUpdate {
1481                name: "text-exercise".to_string(),
1482                slug: TEST_HELPER_EXERCISE_SERVICE_NAME.to_string(),
1483                public_url: "https://example.com".to_string(),
1484                internal_url: None,
1485                max_reprocessing_submissions_at_once: 1,
1486            },
1487        )
1488        .await
1489        .unwrap();
1490        exercise_service_info::insert(
1491            tx,
1492            &PathInfo {
1493                exercise_service_id: exercise_service.id,
1494                user_interface_iframe_path: "/iframe".to_string(),
1495                grade_endpoint_path: "/grade".to_string(),
1496                public_spec_endpoint_path: "/public-spec".to_string(),
1497                model_solution_spec_endpoint_path: "test-only-empty-path".to_string(),
1498                has_custom_view: false,
1499            },
1500        )
1501        .await
1502        .unwrap();
1503    }
1504
1505    #[tokio::test]
1506    async fn selects_course_material_exercise_for_enrolled_student() {
1507        insert_data!(
1508            :tx,
1509            user: user_id,
1510            org: _organization_id,
1511            course: course_id,
1512            instance: course_instance,
1513            :course_module,
1514            chapter: chapter_id,
1515            page: _page_id,
1516            exercise: exercise_id,
1517            slide: exercise_slide_id,
1518            task: exercise_task_id
1519        );
1520        insert_exercise_service_with_info(tx.as_mut()).await;
1521        course_instance_enrollments::insert_enrollment_and_set_as_current(
1522            tx.as_mut(),
1523            NewCourseInstanceEnrollment {
1524                course_id,
1525                course_instance_id: course_instance.id,
1526                user_id,
1527            },
1528        )
1529        .await
1530        .unwrap();
1531
1532        let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
1533            tx.as_mut(),
1534            user_id,
1535            exercise_id,
1536            CourseOrExamId::Course(course_id),
1537        )
1538        .await
1539        .unwrap();
1540        assert!(user_exercise_state.is_none());
1541
1542        let exercise = get_course_material_exercise(
1543            tx.as_mut(),
1544            Some(user_id),
1545            exercise_id,
1546            |_| unimplemented!(),
1547        )
1548        .await
1549        .unwrap();
1550        assert_eq!(
1551            exercise
1552                .current_exercise_slide
1553                .exercise_tasks
1554                .first()
1555                .unwrap()
1556                .id,
1557            exercise_task_id
1558        );
1559
1560        let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
1561            tx.as_mut(),
1562            user_id,
1563            exercise_id,
1564            CourseOrExamId::Course(course_id),
1565        )
1566        .await
1567        .unwrap();
1568        assert_eq!(
1569            user_exercise_state
1570                .unwrap()
1571                .selected_exercise_slide_id
1572                .unwrap(),
1573            exercise_slide_id
1574        );
1575    }
1576
1577    #[tokio::test]
1578    async fn course_material_exercise_inherits_chapter_deadline() {
1579        insert_data!(
1580            :tx,
1581            user: user_id,
1582            org: organization_id,
1583            course: course_id,
1584            instance: course_instance,
1585            :course_module,
1586            chapter: chapter_id,
1587            page: page_id,
1588            exercise: exercise_id,
1589            slide: exercise_slide_id,
1590            task: _exercise_task_id
1591        );
1592        insert_exercise_service_with_info(tx.as_mut()).await;
1593        course_instance_enrollments::insert_enrollment_and_set_as_current(
1594            tx.as_mut(),
1595            NewCourseInstanceEnrollment {
1596                course_id,
1597                course_instance_id: course_instance.id,
1598                user_id,
1599            },
1600        )
1601        .await
1602        .unwrap();
1603
1604        let chapter_deadline = Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap();
1605        let chapter = chapters::get_chapter(tx.as_mut(), chapter_id)
1606            .await
1607            .unwrap();
1608        chapters::update_chapter(
1609            tx.as_mut(),
1610            chapter_id,
1611            chapters::ChapterUpdate {
1612                name: chapter.name,
1613                color: chapter.color,
1614                front_page_id: chapter.front_page_id,
1615                deadline: Some(chapter_deadline),
1616                opens_at: chapter.opens_at,
1617                course_module_id: Some(chapter.course_module_id),
1618            },
1619        )
1620        .await
1621        .unwrap();
1622
1623        let exercise = get_course_material_exercise(
1624            tx.as_mut(),
1625            Some(user_id),
1626            exercise_id,
1627            |_| unimplemented!(),
1628        )
1629        .await
1630        .unwrap();
1631
1632        assert_eq!(exercise.exercise.deadline, Some(chapter_deadline));
1633    }
1634
1635    #[tokio::test]
1636    async fn resetting_exercise_unlocks_its_chapter_for_user() {
1637        insert_data!(
1638            :tx,
1639            user: user_id,
1640            org: _organization_id,
1641            course: course_id,
1642            instance: _course_instance,
1643            :course_module,
1644            chapter: chapter_id,
1645            page: _page_id,
1646            exercise: exercise_id,
1647            slide: _exercise_slide_id,
1648            task: _exercise_task_id
1649        );
1650
1651        let existing_course = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1652        courses::update_course(
1653            tx.as_mut(),
1654            course_id,
1655            courses::CourseUpdate {
1656                name: existing_course.name,
1657                description: existing_course.description,
1658                is_draft: existing_course.is_draft,
1659                is_test_mode: existing_course.is_test_mode,
1660                can_add_chatbot: existing_course.can_add_chatbot,
1661                is_unlisted: existing_course.is_unlisted,
1662                is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
1663                ask_marketing_consent: existing_course.ask_marketing_consent,
1664                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
1665                flagged_answers_skip_manual_review_and_allow_retry: existing_course
1666                    .flagged_answers_skip_manual_review_and_allow_retry,
1667                closed_at: existing_course.closed_at,
1668                closed_additional_message: existing_course.closed_additional_message,
1669                closed_course_successor_id: existing_course.closed_course_successor_id,
1670                chapter_locking_enabled: true,
1671            },
1672        )
1673        .await
1674        .unwrap();
1675
1676        user_chapter_locking_statuses::complete_and_lock_chapter(
1677            tx.as_mut(),
1678            user_id,
1679            chapter_id,
1680            course_id,
1681        )
1682        .await
1683        .unwrap();
1684
1685        reset_exercises_for_selected_users(
1686            tx.as_mut(),
1687            &[(user_id, vec![exercise_id])],
1688            Some(user_id),
1689            course_id,
1690            Some("test-reset".to_string()),
1691        )
1692        .await
1693        .unwrap();
1694
1695        let status = user_chapter_locking_statuses::get_or_init_status(
1696            tx.as_mut(),
1697            user_id,
1698            chapter_id,
1699            Some(course_id),
1700            Some(true),
1701        )
1702        .await
1703        .unwrap();
1704
1705        assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
1706    }
1707}