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