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    /// The latest non-FullPoints grading decision addressed to the student, unless the teacher marked it hidden.
109    pub teacher_grading_decision: Option<CourseMaterialTeacherGradingDecision>,
110}
111
112/// What the student is told about a teacher's grading decision.
113///
114/// The decision type is included so the material can explain the decision in the student's own
115/// language; the justification is whatever the teacher wrote on top of that, if anything.
116#[derive(Debug, Serialize, Deserialize, ToSchema)]
117pub struct CourseMaterialTeacherGradingDecision {
118    pub teacher_decision: TeacherDecisionType,
119    pub justification: Option<String>,
120}
121
122impl CourseMaterialExercise {
123    pub fn clear_grading_information(&mut self) {
124        self.exercise_status = None;
125        self.teacher_grading_decision = None;
126        self.current_exercise_slide
127            .exercise_tasks
128            .iter_mut()
129            .for_each(|task| {
130                task.model_solution_spec = None;
131                task.previous_submission_grading = None;
132            });
133    }
134
135    pub fn clear_model_solution_specs(&mut self) {
136        self.current_exercise_slide
137            .exercise_tasks
138            .iter_mut()
139            .for_each(|task| {
140                task.model_solution_spec = None;
141            });
142    }
143}
144
145/**
146Indicates what is the user's completion status for a exercise.
147
148As close as possible to LTI's activity progress for compatibility: <https://www.imsglobal.org/spec/lti-ags/v2p0#activityprogress>.
149*/
150#[derive(
151    Debug,
152    Serialize,
153    Deserialize,
154    PartialEq,
155    Eq,
156    Clone,
157    Copy,
158    Default,
159    Display,
160    sqlx::Type,
161    ToSchema,
162)]
163#[sqlx(type_name = "activity_progress", rename_all = "kebab-case")]
164pub enum ActivityProgress {
165    /// The user has not started the activity, or the activity has been reset for that student.
166    #[default]
167    Initialized,
168    /// The activity associated with the exercise has been started by the user to which the result relates.
169    Started,
170    /// The activity is being drafted and is available for comment.
171    InProgress,
172    /// The activity has been submitted at least once by the user but the user is still able make further submissions.
173    Submitted,
174    /// The user has completed the activity associated with the exercise.
175    Completed,
176}
177
178/**
179
180Tells what's the status of the grading progress for a user and exercise.
181
182As close as possible LTI's grading progress for compatibility: <https://www.imsglobal.org/spec/lti-ags/v2p0#gradingprogress>
183*/
184#[derive(
185    Clone,
186    Copy,
187    Debug,
188    Deserialize,
189    Eq,
190    Serialize,
191    Ord,
192    PartialEq,
193    PartialOrd,
194    Display,
195    sqlx::Type,
196    ToSchema,
197)]
198#[sqlx(type_name = "grading_progress", rename_all = "kebab-case")]
199pub enum GradingProgress {
200    /// The grading could not complete.
201    Failed,
202    /// There is no grading process occurring; for example, the student has not yet made any submission.
203    NotReady,
204    /// 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.
205    PendingManual,
206    /// 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.
207    Pending,
208    /// The grading process is completed; the score value, if any, represents the current Final Grade;
209    FullyGraded,
210}
211
212impl GradingProgress {
213    pub fn is_complete(self) -> bool {
214        self == Self::FullyGraded || self == Self::Failed
215    }
216}
217
218#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
219
220pub struct ExerciseStatus {
221    // None when grading has not completed yet. Max score can be found from the associated exercise.
222    pub score_given: Option<f32>,
223    pub activity_progress: ActivityProgress,
224    pub grading_progress: GradingProgress,
225    pub reviewing_stage: ReviewingStage,
226}
227
228/// How many exercises of the course with peer or self reviews use its default review config.
229pub async fn count_using_course_default_review_config(
230    conn: &mut PgConnection,
231    course_id: Uuid,
232) -> ModelResult<i64> {
233    let count = sqlx::query_scalar!(
234        r#"
235SELECT COUNT(*) AS "count!"
236FROM exercises
237WHERE course_id = $1
238  AND use_course_default_peer_or_self_review_config
239  AND (
240    needs_peer_review
241    OR needs_self_review
242  )
243  AND deleted_at IS NULL
244        "#,
245        course_id
246    )
247    .fetch_one(conn)
248    .await?;
249    Ok(count)
250}
251
252#[allow(clippy::too_many_arguments)]
253pub async fn insert(
254    conn: &mut PgConnection,
255    pkey_policy: PKeyPolicy<Uuid>,
256    course_id: Uuid,
257    name: &str,
258    page_id: Uuid,
259    chapter_id: Uuid,
260    order_number: i32,
261) -> ModelResult<Uuid> {
262    let course = crate::courses::get_course(conn, course_id).await?;
263    let exercise_language_group_id = crate::exercise_language_groups::insert(
264        conn,
265        PKeyPolicy::Generate,
266        course.course_language_group_id,
267    )
268    .await?;
269
270    let res = sqlx::query!(
271        "
272INSERT INTO exercises (
273    id,
274    course_id,
275    name,
276    page_id,
277    chapter_id,
278    order_number,
279    exercise_language_group_id
280  )
281VALUES ($1, $2, $3, $4, $5, $6, $7)
282RETURNING *
283        ",
284        pkey_policy.into_uuid(),
285        course_id,
286        name,
287        page_id,
288        chapter_id,
289        order_number,
290        exercise_language_group_id,
291    )
292    .fetch_one(conn)
293    .await?;
294    Ok(res.id)
295}
296
297pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
298    let exercise = sqlx::query_as!(
299        Exercise,
300        "
301SELECT *
302FROM exercises
303WHERE id = $1
304",
305        id
306    )
307    .fetch_one(conn)
308    .await?;
309    Ok(exercise)
310}
311
312pub async fn get_non_deleted_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
313    let exercise = sqlx::query_as!(
314        Exercise,
315        "
316SELECT *
317FROM exercises
318WHERE id = $1
319  AND deleted_at IS NULL
320",
321        id
322    )
323    .fetch_one(conn)
324    .await?;
325    Ok(exercise)
326}
327
328pub async fn get_non_deleted_by_ids(
329    conn: &mut PgConnection,
330    ids: &[Uuid],
331) -> ModelResult<Vec<Exercise>> {
332    let exercises = sqlx::query_as!(
333        Exercise,
334        "
335SELECT *
336FROM exercises
337WHERE id = ANY($1)
338  AND deleted_at IS NULL
339",
340        ids
341    )
342    .fetch_all(conn)
343    .await?;
344    Ok(exercises)
345}
346
347pub async fn get_exercise_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
348    let exercise = sqlx::query_as!(Exercise, "SELECT * FROM exercises WHERE id = $1;", id)
349        .fetch_one(conn)
350        .await?;
351    Ok(exercise)
352}
353
354pub async fn get_exercises_by_course_id(
355    conn: &mut PgConnection,
356    course_id: Uuid,
357) -> ModelResult<Vec<Exercise>> {
358    let exercises = sqlx::query_as!(
359        Exercise,
360        r#"
361SELECT *
362FROM exercises
363WHERE course_id = $1
364  AND deleted_at IS NULL
365"#,
366        course_id
367    )
368    .fetch_all(&mut *conn)
369    .await?;
370    Ok(exercises)
371}
372
373/// The course's exercises among `ids`, skipping deleted ones and other courses' exercises.
374pub async fn get_course_exercises_by_ids(
375    conn: &mut PgConnection,
376    course_id: Uuid,
377    ids: &[Uuid],
378) -> ModelResult<Vec<Exercise>> {
379    let exercises = sqlx::query_as!(
380        Exercise,
381        r#"
382SELECT *
383FROM exercises
384WHERE course_id = $1
385  AND id = ANY($2)
386  AND deleted_at IS NULL
387"#,
388        course_id,
389        ids
390    )
391    .fetch_all(&mut *conn)
392    .await?;
393    Ok(exercises)
394}
395
396pub async fn get_exercise_submissions_and_status_by_course_instance_id(
397    conn: &mut PgConnection,
398    course_instance_id: Uuid,
399    user_id: Uuid,
400) -> ModelResult<Vec<ExerciseGradingStatus>> {
401    let exercises = sqlx::query_as!(
402        ExerciseGradingStatus,
403        r#"
404        SELECT
405        e.id as exercise_id,
406        e.name as exercise_name,
407        e.score_maximum,
408        ues.score_given,
409        tgd.teacher_decision,
410        ess.id as submission_id,
411        ess.updated_at
412        FROM exercises e
413        LEFT JOIN user_exercise_states ues on e.id = ues.exercise_id
414        LEFT JOIN teacher_grading_decisions tgd on tgd.user_exercise_state_id = ues.id
415        LEFT JOIN exercise_slide_submissions ess on e.id = ess.exercise_id
416        WHERE e.course_id = (
417            SELECT course_id
418            FROM course_instances
419            WHERE id = $1
420          )
421          AND e.deleted_at IS NULL
422          AND ess.user_id = $2
423          AND ues.user_id = $2
424        ORDER BY e.order_number ASC;
425"#,
426        course_instance_id,
427        user_id
428    )
429    .fetch_all(conn)
430    .await?;
431    Ok(exercises)
432}
433
434pub async fn get_exercises_by_chapter_id(
435    conn: &mut PgConnection,
436    chapter_id: Uuid,
437) -> ModelResult<Vec<Exercise>> {
438    let exercises = sqlx::query_as!(
439        Exercise,
440        r#"
441SELECT *
442FROM exercises
443WHERE chapter_id = $1
444  AND deleted_at IS NULL
445"#,
446        chapter_id
447    )
448    .fetch_all(&mut *conn)
449    .await?;
450    Ok(exercises)
451}
452
453pub async fn get_exercises_by_chapter_ids(
454    conn: &mut PgConnection,
455    chapter_ids: &[Uuid],
456) -> ModelResult<Vec<Exercise>> {
457    if chapter_ids.is_empty() {
458        return Ok(Vec::new());
459    }
460    let exercises = sqlx::query_as!(
461        Exercise,
462        r#"
463SELECT *
464FROM exercises
465WHERE chapter_id = ANY($1)
466  AND deleted_at IS NULL
467"#,
468        chapter_ids as &[Uuid]
469    )
470    .fetch_all(&mut *conn)
471    .await?;
472    Ok(exercises)
473}
474
475/// The undeleted exercises on the pages.
476pub async fn get_exercises_by_page_ids(
477    conn: &mut PgConnection,
478    page_ids: &[Uuid],
479) -> ModelResult<Vec<Exercise>> {
480    let exercises = sqlx::query_as!(
481        Exercise,
482        r#"
483SELECT *
484  FROM exercises
485WHERE page_id = ANY($1)
486  AND deleted_at IS NULL;
487"#,
488        page_ids,
489    )
490    .fetch_all(&mut *conn)
491    .await?;
492    Ok(exercises)
493}
494
495pub async fn get_exercises_by_exam_id(
496    conn: &mut PgConnection,
497    exam_id: Uuid,
498) -> ModelResult<Vec<Exercise>> {
499    let exercises = sqlx::query_as!(
500        Exercise,
501        r#"
502SELECT *
503FROM exercises
504WHERE exam_id = $1
505  AND deleted_at IS NULL
506"#,
507        exam_id,
508    )
509    .fetch_all(&mut *conn)
510    .await?;
511    Ok(exercises)
512}
513
514pub async fn get_course_or_exam_id(
515    conn: &mut PgConnection,
516    id: Uuid,
517) -> ModelResult<CourseOrExamId> {
518    let res = sqlx::query!(
519        "
520SELECT course_id,
521  exam_id
522FROM exercises
523WHERE id = $1
524",
525        id
526    )
527    .fetch_one(conn)
528    .await?;
529    CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
530}
531
532pub async fn get_course_material_exercise(
533    conn: &mut PgConnection,
534    user_id: Option<Uuid>,
535    exercise_id: Uuid,
536    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
537    file_store: &dyn FileStore,
538    app_conf: &ApplicationConfiguration,
539) -> ModelResult<CourseMaterialExercise> {
540    let mut exercise = get_by_id(conn, exercise_id).await?;
541    if exercise.deadline.is_none()
542        && let Some(chapter_id) = exercise.chapter_id
543    {
544        let chapter = crate::chapters::get_chapter(conn, chapter_id).await?;
545        exercise.deadline = chapter.deadline;
546    }
547    let (current_exercise_slide, instance_or_exam_id) = get_or_select_exercise_slide(
548        &mut *conn,
549        user_id,
550        &exercise,
551        fetch_service_info,
552        file_store,
553        app_conf,
554    )
555    .await?;
556    info!(
557        "Current exercise slide id: {:#?}",
558        current_exercise_slide.id
559    );
560
561    let user_exercise_state = match (user_id, instance_or_exam_id) {
562        (Some(user_id), Some(course_or_exam_id)) => {
563            user_exercise_states::get_user_exercise_state_if_exists(
564                conn,
565                user_id,
566                exercise.id,
567                course_or_exam_id,
568            )
569            .await?
570        }
571        _ => None,
572    };
573
574    let teacher_grading_decision = if let Some(user_id) = user_id {
575        let decision = crate::teacher_grading_decisions::try_to_get_latest_grading_decision_still_addressed_to_student(
576            &mut *conn, user_id, exercise.id,
577        )
578        .await?;
579        decision.and_then(|d| {
580            if d.teacher_decision != TeacherDecisionType::FullPoints && d.hidden != Some(true) {
581                Some(CourseMaterialTeacherGradingDecision {
582                    teacher_decision: d.teacher_decision,
583                    justification: d.justification,
584                })
585            } else {
586                None
587            }
588        })
589    } else {
590        None
591    };
592
593    let can_post_submission =
594        determine_can_post_submission(&mut *conn, user_id, &exercise, &user_exercise_state).await?;
595
596    let previous_exercise_slide_submission = match user_id {
597        Some(user_id) => {
598            crate::exercise_slide_submissions::try_to_get_users_latest_exercise_slide_submission(
599                conn,
600                current_exercise_slide.id,
601                user_id,
602            )
603            .await?
604        }
605        _ => None,
606    };
607
608    let exercise_status = user_exercise_state.map(|user_exercise_state| ExerciseStatus {
609        score_given: user_exercise_state.score_given,
610        activity_progress: user_exercise_state.activity_progress,
611        grading_progress: user_exercise_state.grading_progress,
612        reviewing_stage: user_exercise_state.reviewing_stage,
613    });
614
615    let exercise_slide_submission_counts = if let Some(user_id) = user_id {
616        if let Some(cioreid) = instance_or_exam_id {
617            get_exercise_slide_submission_counts_for_exercise_user(
618                conn,
619                exercise_id,
620                cioreid,
621                user_id,
622            )
623            .await?
624        } else {
625            HashMap::new()
626        }
627    } else {
628        HashMap::new()
629    };
630
631    let peer_or_self_review_config = if let Some(course_id) = exercise.course_id {
632        if exercise.needs_peer_review || exercise.needs_self_review {
633            let prc = crate::peer_or_self_review_configs::get_by_exercise_or_course_id(
634                conn, &exercise, course_id,
635            )
636            .await
637            .optional()?;
638            prc.map(|prc| CourseMaterialPeerOrSelfReviewConfig {
639                id: prc.id,
640                course_id: prc.course_id,
641                exercise_id: prc.exercise_id,
642                peer_reviews_to_give: prc.peer_reviews_to_give,
643                peer_reviews_to_receive: prc.peer_reviews_to_receive,
644            })
645        } else {
646            None
647        }
648    } else {
649        None
650    };
651
652    let user_course_instance_exercise_service_variables = match (user_id, instance_or_exam_id) {
653        (Some(user_id), Some(course_or_exam_id)) => {
654            Some(crate::user_course_exercise_service_variables::get_all_variables_for_user_and_course_or_exam(conn, user_id, course_or_exam_id).await?)
655        }
656        _ => None,
657    }.unwrap_or_default();
658
659    let should_show_reset_message = if let Some(user_id) = user_id {
660        crate::exercise_reset_logs::user_should_see_reset_message_for_exercise(
661            conn,
662            user_id,
663            exercise_id,
664        )
665        .await?
666    } else {
667        None
668    };
669
670    Ok(CourseMaterialExercise {
671        exercise,
672        can_post_submission,
673        current_exercise_slide,
674        exercise_status,
675        exercise_slide_submission_counts,
676        peer_or_self_review_config,
677        user_course_instance_exercise_service_variables,
678        previous_exercise_slide_submission,
679        should_show_reset_message,
680        teacher_grading_decision,
681    })
682}
683
684async fn determine_can_post_submission(
685    conn: &mut PgConnection,
686    user_id: Option<Uuid>,
687    exercise: &Exercise,
688    user_exercise_state: &Option<UserExerciseState>,
689) -> Result<bool, ModelError> {
690    if let Some(user_exercise_state) = user_exercise_state {
691        // 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.
692        if user_exercise_state.reviewing_stage != ReviewingStage::NotStarted {
693            return Ok(false);
694        }
695    }
696
697    let can_post_submission = if let Some(user_id) = user_id {
698        if let Some(exam_id) = exercise.exam_id {
699            exams::verify_exam_submission_can_be_made(conn, exam_id, user_id).await?
700        } else {
701            true
702        }
703    } else {
704        false
705    };
706    Ok(can_post_submission)
707}
708
709pub async fn get_or_select_exercise_slide(
710    conn: &mut PgConnection,
711    user_id: Option<Uuid>,
712    exercise: &Exercise,
713    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
714    file_store: &dyn FileStore,
715    app_conf: &ApplicationConfiguration,
716) -> ModelResult<(CourseMaterialExerciseSlide, Option<CourseOrExamId>)> {
717    match (user_id, exercise.course_id, exercise.exam_id) {
718        (None, ..) => {
719            // No signed in user. Show random exercise without model solution.
720            let random_slide =
721                exercise_slides::get_random_exercise_slide_for_exercise(conn, exercise.id).await?;
722            let random_slide_tasks = exercise_tasks::get_course_material_exercise_tasks(
723                conn,
724                random_slide.id,
725                None,
726                fetch_service_info,
727                file_store,
728                app_conf,
729            )
730            .await?;
731            Ok((
732                CourseMaterialExerciseSlide {
733                    id: random_slide.id,
734                    exercise_tasks: random_slide_tasks,
735                },
736                None,
737            ))
738        }
739        (Some(user_id), Some(course_id), None) => {
740            // signed in, course exercise
741            let user_course_settings = user_course_settings::get_user_course_settings_by_course_id(
742                conn, user_id, course_id,
743            )
744            .await?;
745            match user_course_settings {
746                Some(settings) if settings.current_course_id == course_id => {
747                    // User is enrolled on an instance of the given course.
748                    let course_or_exam_id: CourseOrExamId = exercise.try_into()?;
749                    let tasks =
750                        exercise_tasks::get_or_select_user_exercise_slide_for_course_or_exam(
751                            conn,
752                            user_id,
753                            exercise.id,
754                            course_or_exam_id,
755                            fetch_service_info,
756                            file_store,
757                            app_conf,
758                        )
759                        .await?;
760                    Ok((tasks, Some(CourseOrExamId::Course(course_id))))
761                }
762                Some(_) => {
763                    // User is enrolled on a different language version of the course. Show exercise
764                    // slide based on their latest enrollment or a random one.
765                    let exercise_tasks =
766                        exercise_tasks::get_existing_users_exercise_slide_for_course(
767                            conn,
768                            user_id,
769                            exercise.id,
770                            course_id,
771                            &fetch_service_info,
772                            file_store,
773                            app_conf,
774                        )
775                        .await?;
776                    if let Some(exercise_tasks) = exercise_tasks {
777                        Ok((exercise_tasks, Some(CourseOrExamId::Course(course_id))))
778                    } else {
779                        // no exercise task has been chosen for the user
780                        let random_slide = exercise_slides::get_random_exercise_slide_for_exercise(
781                            conn,
782                            exercise.id,
783                        )
784                        .await?;
785                        let random_tasks = exercise_tasks::get_course_material_exercise_tasks(
786                            conn,
787                            random_slide.id,
788                            Some(user_id),
789                            &fetch_service_info,
790                            file_store,
791                            app_conf,
792                        )
793                        .await?;
794
795                        Ok((
796                            CourseMaterialExerciseSlide {
797                                id: random_slide.id,
798                                exercise_tasks: random_tasks,
799                            },
800                            None,
801                        ))
802                    }
803                }
804                None => {
805                    // User is not enrolled on any course version. This is not a valid scenario because
806                    // tasks are based on a specific instance.
807                    Err(ModelError::new(
808                        ModelErrorType::PreconditionFailed,
809                        "User must be enrolled to the course".to_string(),
810                        None,
811                    ))
812                }
813            }
814        }
815        (Some(user_id), _, Some(exam_id)) => {
816            info!("selecting exam task");
817            // signed in, exam exercise
818            let tasks = exercise_tasks::get_or_select_user_exercise_slide_for_course_or_exam(
819                conn,
820                user_id,
821                exercise.id,
822                CourseOrExamId::Exam(exam_id),
823                fetch_service_info,
824                file_store,
825                app_conf,
826            )
827            .await?;
828            info!("selecting exam task {:#?}", tasks);
829            Ok((tasks, Some(CourseOrExamId::Exam(exam_id))))
830        }
831        (Some(_), ..) => Err(ModelError::new(
832            ModelErrorType::Generic,
833            "The selected exercise is not attached to any course or exam".to_string(),
834            None,
835        )),
836    }
837}
838
839pub async fn delete_exercises_by_page_id(
840    conn: &mut PgConnection,
841    page_id: Uuid,
842) -> ModelResult<Vec<Uuid>> {
843    let deleted_ids = sqlx::query!(
844        "
845UPDATE exercises
846SET deleted_at = now()
847WHERE page_id = $1
848AND deleted_at IS NULL
849RETURNING *;
850        ",
851        page_id
852    )
853    .fetch_all(conn)
854    .await?
855    .into_iter()
856    .map(|x| x.id)
857    .collect();
858    Ok(deleted_ids)
859}
860
861pub async fn update_teacher_reviews_answer_after_locking(
862    conn: &mut PgConnection,
863    exercise_id: Uuid,
864    teacher_reviews_answer_after_locking: bool,
865) -> ModelResult<Exercise> {
866    let exercise = sqlx::query_as!(
867        Exercise,
868        r#"
869UPDATE exercises
870SET teacher_reviews_answer_after_locking = $2
871WHERE id = $1
872  AND deleted_at IS NULL
873RETURNING *
874        "#,
875        exercise_id,
876        teacher_reviews_answer_after_locking
877    )
878    .fetch_one(conn)
879    .await?;
880
881    Ok(exercise)
882}
883
884pub async fn set_exercise_to_use_exercise_specific_peer_or_self_review_config(
885    conn: &mut PgConnection,
886    exercise_id: Uuid,
887    needs_peer_review: bool,
888    needs_self_review: bool,
889    use_course_default_peer_or_self_review_config: bool,
890) -> ModelResult<Uuid> {
891    let id = sqlx::query!(
892        "
893UPDATE exercises
894SET use_course_default_peer_or_self_review_config = $1,
895  needs_peer_review = $2,
896  needs_self_review = $3
897WHERE id = $4
898RETURNING *;
899        ",
900        use_course_default_peer_or_self_review_config,
901        needs_peer_review,
902        needs_self_review,
903        exercise_id
904    )
905    .fetch_one(conn)
906    .await?;
907
908    Ok(id.id)
909}
910
911pub async fn get_all_exercise_statuses_by_user_id_and_course_id(
912    conn: &mut PgConnection,
913    course_id: Uuid,
914    user_id: Uuid,
915) -> ModelResult<Vec<ExerciseStatusSummaryForUser>> {
916    let course_or_exam_id = CourseOrExamId::Course(course_id);
917    // Load all the data for this user from all the exercises to memory, and group most of them to HashMaps by exercise id
918    let exercises = crate::exercises::get_exercises_by_course_id(&mut *conn, course_id).await?;
919    let mut user_exercise_states =
920        crate::user_exercise_states::get_all_for_user_and_course_or_exam(
921            &mut *conn,
922            user_id,
923            course_or_exam_id,
924        )
925        .await?
926        .into_iter()
927        .map(|ues| (ues.exercise_id, ues))
928        .collect::<HashMap<_, _>>();
929    let mut exercise_slide_submissions =
930        crate::exercise_slide_submissions::get_users_all_submissions_for_course_or_exam(
931            &mut *conn,
932            user_id,
933            course_or_exam_id,
934        )
935        .await?
936        .into_iter()
937        .into_group_map_by(|o| o.exercise_id);
938    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()
939        .into_group_map_by(|o| o.exercise_id);
940    let given_submission_ids: Vec<Uuid> = given_peer_or_self_review_submissions
941        .values()
942        .flatten()
943        .map(|prs| prs.exercise_slide_submission_id)
944        .collect();
945    let submission_owner_user_ids =
946        crate::exercise_slide_submissions::get_user_ids_by_submission_ids(
947            &mut *conn,
948            &given_submission_ids,
949        )
950        .await?;
951    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()
952        .into_group_map_by(|o| o.exercise_id);
953    let given_peer_or_self_review_submission_ids = given_peer_or_self_review_submissions
954        .values()
955        .flatten()
956        .map(|x| x.id)
957        .collect::<Vec<_>>();
958    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?
959        .into_iter()
960        .into_group_map_by(|o| {
961            let peer_review_submission = given_peer_or_self_review_submissions.clone().into_iter()
962                .find(|(_exercise_id, prs)| prs.iter().any(|p| p.id == o.peer_or_self_review_submission_id))
963                .unwrap_or_else(|| (Uuid::nil(), vec![]));
964            peer_review_submission.0
965    });
966    let received_peer_or_self_review_submission_ids = received_peer_or_self_review_submissions
967        .values()
968        .flatten()
969        .map(|x| x.id)
970        .collect::<Vec<_>>();
971    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()
972    .into_group_map_by(|o| {
973        let peer_review_submission = received_peer_or_self_review_submissions.clone().into_iter()
974            .find(|(_exercise_id, prs)| prs.iter().any(|p| p.id == o.peer_or_self_review_submission_id))
975            .unwrap_or_else(|| (Uuid::nil(), vec![]));
976        peer_review_submission.0
977    });
978    let mut peer_review_queue_entries =
979        crate::peer_review_queue_entries::get_all_by_user_and_course_id(
980            &mut *conn, user_id, course_id,
981        )
982        .await?
983        .into_iter()
984        .map(|x| (x.exercise_id, x))
985        .collect::<HashMap<_, _>>();
986    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()
987    .filter_map(|tgd| {
988        let user_exercise_state = user_exercise_states.clone().into_iter()
989            .find(|(_exercise_id, ues)|  ues.id == tgd.user_exercise_state_id)?;
990        Some((user_exercise_state.0, tgd))
991    }).collect::<HashMap<_, _>>();
992    let all_peer_or_self_review_question_ids = given_peer_or_self_review_question_submissions
993        .iter()
994        .chain(received_peer_or_self_review_question_submissions.iter())
995        .flat_map(|(_exercise_id, prqs)| prqs.iter().map(|p| p.peer_or_self_review_question_id))
996        .collect::<Vec<_>>();
997    let all_peer_or_self_review_questions = crate::peer_or_self_review_questions::get_by_ids(
998        &mut *conn,
999        &all_peer_or_self_review_question_ids,
1000    )
1001    .await?;
1002
1003    // Map all the data for all the exercises to be summaries of the data for each exercise.
1004    //
1005    // Since all data is in hashmaps grouped by exercise id, and we iterate though every
1006    // exercise id exactly once, we can just remove the data for the exercise from the
1007    // hashmaps and avoid extra copying.
1008    let res = exercises
1009        .into_iter()
1010        .map(|exercise| {
1011            let user_exercise_state = user_exercise_states.remove(&exercise.id);
1012            let exercise_slide_submissions = exercise_slide_submissions
1013                .remove(&exercise.id)
1014                .unwrap_or_default();
1015            let given_peer_or_self_review_submissions = given_peer_or_self_review_submissions
1016                .remove(&exercise.id)
1017                .unwrap_or_default()
1018                .into_iter()
1019                .map(|prs| {
1020                    let submission_owner_user_id = submission_owner_user_ids
1021                        .get(&prs.exercise_slide_submission_id)
1022                        .copied();
1023                    PeerOrSelfReviewSubmissionWithSubmissionOwner {
1024                        submission: prs,
1025                        submission_owner_user_id,
1026                    }
1027                })
1028                .collect();
1029            let received_peer_or_self_review_submissions = received_peer_or_self_review_submissions
1030                .remove(&exercise.id)
1031                .unwrap_or_default()
1032                .into_iter()
1033                .map(|prs| PeerOrSelfReviewSubmissionWithSubmissionOwner {
1034                    submission: prs,
1035                    submission_owner_user_id: None,
1036                })
1037                .collect();
1038            let given_peer_or_self_review_question_submissions =
1039                given_peer_or_self_review_question_submissions
1040                    .remove(&exercise.id)
1041                    .unwrap_or_default();
1042            let received_peer_or_self_review_question_submissions =
1043                received_peer_or_self_review_question_submissions
1044                    .remove(&exercise.id)
1045                    .unwrap_or_default();
1046            let peer_review_queue_entry = peer_review_queue_entries.remove(&exercise.id);
1047            let teacher_grading_decision = teacher_grading_decisions.remove(&exercise.id);
1048            let peer_or_self_review_question_ids = given_peer_or_self_review_question_submissions
1049                .iter()
1050                .chain(received_peer_or_self_review_question_submissions.iter())
1051                .map(|prqs| prqs.peer_or_self_review_question_id)
1052                .unique()
1053                .collect::<Vec<_>>();
1054            let peer_or_self_review_questions = all_peer_or_self_review_questions
1055                .iter()
1056                .filter(|prq| peer_or_self_review_question_ids.contains(&prq.id))
1057                .cloned()
1058                .collect::<Vec<_>>();
1059            ExerciseStatusSummaryForUser {
1060                exercise,
1061                user_exercise_state,
1062                exercise_slide_submissions,
1063                given_peer_or_self_review_submissions,
1064                received_peer_or_self_review_submissions,
1065                given_peer_or_self_review_question_submissions,
1066                received_peer_or_self_review_question_submissions,
1067                peer_review_queue_entry,
1068                teacher_grading_decision,
1069                peer_or_self_review_questions,
1070            }
1071        })
1072        .collect::<Vec<_>>();
1073    Ok(res)
1074}
1075
1076pub async fn get_exercises_by_module_containing_exercise_type(
1077    conn: &mut PgConnection,
1078    exercise_type: &str,
1079    course_module_id: Uuid,
1080) -> ModelResult<Vec<Exercise>> {
1081    let res: Vec<Exercise> = sqlx::query_as!(
1082        Exercise,
1083        r#"
1084SELECT DISTINCT(ex.*)
1085FROM exercises ex
1086  JOIN exercise_slides slides ON ex.id = slides.exercise_id
1087  JOIN exercise_tasks tasks ON slides.id = tasks.exercise_slide_id
1088  JOIN chapters c ON ex.chapter_id = c.id
1089where tasks.exercise_type = $1
1090  AND c.course_module_id = $2
1091  AND ex.deleted_at IS NULL
1092  AND tasks.deleted_at IS NULL
1093  and c.deleted_at IS NULL
1094  and slides.deleted_at IS NULL
1095        "#,
1096        exercise_type,
1097        course_module_id
1098    )
1099    .fetch_all(conn)
1100    .await?;
1101    Ok(res)
1102}
1103
1104/// Collects user_ids and related exercise_ids according to given filters
1105pub async fn collect_user_ids_and_exercise_ids_for_reset(
1106    conn: &mut PgConnection,
1107    user_ids: &[Uuid],
1108    exercise_ids: &[Uuid],
1109    threshold: Option<f64>,
1110    reset_all_below_max: bool,
1111    reset_only_locked_reviews: bool,
1112) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1113    let results = sqlx::query!(
1114        r#"
1115SELECT DISTINCT ues.user_id,
1116  ues.exercise_id
1117FROM user_exercise_states ues
1118  LEFT JOIN exercises e ON ues.exercise_id = e.id
1119WHERE ues.user_id = ANY($1)
1120  AND ues.exercise_id = ANY($2)
1121  AND ues.deleted_at IS NULL
1122  AND (
1123    $3 = FALSE
1124    OR ues.score_given < e.score_maximum
1125  )
1126  AND (
1127    $4::FLOAT IS NULL
1128    OR ues.score_given < $4::FLOAT
1129  )
1130  AND (
1131    $5 = FALSE
1132    OR ues.reviewing_stage = 'reviewed_and_locked'
1133  )
1134            "#,
1135        user_ids,
1136        exercise_ids,
1137        reset_all_below_max,
1138        threshold,
1139        reset_only_locked_reviews
1140    )
1141    .fetch_all(&mut *conn)
1142    .await?;
1143
1144    let mut user_exercise_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
1145    for row in &results {
1146        user_exercise_map
1147            .entry(row.user_id)
1148            .or_default()
1149            .push(row.exercise_id);
1150    }
1151
1152    Ok(user_exercise_map.into_iter().collect())
1153}
1154
1155/// Resolves affected chapter ids for exercise resets in a course.
1156async fn get_chapter_ids_for_exercises_in_course(
1157    conn: &mut PgConnection,
1158    exercise_ids: &[Uuid],
1159    course_id: Uuid,
1160) -> ModelResult<Vec<Uuid>> {
1161    let mut chapter_ids = HashSet::new();
1162    for exercise_id in exercise_ids {
1163        let exercise = get_exercise_by_id(conn, *exercise_id).await?;
1164        if exercise.course_id == Some(course_id)
1165            && let Some(chapter_id) = exercise.chapter_id
1166        {
1167            chapter_ids.insert(chapter_id);
1168        }
1169    }
1170    Ok(chapter_ids.into_iter().collect())
1171}
1172
1173/// Resets all related tables for selected users and related exercises.
1174pub async fn reset_exercises_for_selected_users(
1175    conn: &mut PgConnection,
1176    users_and_exercises: &[(Uuid, Vec<Uuid>)],
1177    reset_by: Option<Uuid>,
1178    course_id: Uuid,
1179    reason: Option<String>,
1180) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1181    let mut successful_resets = Vec::new();
1182    let mut tx = conn.begin().await?;
1183    for (user_id, exercise_ids) in users_and_exercises {
1184        sqlx::query!(
1185            r#"
1186UPDATE exercise_slide_submissions
1187SET deleted_at = NOW()
1188WHERE user_id = $1
1189  AND exercise_id = ANY($2)
1190  AND deleted_at IS NULL
1191            "#,
1192            user_id,
1193            exercise_ids
1194        )
1195        .execute(&mut *tx)
1196        .await?;
1197
1198        sqlx::query!(
1199            r#"
1200UPDATE exercise_task_submissions
1201SET deleted_at = NOW()
1202WHERE exercise_slide_submission_id IN (
1203    SELECT id
1204    FROM exercise_slide_submissions
1205    WHERE user_id = $1
1206      AND exercise_id = ANY($2)
1207  )
1208  AND deleted_at IS NULL
1209            "#,
1210            user_id,
1211            exercise_ids
1212        )
1213        .execute(&mut *tx)
1214        .await?;
1215
1216        sqlx::query!(
1217            r#"
1218UPDATE peer_review_queue_entries
1219SET deleted_at = NOW()
1220WHERE user_id = $1
1221  AND exercise_id = ANY($2)
1222  AND deleted_at IS NULL
1223            "#,
1224            user_id,
1225            exercise_ids
1226        )
1227        .execute(&mut *tx)
1228        .await?;
1229
1230        sqlx::query!(
1231            r#"
1232UPDATE exercise_task_gradings
1233SET deleted_at = NOW()
1234WHERE exercise_task_submission_id IN (
1235    SELECT id
1236    FROM exercise_task_submissions
1237    WHERE exercise_slide_submission_id IN (
1238        SELECT id
1239        FROM exercise_slide_submissions
1240        WHERE user_id = $1
1241          AND exercise_id = ANY($2)
1242      )
1243  )
1244  AND deleted_at IS NULL
1245            "#,
1246            user_id,
1247            exercise_ids
1248        )
1249        .execute(&mut *tx)
1250        .await?;
1251
1252        // Teacher grading decisions are left in place: they hang off the state deleted here, so
1253        // they cannot score the next attempt, and they carry what the teacher told the student.
1254        sqlx::query!(
1255            r#"
1256UPDATE user_exercise_states
1257SET deleted_at = NOW()
1258WHERE user_id = $1
1259  AND exercise_id = ANY($2)
1260  AND deleted_at IS NULL
1261            "#,
1262            user_id,
1263            exercise_ids
1264        )
1265        .execute(&mut *tx)
1266        .await?;
1267
1268        sqlx::query!(
1269            r#"
1270UPDATE user_exercise_task_states
1271SET deleted_at = NOW()
1272WHERE user_exercise_slide_state_id IN (
1273    SELECT id
1274    FROM user_exercise_slide_states
1275    WHERE user_exercise_state_id IN (
1276        SELECT id
1277        FROM user_exercise_states
1278        WHERE user_id = $1
1279          AND exercise_id = ANY($2)
1280      )
1281  )
1282  AND deleted_at IS NULL
1283            "#,
1284            user_id,
1285            exercise_ids
1286        )
1287        .execute(&mut *tx)
1288        .await?;
1289
1290        sqlx::query!(
1291            r#"
1292UPDATE user_exercise_slide_states
1293SET deleted_at = NOW()
1294WHERE user_exercise_state_id IN (
1295    SELECT id
1296    FROM user_exercise_states
1297    WHERE user_id = $1
1298      AND exercise_id = ANY($2)
1299  )
1300  AND deleted_at IS NULL
1301            "#,
1302            user_id,
1303            exercise_ids
1304        )
1305        .execute(&mut *tx)
1306        .await?;
1307
1308        // Adds a log of a reset exercise for a user
1309        exercise_reset_logs::log_exercise_reset(
1310            &mut tx,
1311            reset_by,
1312            *user_id,
1313            exercise_ids,
1314            course_id,
1315            reason.clone(),
1316        )
1317        .await?;
1318
1319        let chapter_ids =
1320            get_chapter_ids_for_exercises_in_course(&mut tx, exercise_ids, course_id).await?;
1321        // Keep chapters that are still in their initial `not_unlocked_yet` state untouched.
1322        user_chapter_locking_statuses::unlock_chapters_for_user(
1323            &mut tx,
1324            *user_id,
1325            course_id,
1326            &chapter_ids,
1327        )
1328        .await?;
1329
1330        successful_resets.push((*user_id, exercise_ids.to_vec()));
1331    }
1332    tx.commit().await?;
1333    Ok(successful_resets)
1334}
1335
1336pub async fn reset_progress_by_course_id_user_ids_and_exercise_ids(
1337    conn: &mut PgConnection,
1338    course_id: Uuid,
1339    user_ids: &[Uuid],
1340    exercise_ids: &[Uuid],
1341    reset_by: Option<Uuid>,
1342    reason: Option<String>,
1343) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1344    let mut successful_resets = Vec::new();
1345    let mut tx = conn.begin().await?;
1346    let validated_exercise_ids = sqlx::query!(
1347        r#"
1348SELECT id
1349FROM exercises
1350WHERE id = ANY($1)
1351  AND course_id = $2
1352  AND deleted_at IS NULL
1353        "#,
1354        exercise_ids,
1355        course_id
1356    )
1357    .fetch_all(&mut *tx)
1358    .await?
1359    .into_iter()
1360    .map(|row| row.id)
1361    .collect::<Vec<_>>();
1362
1363    if validated_exercise_ids.is_empty() {
1364        tx.commit().await?;
1365        return Ok(successful_resets);
1366    }
1367
1368    for user_id in user_ids {
1369        sqlx::query!(
1370            r#"
1371UPDATE exercise_slide_submissions
1372SET deleted_at = NOW()
1373WHERE user_id = $1
1374  AND course_id = $2
1375  AND exercise_id = ANY($3)
1376  AND deleted_at IS NULL
1377            "#,
1378            user_id,
1379            course_id,
1380            &validated_exercise_ids
1381        )
1382        .execute(&mut *tx)
1383        .await?;
1384
1385        sqlx::query!(
1386            r#"
1387UPDATE exercise_task_submissions
1388SET deleted_at = NOW()
1389WHERE exercise_slide_submission_id IN (
1390    SELECT id
1391    FROM exercise_slide_submissions
1392    WHERE user_id = $1
1393      AND course_id = $2
1394      AND exercise_id = ANY($3)
1395  )
1396  AND deleted_at IS NULL
1397            "#,
1398            user_id,
1399            course_id,
1400            &validated_exercise_ids
1401        )
1402        .execute(&mut *tx)
1403        .await?;
1404
1405        sqlx::query!(
1406            r#"
1407UPDATE peer_review_queue_entries
1408SET deleted_at = NOW()
1409WHERE user_id = $1
1410  AND exercise_id = ANY($2)
1411  AND deleted_at IS NULL
1412            "#,
1413            user_id,
1414            &validated_exercise_ids
1415        )
1416        .execute(&mut *tx)
1417        .await?;
1418
1419        sqlx::query!(
1420            r#"
1421UPDATE exercise_task_gradings
1422SET deleted_at = NOW()
1423WHERE exercise_task_submission_id IN (
1424    SELECT ets.id
1425    FROM exercise_task_submissions ets
1426      JOIN exercise_slide_submissions ess
1427        ON ess.id = ets.exercise_slide_submission_id
1428    WHERE ess.user_id = $1
1429      AND ess.course_id = $2
1430      AND ess.exercise_id = ANY($3)
1431  )
1432  AND deleted_at IS NULL
1433            "#,
1434            user_id,
1435            course_id,
1436            &validated_exercise_ids
1437        )
1438        .execute(&mut *tx)
1439        .await?;
1440
1441        sqlx::query!(
1442            r#"
1443UPDATE user_exercise_task_states
1444SET deleted_at = NOW()
1445WHERE user_exercise_slide_state_id IN (
1446    SELECT uess.id
1447    FROM user_exercise_slide_states uess
1448      JOIN user_exercise_states ues ON ues.id = uess.user_exercise_state_id
1449    WHERE ues.user_id = $1
1450      AND ues.course_id = $2
1451      AND ues.exercise_id = ANY($3)
1452  )
1453  AND deleted_at IS NULL
1454            "#,
1455            user_id,
1456            course_id,
1457            &validated_exercise_ids
1458        )
1459        .execute(&mut *tx)
1460        .await?;
1461
1462        sqlx::query!(
1463            r#"
1464UPDATE user_exercise_slide_states
1465SET deleted_at = NOW()
1466WHERE user_exercise_state_id IN (
1467    SELECT id
1468    FROM user_exercise_states
1469    WHERE user_id = $1
1470      AND course_id = $2
1471      AND exercise_id = ANY($3)
1472  )
1473  AND deleted_at IS NULL
1474            "#,
1475            user_id,
1476            course_id,
1477            &validated_exercise_ids
1478        )
1479        .execute(&mut *tx)
1480        .await?;
1481
1482        // Teacher grading decisions are left in place: they hang off the state deleted here, so
1483        // they cannot score the next attempt, and they carry what the teacher told the student.
1484        sqlx::query!(
1485            r#"
1486UPDATE user_exercise_states
1487SET deleted_at = NOW()
1488WHERE user_id = $1
1489  AND course_id = $2
1490  AND exercise_id = ANY($3)
1491  AND deleted_at IS NULL
1492            "#,
1493            user_id,
1494            course_id,
1495            &validated_exercise_ids
1496        )
1497        .execute(&mut *tx)
1498        .await?;
1499
1500        exercise_reset_logs::log_exercise_reset(
1501            &mut tx,
1502            reset_by,
1503            *user_id,
1504            &validated_exercise_ids,
1505            course_id,
1506            reason.clone(),
1507        )
1508        .await?;
1509
1510        let chapter_ids =
1511            get_chapter_ids_for_exercises_in_course(&mut tx, &validated_exercise_ids, course_id)
1512                .await?;
1513        user_chapter_locking_statuses::unlock_chapters_for_user(
1514            &mut tx,
1515            *user_id,
1516            course_id,
1517            &chapter_ids,
1518        )
1519        .await?;
1520
1521        successful_resets.push((*user_id, validated_exercise_ids.clone()));
1522    }
1523    tx.commit().await?;
1524    Ok(successful_resets)
1525}
1526
1527/// Sets the exercise's own deadline, or clears it with `None`.
1528///
1529/// Clearing does not necessarily leave the exercise open: an exercise without a deadline inherits
1530/// its chapter's, which [`get_course_material_exercise`] resolves.
1531pub async fn set_deadline(
1532    conn: &mut PgConnection,
1533    id: Uuid,
1534    deadline: Option<DateTime<Utc>>,
1535) -> ModelResult<()> {
1536    sqlx::query!(
1537        "UPDATE exercises SET deadline = $2 WHERE id = $1",
1538        id,
1539        deadline
1540    )
1541    .execute(conn)
1542    .await?;
1543    Ok(())
1544}
1545
1546/// Sets how many tries an exercise allows per slide.
1547///
1548/// `max_tries_per_slide` is enforced only while `limit_number_of_tries` is set; otherwise it just
1549/// remembers what the teacher last typed, so clearing the flag does not discard the number.
1550pub async fn set_try_limit(
1551    conn: &mut PgConnection,
1552    id: Uuid,
1553    limit_number_of_tries: bool,
1554    max_tries_per_slide: Option<i32>,
1555) -> ModelResult<()> {
1556    sqlx::query!(
1557        "
1558UPDATE exercises
1559SET limit_number_of_tries = $2,
1560  max_tries_per_slide = $3
1561WHERE id = $1
1562",
1563        id,
1564        limit_number_of_tries,
1565        max_tries_per_slide
1566    )
1567    .execute(conn)
1568    .await?;
1569    Ok(())
1570}
1571
1572#[cfg(test)]
1573mod test {
1574    use super::*;
1575    use crate::{
1576        chapters,
1577        course_instance_enrollments::{self, NewCourseInstanceEnrollment},
1578        courses,
1579        exercise_service_info::{self, PathInfo},
1580        exercise_services::{self, ExerciseServiceNewOrUpdate},
1581        test_helper::Conn,
1582        test_helper::*,
1583        user_chapter_locking_statuses::{self, ChapterLockingStatus},
1584        user_exercise_states,
1585    };
1586    use chrono::TimeZone;
1587    use sqlx::PgConnection;
1588
1589    async fn insert_exercise_service_with_info(tx: &mut PgConnection) {
1590        let exercise_service = exercise_services::insert_exercise_service(
1591            tx,
1592            &ExerciseServiceNewOrUpdate {
1593                name: "text-exercise".to_string(),
1594                slug: TEST_HELPER_EXERCISE_SERVICE_NAME.to_string(),
1595                public_url: "https://example.com".to_string(),
1596                internal_url: None,
1597                max_reprocessing_submissions_at_once: 1,
1598                is_global_admin_only: false,
1599                is_experimental: false,
1600            },
1601        )
1602        .await
1603        .unwrap();
1604        exercise_service_info::insert(
1605            tx,
1606            &PathInfo {
1607                exercise_service_id: exercise_service.id,
1608                user_interface_iframe_path: "/iframe".to_string(),
1609                grade_endpoint_path: "/grade".to_string(),
1610                public_spec_endpoint_path: "/public-spec".to_string(),
1611                model_solution_spec_endpoint_path: "test-only-empty-path".to_string(),
1612                has_custom_view: false,
1613                supports_native_client: false,
1614                produces_file_answers: false,
1615                declares_spec_files: false,
1616            },
1617        )
1618        .await
1619        .unwrap();
1620    }
1621
1622    #[tokio::test]
1623    async fn selects_course_material_exercise_for_enrolled_student() {
1624        insert_data!(
1625            :tx,
1626            user: user_id,
1627            org: _organization_id,
1628            course: course_id,
1629            instance: course_instance,
1630            :course_module,
1631            chapter: chapter_id,
1632            page: _page_id,
1633            exercise: exercise_id,
1634            slide: exercise_slide_id,
1635            task: exercise_task_id
1636        );
1637        insert_exercise_service_with_info(tx.as_mut()).await;
1638        course_instance_enrollments::insert_enrollment_and_set_as_current(
1639            tx.as_mut(),
1640            NewCourseInstanceEnrollment {
1641                course_id,
1642                course_instance_id: course_instance.id,
1643                user_id,
1644            },
1645        )
1646        .await
1647        .unwrap();
1648
1649        let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
1650            tx.as_mut(),
1651            user_id,
1652            exercise_id,
1653            CourseOrExamId::Course(course_id),
1654        )
1655        .await
1656        .unwrap();
1657        assert!(user_exercise_state.is_none());
1658
1659        let exercise = get_course_material_exercise(
1660            tx.as_mut(),
1661            Some(user_id),
1662            exercise_id,
1663            |_| unimplemented!(),
1664            &init_file_store(),
1665            &init_app_conf().expect("Application Configuration initialization failed"),
1666        )
1667        .await
1668        .unwrap();
1669        assert_eq!(
1670            exercise
1671                .current_exercise_slide
1672                .exercise_tasks
1673                .first()
1674                .unwrap()
1675                .id,
1676            exercise_task_id
1677        );
1678
1679        let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
1680            tx.as_mut(),
1681            user_id,
1682            exercise_id,
1683            CourseOrExamId::Course(course_id),
1684        )
1685        .await
1686        .unwrap();
1687        assert_eq!(
1688            user_exercise_state
1689                .unwrap()
1690                .selected_exercise_slide_id
1691                .unwrap(),
1692            exercise_slide_id
1693        );
1694    }
1695
1696    #[tokio::test]
1697    async fn course_material_exercise_inherits_chapter_deadline() {
1698        insert_data!(
1699            :tx,
1700            user: user_id,
1701            org: organization_id,
1702            course: course_id,
1703            instance: course_instance,
1704            :course_module,
1705            chapter: chapter_id,
1706            page: page_id,
1707            exercise: exercise_id,
1708            slide: exercise_slide_id,
1709            task: _exercise_task_id
1710        );
1711        insert_exercise_service_with_info(tx.as_mut()).await;
1712        course_instance_enrollments::insert_enrollment_and_set_as_current(
1713            tx.as_mut(),
1714            NewCourseInstanceEnrollment {
1715                course_id,
1716                course_instance_id: course_instance.id,
1717                user_id,
1718            },
1719        )
1720        .await
1721        .unwrap();
1722
1723        let chapter_deadline = Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap();
1724        let chapter = chapters::get_chapter(tx.as_mut(), chapter_id)
1725            .await
1726            .unwrap();
1727        chapters::update_chapter(
1728            tx.as_mut(),
1729            chapter_id,
1730            chapters::ChapterUpdate {
1731                name: chapter.name,
1732                color: chapter.color,
1733                front_page_id: chapter.front_page_id,
1734                deadline: Some(chapter_deadline),
1735                opens_at: chapter.opens_at,
1736                course_module_id: Some(chapter.course_module_id),
1737            },
1738            user_id,
1739        )
1740        .await
1741        .unwrap();
1742
1743        let exercise = get_course_material_exercise(
1744            tx.as_mut(),
1745            Some(user_id),
1746            exercise_id,
1747            |_| unimplemented!(),
1748            &init_file_store(),
1749            &init_app_conf().expect("Application Configuration initialization failed"),
1750        )
1751        .await
1752        .unwrap();
1753
1754        assert_eq!(exercise.exercise.deadline, Some(chapter_deadline));
1755    }
1756
1757    #[tokio::test]
1758    async fn resetting_exercise_unlocks_its_chapter_for_user() {
1759        insert_data!(
1760            :tx,
1761            user: user_id,
1762            org: _organization_id,
1763            course: course_id,
1764            instance: _course_instance,
1765            :course_module,
1766            chapter: chapter_id,
1767            page: _page_id,
1768            exercise: exercise_id,
1769            slide: _exercise_slide_id,
1770            task: _exercise_task_id
1771        );
1772        let app_config = init_app_conf().expect("Application Configuration initialization failed");
1773
1774        let existing_course = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1775        courses::update_course(
1776            tx.as_mut(),
1777            &app_config,
1778            course_id,
1779            courses::CourseUpdate {
1780                flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
1781                chapter_locking_enabled: true,
1782                ..courses::CourseUpdate::from(&existing_course)
1783            },
1784            user_id,
1785            false,
1786        )
1787        .await
1788        .unwrap();
1789
1790        user_chapter_locking_statuses::complete_and_lock_chapter(
1791            tx.as_mut(),
1792            user_id,
1793            chapter_id,
1794            course_id,
1795        )
1796        .await
1797        .unwrap();
1798
1799        reset_exercises_for_selected_users(
1800            tx.as_mut(),
1801            &[(user_id, vec![exercise_id])],
1802            Some(user_id),
1803            course_id,
1804            Some("test-reset".to_string()),
1805        )
1806        .await
1807        .unwrap();
1808
1809        let status = user_chapter_locking_statuses::get_or_init_status(
1810            tx.as_mut(),
1811            user_id,
1812            chapter_id,
1813            Some(course_id),
1814            Some(true),
1815        )
1816        .await
1817        .unwrap();
1818
1819        assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
1820    }
1821}