Skip to main content

headless_lms_models/
user_exercise_states.rs

1use derive_more::Display;
2use std::collections::HashMap;
3
4use futures::Stream;
5use headless_lms_utils::numbers::option_f32_to_f32_two_decimals_with_none_as_zero;
6use serde_json::Value;
7use utoipa::ToSchema;
8
9use crate::{
10    course_modules::{self, CourseModule},
11    courses,
12    exercises::{ActivityProgress, Exercise, GradingProgress},
13    prelude::*,
14};
15
16#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, Display, ToSchema)]
17#[sqlx(type_name = "reviewing_stage", rename_all = "snake_case")]
18/**
19Tells what stage of reviewing the user is currently in. Used for for peer review, self review, and manual review. If an exercise does not involve reviewing, the value of this stage will always be `NotStarted`.
20*/
21pub enum ReviewingStage {
22    /**
23    In this stage the user submits answers to the exercise. If the exercise allows it, the user can answer the exercise multiple times. If the exercise is not in this stage, the user cannot answer the exercise. Most exercises will never leave this stage because other stages are reseverved for situations when we cannot give the user points just based on the automatic gradings.
24    */
25    NotStarted,
26    /// In this stage the student is instructed to give peer reviews to other students.
27    PeerReview,
28    /// In this stage the student is instructed to review their own answer.
29    SelfReview,
30    /// In this stage the student has completed the neccessary peer and self reviews but is waiting for other students to peer review their answer before we can give points for this exercise.
31    WaitingForPeerReviews,
32    /**
33    In this stage the student has completed everything they need to do, but before we can give points for this exercise, we need a manual grading from the teacher.
34
35    Reasons for ending up in this stage may be one of these:
36
37    1. The exercise is configured to require all answers to be reviewed by the teacher.
38    2. The answer has received poor reviews from the peers, and the exercise has been configured so that the teacher has to double-check whether it is justified to not give full points to the student.
39    */
40    WaitingForManualGrading,
41    /**
42    In this stage the the reviews have been completed and the points have been awarded to the student. However, since the answer had to go though the review process, the student may no longer answer the exercise since because
43
44    1. It is likely that we revealed the model solution to the student during the review process.
45    2. In case of peer review, a new answer would have to be reviewed by other students again, and that would be unreasonable extra work for others.
46
47    If the teacher for some reasoon feels bad for the student and wants to give them a new chance, the answers for this exercise should be reset, the reason should be recorded somewhere in the database, and the value of this column should be set to `NotStarted`. Deleting the whole user_exercise_state may also be wise. However, if we end up doing this for a teacher, we should make sure that the teacher realizes that they should not give an unfair advantage to anyone.
48    */
49    ReviewedAndLocked,
50    /// In this stage the exercise has been locked due to chapter locking, but no review has been performed.
51    Locked,
52    /// In this stage the chapter was locked while the student had not returned an answer to the exercise. There is nothing for anyone to review, and the student can no longer answer the exercise.
53    NotAnsweredAndLocked,
54}
55
56#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
57pub struct UserExerciseState {
58    pub id: Uuid,
59    pub user_id: Uuid,
60    pub exercise_id: Uuid,
61    pub course_id: Option<Uuid>,
62    pub exam_id: Option<Uuid>,
63    pub created_at: DateTime<Utc>,
64    pub updated_at: DateTime<Utc>,
65    pub deleted_at: Option<DateTime<Utc>>,
66    pub score_given: Option<f32>,
67    pub grading_progress: GradingProgress,
68    pub activity_progress: ActivityProgress,
69    pub reviewing_stage: ReviewingStage,
70    pub selected_exercise_slide_id: Option<Uuid>,
71}
72
73impl UserExerciseState {
74    pub fn get_course_id(&self) -> ModelResult<Uuid> {
75        self.course_id.ok_or_else(|| {
76            ModelError::new(
77                ModelErrorType::Generic,
78                "Exercise is not part of a course.".to_string(),
79                None,
80            )
81        })
82    }
83
84    pub fn get_selected_exercise_slide_id(&self) -> ModelResult<Uuid> {
85        self.selected_exercise_slide_id.ok_or_else(|| {
86            ModelError::new(
87                ModelErrorType::Generic,
88                "No exercise slide selected.".to_string(),
89                None,
90            )
91        })
92    }
93}
94
95#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
96pub struct UserExerciseStateUpdate {
97    pub id: Uuid,
98    pub score_given: Option<f32>,
99    pub activity_progress: ActivityProgress,
100    pub reviewing_stage: ReviewingStage,
101    pub grading_progress: GradingProgress,
102}
103
104#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
105
106pub struct UserCourseProgress {
107    pub course_module_id: Uuid,
108    pub course_module_name: String,
109    pub course_module_order_number: i32,
110    pub score_given: f32,
111    pub score_required: Option<i32>,
112    pub score_maximum: Option<u32>,
113    pub total_exercises: Option<u32>,
114    pub attempted_exercises: Option<i32>,
115    pub attempted_exercises_required: Option<i32>,
116}
117
118#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
119
120pub struct UserCourseChapterExerciseProgress {
121    pub exercise_id: Uuid,
122    pub score_given: f32,
123}
124
125#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
126pub struct DatabaseUserCourseChapterExerciseProgress {
127    pub exercise_id: Uuid,
128    pub score_given: Option<f32>,
129}
130
131#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
132pub struct UserChapterMetrics {
133    pub score_given: Option<f32>,
134    pub attempted_exercises: Option<i64>,
135}
136
137#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
138pub struct UserCourseMetrics {
139    pub course_module_id: Uuid,
140    pub score_given: Option<f32>,
141    pub attempted_exercises: Option<i64>,
142}
143
144#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
145pub struct CourseExerciseMetrics {
146    course_module_id: Uuid,
147    total_exercises: Option<i64>,
148    score_maximum: Option<i64>,
149}
150
151#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone, ToSchema)]
152
153pub struct ExerciseUserCounts {
154    exercise_name: String,
155    exercise_order_number: i32,
156    page_order_number: i32,
157    chapter_number: i32,
158    exercise_id: Uuid,
159
160    n_users_attempted: Option<i64>,
161
162    n_users_with_some_points: Option<i64>,
163
164    n_users_with_max_points: Option<i64>,
165}
166
167pub async fn get_course_metrics(
168    conn: &mut PgConnection,
169    course_id: Uuid,
170) -> ModelResult<Vec<CourseExerciseMetrics>> {
171    let res = sqlx::query_as!(
172        CourseExerciseMetrics,
173        r"
174SELECT chapters.course_module_id,
175  COUNT(exercises.id) AS total_exercises,
176  SUM(exercises.score_maximum) AS score_maximum
177FROM courses c
178  LEFT JOIN exercises ON (c.id = exercises.course_id)
179  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
180WHERE exercises.deleted_at IS NULL
181  AND c.id = $1
182  AND chapters.course_module_id IS NOT NULL
183GROUP BY chapters.course_module_id
184        ",
185        course_id
186    )
187    .fetch_all(conn)
188    .await?;
189    Ok(res)
190}
191
192pub async fn get_course_metrics_open_chapters(
193    conn: &mut PgConnection,
194    course_id: Uuid,
195) -> ModelResult<Vec<CourseExerciseMetrics>> {
196    let res = sqlx::query_as!(
197        CourseExerciseMetrics,
198        r"
199SELECT chapters.course_module_id,
200  COUNT(exercises.id) AS total_exercises,
201  SUM(exercises.score_maximum) AS score_maximum
202FROM courses c
203  LEFT JOIN exercises ON (c.id = exercises.course_id)
204  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
205WHERE exercises.deleted_at IS NULL
206  AND c.id = $1
207  AND chapters.course_module_id IS NOT NULL
208  AND chapters.deleted_at IS NULL
209  AND ((chapters.opens_at < now()) OR chapters.opens_at IS NULL)
210GROUP BY chapters.course_module_id
211        ",
212        course_id
213    )
214    .fetch_all(conn)
215    .await?;
216    Ok(res)
217}
218
219pub async fn get_course_metrics_indexed_by_module_id(
220    conn: &mut PgConnection,
221    course_id: Uuid,
222    only_open_chapters: bool,
223) -> ModelResult<HashMap<Uuid, CourseExerciseMetrics>> {
224    let res = if only_open_chapters {
225        get_course_metrics_open_chapters(conn, course_id)
226            .await?
227            .into_iter()
228            .map(|x| (x.course_module_id, x))
229            .collect()
230    } else {
231        get_course_metrics(conn, course_id)
232            .await?
233            .into_iter()
234            .map(|x| (x.course_module_id, x))
235            .collect()
236    };
237    Ok(res)
238}
239
240/// Gets course metrics for a single module.
241pub async fn get_single_module_metrics(
242    conn: &mut PgConnection,
243    course_id: Uuid,
244    course_module_id: Uuid,
245    user_id: Uuid,
246) -> ModelResult<UserCourseMetrics> {
247    let res = sqlx::query!(
248        "
249SELECT COUNT(ues.exercise_id) AS attempted_exercises,
250  COALESCE(SUM(ues.score_given), 0) AS score_given
251FROM user_exercise_states AS ues
252  LEFT JOIN exercises ON (ues.exercise_id = exercises.id)
253  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
254WHERE chapters.course_module_id = $1
255  AND ues.course_id = $2
256  AND ues.activity_progress IN ('completed', 'submitted')
257  AND ues.user_id = $3
258  AND ues.deleted_at IS NULL
259        ",
260        course_module_id,
261        course_id,
262        user_id,
263    )
264    .map(|x| UserCourseMetrics {
265        course_module_id,
266        score_given: x.score_given,
267        attempted_exercises: x.attempted_exercises,
268    })
269    .fetch_one(conn)
270    .await?;
271    Ok(res)
272}
273
274pub async fn get_user_course_metrics(
275    conn: &mut PgConnection,
276    course_id: Uuid,
277    user_id: Uuid,
278) -> ModelResult<Vec<UserCourseMetrics>> {
279    let res = sqlx::query_as!(
280        UserCourseMetrics,
281        r"
282SELECT chapters.course_module_id,
283  COUNT(ues.exercise_id) AS attempted_exercises,
284  COALESCE(SUM(ues.score_given), 0) AS score_given
285FROM user_exercise_states AS ues
286  LEFT JOIN exercises ON (ues.exercise_id = exercises.id)
287  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
288WHERE ues.course_id = $1
289  AND ues.activity_progress IN ('completed', 'submitted')
290  AND ues.user_id = $2
291  AND ues.deleted_at IS NULL
292GROUP BY chapters.course_module_id;
293        ",
294        course_id,
295        user_id,
296    )
297    .fetch_all(conn)
298    .await?;
299    Ok(res)
300}
301
302pub async fn get_user_course_metrics_only_open_chapters(
303    conn: &mut PgConnection,
304    course_id: Uuid,
305    user_id: Uuid,
306) -> ModelResult<Vec<UserCourseMetrics>> {
307    let res = sqlx::query_as!(
308        UserCourseMetrics,
309        r"
310SELECT chapters.course_module_id,
311  COUNT(ues.exercise_id) AS attempted_exercises,
312  COALESCE(SUM(ues.score_given), 0) AS score_given
313FROM user_exercise_states AS ues
314  LEFT JOIN exercises ON (ues.exercise_id = exercises.id)
315  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
316WHERE ues.course_id = $1
317  AND ues.activity_progress IN ('completed', 'submitted')
318  AND ues.user_id = $2
319  AND ues.deleted_at IS NULL
320  AND chapters.deleted_at IS NULL
321  AND ((chapters.opens_at < now()) OR chapters.opens_at IS NULL)
322GROUP BY chapters.course_module_id;
323        ",
324        course_id,
325        user_id,
326    )
327    .fetch_all(conn)
328    .await?;
329    Ok(res)
330}
331
332pub async fn get_user_course_metrics_indexed_by_module_id(
333    conn: &mut PgConnection,
334    course_id: Uuid,
335    user_id: Uuid,
336    only_open_chapters: bool,
337) -> ModelResult<HashMap<Uuid, UserCourseMetrics>> {
338    let res = if only_open_chapters {
339        get_user_course_metrics_only_open_chapters(conn, course_id, user_id)
340            .await?
341            .into_iter()
342            .map(|x| (x.course_module_id, x))
343            .collect()
344    } else {
345        get_user_course_metrics(conn, course_id, user_id)
346            .await?
347            .into_iter()
348            .map(|x| (x.course_module_id, x))
349            .collect()
350    };
351    Ok(res)
352}
353
354pub async fn get_user_course_chapter_metrics(
355    conn: &mut PgConnection,
356    course_id: Uuid,
357    exercise_ids: &[Uuid],
358    user_id: Uuid,
359) -> ModelResult<UserChapterMetrics> {
360    let res = sqlx::query_as!(
361        UserChapterMetrics,
362        r#"
363SELECT COUNT(ues.exercise_id) AS attempted_exercises,
364  COALESCE(SUM(ues.score_given), 0) AS score_given
365FROM user_exercise_states AS ues
366WHERE ues.exercise_id IN (
367    SELECT UNNEST($1::uuid [])
368  )
369  AND ues.deleted_at IS NULL
370  AND ues.activity_progress IN ('completed', 'submitted')
371  AND ues.user_id = $2
372  AND ues.course_id = $3;
373                "#,
374        &exercise_ids,
375        user_id,
376        course_id
377    )
378    .fetch_one(conn)
379    .await?;
380    Ok(res)
381}
382
383pub async fn get_user_course_progress(
384    conn: &mut PgConnection,
385    course_id: Uuid,
386    user_id: Uuid,
387    only_open_chapters: bool,
388) -> ModelResult<Vec<UserCourseProgress>> {
389    let course_metrics =
390        get_course_metrics_indexed_by_module_id(&mut *conn, course_id, only_open_chapters).await?;
391    let user_metrics =
392        get_user_course_metrics_indexed_by_module_id(conn, course_id, user_id, only_open_chapters)
393            .await?;
394    let course_name = courses::get_course(conn, course_id).await?.name;
395    let course_modules = if only_open_chapters {
396        course_modules::get_by_course_id_only_with_open_chapters(conn, course_id).await?
397    } else {
398        course_modules::get_by_course_id(conn, course_id).await?
399    };
400    merge_modules_with_metrics(course_modules, &course_metrics, &user_metrics, &course_name)
401}
402
403/// Gets the total amount of points that the user has received from an exam.
404///
405/// The caller should take into consideration that for an ongoing exam the result will be volatile.
406pub async fn get_user_total_exam_points(
407    conn: &mut PgConnection,
408    user_id: Uuid,
409    exam_id: Uuid,
410) -> ModelResult<Option<f32>> {
411    let res = sqlx::query!(
412        r#"
413SELECT SUM(score_given) AS "points"
414FROM user_exercise_states
415WHERE user_id = $2
416  AND exam_id = $1
417  AND deleted_at IS NULL
418        "#,
419        exam_id,
420        user_id,
421    )
422    .map(|x| x.points)
423    .fetch_one(conn)
424    .await?;
425    Ok(res)
426}
427
428fn merge_modules_with_metrics(
429    course_modules: Vec<CourseModule>,
430    course_metrics_by_course_module_id: &HashMap<Uuid, CourseExerciseMetrics>,
431    user_metrics_by_course_module_id: &HashMap<Uuid, UserCourseMetrics>,
432    default_course_module_name_placeholder: &str,
433) -> ModelResult<Vec<UserCourseProgress>> {
434    course_modules
435        .into_iter()
436        .map(|course_module| {
437            let user_metrics = user_metrics_by_course_module_id.get(&course_module.id);
438            let course_metrics = course_metrics_by_course_module_id.get(&course_module.id);
439            let requirements = course_module.completion_policy.automatic();
440            let progress = UserCourseProgress {
441                course_module_id: course_module.id,
442                // Only default course module doesn't have a name.
443                course_module_name: course_module
444                    .name
445                    .unwrap_or_else(|| default_course_module_name_placeholder.to_string()),
446                course_module_order_number: course_module.order_number,
447                score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
448                    user_metrics.and_then(|x| x.score_given),
449                ),
450                score_required: requirements.and_then(|x| x.number_of_points_treshold),
451                score_maximum: course_metrics
452                    .and_then(|x| x.score_maximum)
453                    .map(TryInto::try_into)
454                    .transpose()?,
455                total_exercises: course_metrics
456                    .and_then(|x| x.total_exercises)
457                    .map(TryInto::try_into)
458                    .transpose()?,
459                attempted_exercises: user_metrics
460                    .and_then(|x| x.attempted_exercises)
461                    .map(TryInto::try_into)
462                    .transpose()?,
463                attempted_exercises_required: requirements
464                    .and_then(|x| x.number_of_exercises_attempted_treshold),
465            };
466            Ok(progress)
467        })
468        .collect::<ModelResult<_>>()
469}
470
471pub async fn get_user_course_chapter_exercises_progress(
472    conn: &mut PgConnection,
473    course_id: Uuid,
474    exercise_ids: &[Uuid],
475    user_id: Uuid,
476) -> ModelResult<Vec<DatabaseUserCourseChapterExerciseProgress>> {
477    let res = sqlx::query_as!(
478        DatabaseUserCourseChapterExerciseProgress,
479        r#"
480SELECT COALESCE(ues.score_given, 0) AS score_given,
481  ues.exercise_id AS exercise_id
482FROM user_exercise_states AS ues
483WHERE ues.deleted_at IS NULL
484  AND ues.exercise_id IN (
485    SELECT UNNEST($1::uuid [])
486  )
487  AND ues.course_id = $2
488  AND ues.user_id = $3;
489        "#,
490        exercise_ids,
491        course_id,
492        user_id,
493    )
494    .fetch_all(conn)
495    .await?;
496    Ok(res)
497}
498
499pub async fn get_or_create_user_exercise_state(
500    conn: &mut PgConnection,
501    user_id: Uuid,
502    exercise_id: Uuid,
503    course_id: Option<Uuid>,
504    exam_id: Option<Uuid>,
505) -> ModelResult<UserExerciseState> {
506    let existing = sqlx::query_as!(
507        UserExerciseState,
508        r#"
509SELECT *FROM user_exercise_states
510WHERE user_id = $1
511  AND exercise_id = $2
512  AND (course_id = $3 OR exam_id = $4)
513  AND deleted_at IS NULL
514"#,
515        user_id,
516        exercise_id,
517        course_id,
518        exam_id
519    )
520    .fetch_optional(&mut *conn)
521    .await?;
522
523    let res = if let Some(existing) = existing {
524        existing
525    } else {
526        sqlx::query_as!(
527            UserExerciseState,
528            r#"
529    INSERT INTO user_exercise_states (user_id, exercise_id, course_id, exam_id)
530    VALUES ($1, $2, $3, $4)
531    RETURNING *      "#,
532            user_id,
533            exercise_id,
534            course_id,
535            exam_id
536        )
537        .fetch_one(&mut *conn)
538        .await?
539    };
540    Ok(res)
541}
542
543pub async fn get_or_create_user_exercise_state_for_users(
544    conn: &mut PgConnection,
545    user_ids: &[Uuid],
546    exercise_id: Uuid,
547    course_id: Option<Uuid>,
548    exam_id: Option<Uuid>,
549) -> ModelResult<HashMap<Uuid, UserExerciseState>> {
550    let existing = sqlx::query_as!(
551        UserExerciseState,
552        r#"
553SELECT *FROM user_exercise_states
554WHERE user_id IN (
555    SELECT UNNEST($1::uuid [])
556  )
557  AND exercise_id = $2
558  AND (course_id = $3 OR exam_id = $4)
559  AND deleted_at IS NULL
560"#,
561        user_ids,
562        exercise_id,
563        course_id,
564        exam_id
565    )
566    .fetch_all(&mut *conn)
567    .await?;
568
569    let mut res = HashMap::with_capacity(user_ids.len());
570    for item in existing.into_iter() {
571        res.insert(item.user_id, item);
572    }
573
574    let missing_user_ids = user_ids
575        .iter()
576        .filter(|user_id| !res.contains_key(user_id))
577        .copied()
578        .collect::<Vec<_>>();
579
580    let created = sqlx::query_as!(
581        UserExerciseState,
582        r#"
583    INSERT INTO user_exercise_states (user_id, exercise_id, course_id, exam_id)
584    SELECT UNNEST($1::uuid []), $2, $3, $4
585    RETURNING *      "#,
586        &missing_user_ids,
587        exercise_id,
588        course_id,
589        exam_id
590    )
591    .fetch_all(&mut *conn)
592    .await?;
593
594    for item in created.into_iter() {
595        res.insert(item.user_id, item);
596    }
597    Ok(res)
598}
599
600pub async fn get_by_user_ids_and_exercise_id(
601    conn: &mut PgConnection,
602    user_ids: &[Uuid],
603    exercise_id: Uuid,
604) -> ModelResult<Vec<UserExerciseState>> {
605    let res = sqlx::query_as!(
606        UserExerciseState,
607        r#"
608SELECT *FROM user_exercise_states
609WHERE user_id = ANY($1)
610  AND exercise_id = $2
611  AND deleted_at IS NULL
612        "#,
613        user_ids,
614        exercise_id
615    )
616    .fetch_all(conn)
617    .await?;
618    Ok(res)
619}
620
621pub async fn get_by_course_id_and_user_ids_and_exercise_ids(
622    conn: &mut PgConnection,
623    course_id: Uuid,
624    user_ids: &[Uuid],
625    exercise_ids: &[Uuid],
626) -> ModelResult<Vec<UserExerciseState>> {
627    let res = sqlx::query_as!(
628        UserExerciseState,
629        r#"
630SELECT ues.id,
631  ues.user_id,
632  ues.exercise_id,
633  ues.course_id,
634  ues.exam_id,
635  ues.created_at,
636  ues.updated_at,
637  ues.deleted_at,
638  ues.score_given,
639  ues.grading_progress,
640  ues.activity_progress,
641  ues.reviewing_stage,
642  ues.selected_exercise_slide_id
643FROM user_exercise_states ues
644  JOIN exercises e ON e.id = ues.exercise_id
645WHERE ues.course_id = $1
646  AND e.course_id = $1
647  AND ues.user_id = ANY($2)
648  AND ues.exercise_id = ANY($3)
649  AND ues.deleted_at IS NULL
650  AND e.deleted_at IS NULL
651        "#,
652        course_id,
653        user_ids,
654        exercise_ids
655    )
656    .fetch_all(conn)
657    .await?;
658    Ok(res)
659}
660
661pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<UserExerciseState> {
662    let res = sqlx::query_as!(
663        UserExerciseState,
664        r#"
665SELECT *FROM user_exercise_states
666WHERE id = $1
667  AND deleted_at IS NULL
668        "#,
669        id,
670    )
671    .fetch_one(conn)
672    .await?;
673    Ok(res)
674}
675
676pub async fn recalculate_by_id_and_exercise_id(
677    conn: &mut PgConnection,
678    state_id: Uuid,
679    exercise_id: Uuid,
680) -> ModelResult<UserExerciseState> {
681    sqlx::query!(
682        r#"
683SELECT id
684FROM user_exercise_states
685WHERE id = $1
686  AND exercise_id = $2
687  AND deleted_at IS NULL
688        "#,
689        state_id,
690        exercise_id
691    )
692    .fetch_one(&mut *conn)
693    .await?;
694
695    crate::library::user_exercise_state_updater::update_user_exercise_state(conn, state_id).await
696}
697
698pub async fn get_by_ids(
699    conn: &mut PgConnection,
700    ids: &[Uuid],
701) -> ModelResult<Vec<UserExerciseState>> {
702    let res = sqlx::query_as!(
703        UserExerciseState,
704        r#"
705SELECT *FROM user_exercise_states
706WHERE id = ANY($1)
707AND deleted_at IS NULL
708"#,
709        &ids
710    )
711    .fetch_all(conn)
712    .await?;
713    Ok(res)
714}
715
716pub async fn get_user_total_course_points(
717    conn: &mut PgConnection,
718    user_id: Uuid,
719    course_id: Uuid,
720) -> ModelResult<Option<f32>> {
721    let res = sqlx::query!(
722        r#"
723SELECT SUM(score_given) AS "total_points"
724FROM user_exercise_states
725WHERE user_id = $1
726  AND course_id = $2
727  AND deleted_at IS NULL
728  GROUP BY user_id
729        "#,
730        user_id,
731        course_id,
732    )
733    .map(|x| x.total_points)
734    .fetch_one(conn)
735    .await?;
736    Ok(res)
737}
738
739pub async fn get_users_current_by_exercise(
740    conn: &mut PgConnection,
741    user_id: Uuid,
742    exercise: &Exercise,
743) -> ModelResult<UserExerciseState> {
744    let course_or_exam_id =
745        CourseOrExamId::from_course_and_exam_ids(exercise.course_id, exercise.exam_id)?;
746
747    let user_exercise_state =
748        get_user_exercise_state_if_exists(conn, user_id, exercise.id, course_or_exam_id)
749            .await?
750            .ok_or_else(|| {
751                ModelError::new(
752                    ModelErrorType::PreconditionFailed,
753                    "Missing user exercise state.".to_string(),
754                    None,
755                )
756            })?;
757    Ok(user_exercise_state)
758}
759
760pub async fn get_user_exercise_state_if_exists(
761    conn: &mut PgConnection,
762    user_id: Uuid,
763    exercise_id: Uuid,
764    course_or_exam_id: CourseOrExamId,
765) -> ModelResult<Option<UserExerciseState>> {
766    let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
767    let res = sqlx::query_as!(
768        UserExerciseState,
769        r#"
770SELECT *FROM user_exercise_states
771WHERE user_id = $1
772  AND exercise_id = $2
773  AND (course_id = $3 OR exam_id = $4)
774  AND deleted_at IS NULL
775      "#,
776        user_id,
777        exercise_id,
778        course_id,
779        exam_id
780    )
781    .fetch_optional(conn)
782    .await?;
783    Ok(res)
784}
785
786/// Returns true when user has chapter exercises pending teacher review.
787pub async fn has_pending_manual_reviews_in_chapter(
788    conn: &mut PgConnection,
789    user_id: Uuid,
790    chapter_id: Uuid,
791) -> ModelResult<bool> {
792    struct PendingManualReviewsInChapterRow {
793        exists: bool,
794    }
795
796    let pending_manual_reviews = sqlx::query_as!(
797        PendingManualReviewsInChapterRow,
798        r#"
799SELECT EXISTS (
800    SELECT 1
801    FROM user_exercise_states ues
802    JOIN exercises e ON e.id = ues.exercise_id
803    WHERE ues.user_id = $1
804      AND e.chapter_id = $2
805      AND ues.reviewing_stage = 'waiting_for_manual_grading'::reviewing_stage
806      AND ues.deleted_at IS NULL
807      AND e.deleted_at IS NULL
808 ) as "exists!"
809        "#,
810        user_id,
811        chapter_id
812    )
813    .fetch_one(conn)
814    .await?
815    .exists;
816    Ok(pending_manual_reviews)
817}
818
819/// Returns true when the user has exercises in the given course module pending teacher review.
820pub async fn has_pending_manual_reviews_in_module(
821    conn: &mut PgConnection,
822    user_id: Uuid,
823    course_id: Uuid,
824    course_module_id: Uuid,
825) -> ModelResult<bool> {
826    struct PendingManualReviewsInModuleRow {
827        exists: bool,
828    }
829
830    let pending_manual_reviews = sqlx::query_as!(
831        PendingManualReviewsInModuleRow,
832        r#"
833SELECT EXISTS (
834    SELECT 1
835    FROM user_exercise_states ues
836    JOIN exercises e ON e.id = ues.exercise_id
837    JOIN chapters c ON c.id = e.chapter_id
838    WHERE ues.user_id = $1
839      AND ues.course_id = $2
840      AND c.course_module_id = $3
841      AND ues.reviewing_stage = 'waiting_for_manual_grading'::reviewing_stage
842      AND ues.deleted_at IS NULL
843      AND e.deleted_at IS NULL
844      AND c.deleted_at IS NULL
845 ) as "exists!"
846        "#,
847        user_id,
848        course_id,
849        course_module_id
850    )
851    .fetch_one(conn)
852    .await?
853    .exists;
854    Ok(pending_manual_reviews)
855}
856
857pub async fn get_all_for_user_and_course_or_exam(
858    conn: &mut PgConnection,
859    user_id: Uuid,
860    course_or_exam_id: CourseOrExamId,
861) -> ModelResult<Vec<UserExerciseState>> {
862    let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
863    let res = sqlx::query_as!(
864        UserExerciseState,
865        r#"
866SELECT *FROM user_exercise_states
867WHERE user_id = $1
868  AND (course_id = $2 OR exam_id = $3)
869  AND deleted_at IS NULL
870      "#,
871        user_id,
872        course_id,
873        exam_id
874    )
875    .fetch_all(conn)
876    .await?;
877    Ok(res)
878}
879
880pub async fn upsert_selected_exercise_slide_id(
881    conn: &mut PgConnection,
882    user_id: Uuid,
883    exercise_id: Uuid,
884    course_id: Option<Uuid>,
885    exam_id: Option<Uuid>,
886    selected_exercise_slide_id: Option<Uuid>,
887) -> ModelResult<()> {
888    let existing = sqlx::query!(
889        "
890SELECT
891FROM user_exercise_states
892WHERE user_id = $1
893  AND exercise_id = $2
894  AND (course_id = $3 OR exam_id = $4)
895  AND deleted_at IS NULL
896",
897        user_id,
898        exercise_id,
899        course_id,
900        exam_id
901    )
902    .fetch_optional(&mut *conn)
903    .await?;
904    if existing.is_some() {
905        sqlx::query!(
906            "
907UPDATE user_exercise_states
908SET selected_exercise_slide_id = $4
909WHERE user_id = $1
910  AND exercise_id = $2
911  AND (course_id = $3 OR exam_id = $5)
912  AND deleted_at IS NULL
913    ",
914            user_id,
915            exercise_id,
916            course_id,
917            selected_exercise_slide_id,
918            exam_id
919        )
920        .execute(&mut *conn)
921        .await?;
922    } else {
923        sqlx::query!(
924            "
925    INSERT INTO user_exercise_states (
926        user_id,
927        exercise_id,
928        course_id,
929        selected_exercise_slide_id,
930        exam_id
931      )
932    VALUES ($1, $2, $3, $4, $5)
933    ",
934            user_id,
935            exercise_id,
936            course_id,
937            selected_exercise_slide_id,
938            exam_id
939        )
940        .execute(&mut *conn)
941        .await?;
942    }
943    Ok(())
944}
945
946/// TODO: should be moved to the user_exercise_state_updater as a private module so that this cannot be called outside of that module
947pub async fn update(
948    conn: &mut PgConnection,
949    user_exercise_state_update: UserExerciseStateUpdate,
950) -> ModelResult<UserExerciseState> {
951    let res = sqlx::query_as!(
952        UserExerciseState,
953        r#"
954UPDATE user_exercise_states
955SET score_given = $1,
956  activity_progress = $2,
957  reviewing_stage = $3,
958  grading_progress = $4
959WHERE id = $5
960  AND deleted_at IS NULL
961RETURNING *        "#,
962        user_exercise_state_update.score_given,
963        user_exercise_state_update.activity_progress as ActivityProgress,
964        user_exercise_state_update.reviewing_stage as ReviewingStage,
965        user_exercise_state_update.grading_progress as GradingProgress,
966        user_exercise_state_update.id,
967    )
968    .fetch_one(conn)
969    .await?;
970    Ok(res)
971}
972
973pub async fn update_reviewing_stage(
974    conn: &mut PgConnection,
975    user_id: Uuid,
976    course_or_exam_id: CourseOrExamId,
977    exercise_id: Uuid,
978    new_reviewing_stage: ReviewingStage,
979) -> ModelResult<UserExerciseState> {
980    let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
981    let res = sqlx::query_as!(
982        UserExerciseState,
983        r#"
984UPDATE user_exercise_states
985SET reviewing_stage = $5
986WHERE user_id = $1
987AND (course_id = $2 OR exam_id = $3)
988AND exercise_id = $4
989RETURNING *        "#,
990        user_id,
991        course_id,
992        exam_id,
993        exercise_id,
994        new_reviewing_stage as ReviewingStage
995    )
996    .fetch_one(conn)
997    .await?;
998    Ok(res)
999}
1000
1001/// TODO: should be removed
1002pub async fn update_exercise_progress(
1003    conn: &mut PgConnection,
1004    id: Uuid,
1005    reviewing_stage: ReviewingStage,
1006) -> ModelResult<UserExerciseState> {
1007    let res = sqlx::query_as!(
1008        UserExerciseState,
1009        r#"
1010UPDATE user_exercise_states
1011SET reviewing_stage = $1
1012WHERE id = $2
1013  AND deleted_at IS NULL
1014RETURNING *        "#,
1015        reviewing_stage as ReviewingStage,
1016        id
1017    )
1018    .fetch_one(conn)
1019    .await?;
1020    Ok(res)
1021}
1022
1023/// Convenience struct that combines user state to the exercise.
1024///
1025/// Many operations require information about both the user state and the exercise. However, because
1026/// exercises can either belong to a course or an exam it can get difficult to track the proper context.
1027pub struct ExerciseWithUserState {
1028    exercise: Exercise,
1029    user_exercise_state: UserExerciseState,
1030    type_data: EwusCourseOrExam,
1031}
1032
1033impl ExerciseWithUserState {
1034    pub fn new(exercise: Exercise, user_exercise_state: UserExerciseState) -> ModelResult<Self> {
1035        let state = EwusCourseOrExam::from_exercise_and_user_exercise_state(
1036            &exercise,
1037            &user_exercise_state,
1038        )?;
1039        Ok(Self {
1040            exercise,
1041            user_exercise_state,
1042            type_data: state,
1043        })
1044    }
1045
1046    /// Provides a reference to the inner `Exercise`.
1047    pub fn exercise(&self) -> &Exercise {
1048        &self.exercise
1049    }
1050
1051    /// Provides a reference to the inner `UserExerciseState`.
1052    pub fn user_exercise_state(&self) -> &UserExerciseState {
1053        &self.user_exercise_state
1054    }
1055
1056    pub fn exercise_context(&self) -> &EwusCourseOrExam {
1057        &self.type_data
1058    }
1059
1060    pub fn set_user_exercise_state(
1061        &mut self,
1062        user_exercise_state: UserExerciseState,
1063    ) -> ModelResult<()> {
1064        self.type_data = EwusCourseOrExam::from_exercise_and_user_exercise_state(
1065            &self.exercise,
1066            &user_exercise_state,
1067        )?;
1068        self.user_exercise_state = user_exercise_state;
1069        Ok(())
1070    }
1071
1072    pub fn is_exam_exercise(&self) -> bool {
1073        match self.type_data {
1074            EwusCourseOrExam::Course(_) => false,
1075            EwusCourseOrExam::Exam(_) => true,
1076        }
1077    }
1078}
1079
1080pub struct EwusCourse {
1081    pub course_id: Uuid,
1082}
1083
1084pub struct EwusExam {
1085    pub exam_id: Uuid,
1086}
1087
1088pub enum EwusContext<C, E> {
1089    Course(C),
1090    Exam(E),
1091}
1092
1093pub enum EwusCourseOrExam {
1094    Course(EwusCourse),
1095    Exam(EwusExam),
1096}
1097
1098impl EwusCourseOrExam {
1099    pub fn from_exercise_and_user_exercise_state(
1100        exercise: &Exercise,
1101        user_exercise_state: &UserExerciseState,
1102    ) -> ModelResult<Self> {
1103        if exercise.id == user_exercise_state.exercise_id {
1104            let course_id = exercise.course_id;
1105            let exam_id = exercise.exam_id;
1106            match (course_id, exam_id) {
1107                (None, Some(exam_id)) => Ok(Self::Exam(EwusExam { exam_id })),
1108                (Some(course_id), None) => Ok(Self::Course(EwusCourse { course_id })),
1109                _ => Err(ModelError::new(
1110                    ModelErrorType::Generic,
1111                    "Invalid initializer data.".to_string(),
1112                    None,
1113                )),
1114            }
1115        } else {
1116            Err(ModelError::new(
1117                ModelErrorType::Generic,
1118                "Exercise doesn't match the state.".to_string(),
1119                None,
1120            ))
1121        }
1122    }
1123}
1124
1125#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1126pub struct CourseUserPoints {
1127    pub user_id: Uuid,
1128    pub points_for_each_chapter: Vec<CourseUserPointsInner>,
1129}
1130
1131#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1132pub struct CourseUserPointsInner {
1133    pub chapter_number: i32,
1134    pub points_for_chapter: f32,
1135}
1136
1137#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1138pub struct ExamUserPoints {
1139    pub user_id: Uuid,
1140    pub email: String,
1141    pub points_for_exercise: Vec<ExamUserPointsInner>,
1142}
1143
1144#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1145pub struct ExamUserPointsInner {
1146    pub exercise_id: Uuid,
1147    pub score_given: f32,
1148}
1149
1150pub fn stream_course_points(
1151    conn: &mut PgConnection,
1152    course_id: Uuid,
1153) -> impl Stream<Item = sqlx::Result<CourseUserPoints>> + '_ {
1154    sqlx::query!(
1155        "
1156SELECT user_id,
1157  to_jsonb(array_agg(to_jsonb(uue) - 'email' - 'user_id')) AS points_for_each_chapter
1158FROM (
1159    SELECT ud.email,
1160      u.id AS user_id,
1161      c.chapter_number,
1162      COALESCE(SUM(ues.score_given), 0) AS points_for_chapter
1163    FROM user_exercise_states ues
1164      JOIN users u ON u.id = ues.user_id
1165      JOIN user_details ud ON ud.user_id = u.id
1166      JOIN exercises e ON e.id = ues.exercise_id
1167      JOIN chapters c on e.chapter_id = c.id
1168    WHERE ues.course_id = $1
1169      AND ues.deleted_at IS NULL
1170      AND c.deleted_at IS NULL
1171      AND u.deleted_at IS NULL
1172      AND e.deleted_at IS NULL
1173    GROUP BY ud.email,
1174      u.id,
1175      c.chapter_number
1176  ) as uue
1177GROUP BY user_id
1178
1179",
1180        course_id
1181    )
1182    .try_map(|i| {
1183        let user_id = i.user_id;
1184        let points_for_each_chapter = i.points_for_each_chapter.unwrap_or(Value::Null);
1185        serde_json::from_value(points_for_each_chapter)
1186            .map(|points_for_each_chapter| CourseUserPoints {
1187                user_id,
1188                points_for_each_chapter,
1189            })
1190            .map_err(|e| sqlx::Error::Decode(Box::new(e)))
1191    })
1192    .fetch(conn)
1193}
1194
1195pub fn stream_exam_points(
1196    conn: &mut PgConnection,
1197    exam_id: Uuid,
1198) -> impl Stream<Item = sqlx::Result<ExamUserPoints>> + '_ {
1199    sqlx::query!(
1200        "
1201SELECT user_id,
1202  email,
1203  to_jsonb(array_agg(to_jsonb(uue) - 'email' - 'user_id')) AS points_for_exercises
1204FROM (
1205    SELECT u.id AS user_id,
1206      ud.email,
1207      exercise_id,
1208      COALESCE(score_given, 0) as score_given
1209    FROM user_exercise_states ues
1210      JOIN users u ON u.id = ues.user_id
1211      JOIN user_details ud ON ud.user_id = u.id
1212      JOIN exercises e ON e.id = ues.exercise_id
1213    WHERE ues.exam_id = $1
1214      AND ues.deleted_at IS NULL
1215      AND u.deleted_at IS NULL
1216      AND e.deleted_at IS NULL
1217  ) as uue
1218GROUP BY user_id,
1219  email
1220",
1221        exam_id
1222    )
1223    .try_map(|i| {
1224        let user_id = i.user_id;
1225        let points_for_exercises = i.points_for_exercises.unwrap_or(Value::Null);
1226        serde_json::from_value(points_for_exercises)
1227            .map(|points_for_exercise| ExamUserPoints {
1228                user_id,
1229                points_for_exercise,
1230                email: i.email,
1231            })
1232            .map_err(|e| sqlx::Error::Decode(Box::new(e)))
1233    })
1234    .fetch(conn)
1235}
1236
1237pub async fn get_course_users_counts_by_exercise(
1238    conn: &mut PgConnection,
1239    course_id: Uuid,
1240) -> ModelResult<Vec<ExerciseUserCounts>> {
1241    let res = sqlx::query_as!(
1242        ExerciseUserCounts,
1243        r#"
1244SELECT exercises.name as exercise_name,
1245  exercises.order_number as exercise_order_number,
1246  pages.order_number as page_order_number,
1247  chapters.chapter_number,
1248  stat_data.*
1249FROM (
1250    SELECT exercise_id,
1251      COUNT(DISTINCT user_id) FILTER (
1252        WHERE ues.activity_progress = 'completed'
1253      ) as n_users_attempted,
1254      COUNT(DISTINCT user_id) FILTER (
1255        WHERE ues.score_given IS NOT NULL
1256          and ues.score_given > 0
1257          AND ues.activity_progress = 'completed'
1258      ) as n_users_with_some_points,
1259      COUNT(DISTINCT user_id) FILTER (
1260        WHERE ues.score_given IS NOT NULL
1261          and ues.score_given >= exercises.score_maximum
1262          and ues.activity_progress = 'completed'
1263      ) as n_users_with_max_points
1264    FROM exercises
1265      JOIN user_exercise_states ues on exercises.id = ues.exercise_id
1266    WHERE exercises.course_id = $1
1267      AND exercises.deleted_at IS NULL
1268      AND ues.deleted_at IS NULL
1269    GROUP BY exercise_id
1270  ) as stat_data
1271  JOIN exercises ON stat_data.exercise_id = exercises.id
1272  JOIN pages on exercises.page_id = pages.id
1273  JOIN chapters on pages.chapter_id = chapters.id
1274WHERE exercises.deleted_at IS NULL
1275  AND pages.deleted_at IS NULL
1276  AND chapters.deleted_at IS NULL
1277          "#,
1278        course_id
1279    )
1280    .fetch_all(conn)
1281    .await?;
1282    Ok(res)
1283}
1284
1285#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1286
1287pub struct ExportedUserExerciseState {
1288    pub id: Uuid,
1289    pub user_id: Uuid,
1290    pub exercise_id: Uuid,
1291    pub course_id: Option<Uuid>,
1292    pub created_at: DateTime<Utc>,
1293    pub updated_at: DateTime<Utc>,
1294    pub score_given: Option<f32>,
1295    pub grading_progress: GradingProgress,
1296    pub activity_progress: ActivityProgress,
1297    pub reviewing_stage: ReviewingStage,
1298    pub selected_exercise_slide_id: Option<Uuid>,
1299}
1300
1301pub fn stream_user_exercise_states_for_course<'a>(
1302    conn: &'a mut PgConnection,
1303    course_ids: &'a [Uuid],
1304) -> impl Stream<Item = sqlx::Result<ExportedUserExerciseState>> + 'a {
1305    sqlx::query_as!(
1306        ExportedUserExerciseState,
1307        r#"
1308SELECT id,
1309  user_id,
1310  exercise_id,
1311  course_id,
1312  created_at,
1313  updated_at,
1314  score_given,
1315  grading_progress,
1316  activity_progress,
1317  reviewing_stage,
1318  selected_exercise_slide_id
1319FROM user_exercise_states
1320WHERE course_id = ANY($1)
1321  AND deleted_at IS NULL
1322        "#,
1323        course_ids
1324    )
1325    .fetch(conn)
1326}
1327
1328pub async fn get_all_for_course(
1329    conn: &mut PgConnection,
1330    course_id: Uuid,
1331) -> ModelResult<Vec<UserExerciseState>> {
1332    let res = sqlx::query_as!(
1333        UserExerciseState,
1334        r#"
1335SELECT *FROM user_exercise_states
1336WHERE course_id = $1
1337  AND deleted_at IS NULL
1338"#,
1339        course_id,
1340    )
1341    .fetch_all(&mut *conn)
1342    .await?;
1343    Ok(res)
1344}
1345
1346pub async fn get_returned_exercise_ids_for_user_and_course(
1347    conn: &mut PgConnection,
1348    exercise_ids: &[Uuid],
1349    user_id: Uuid,
1350    course_id: Uuid,
1351) -> ModelResult<Vec<Uuid>> {
1352    #[derive(sqlx::FromRow)]
1353    struct ExerciseIdRow {
1354        exercise_id: Uuid,
1355    }
1356
1357    let returned_exercise_ids: Vec<ExerciseIdRow> = sqlx::query_as::<_, ExerciseIdRow>(
1358        r#"
1359        SELECT DISTINCT exercise_id
1360        FROM user_exercise_states
1361        WHERE exercise_id = ANY($1::uuid[])
1362          AND user_id = $2
1363          AND course_id = $3
1364          AND deleted_at IS NULL
1365          AND activity_progress IN ('completed', 'submitted')
1366        "#,
1367    )
1368    .bind(exercise_ids)
1369    .bind(user_id)
1370    .bind(course_id)
1371    .fetch_all(conn)
1372    .await?;
1373
1374    Ok(returned_exercise_ids
1375        .into_iter()
1376        .map(|r| r.exercise_id)
1377        .collect())
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382    use chrono::TimeZone;
1383
1384    use super::*;
1385    use crate::{
1386        chapters::NewChapter,
1387        exercise_slides, exercises,
1388        library::content_management::create_new_chapter,
1389        pages::{NewPage, insert_page},
1390        test_helper::*,
1391    };
1392
1393    mod getting_single_module_course_metrics {
1394        use super::*;
1395
1396        #[tokio::test]
1397        async fn works_without_any_user_exercise_states() {
1398            insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1399            let res = get_single_module_metrics(tx.as_mut(), course, course_module.id, user).await;
1400            assert!(res.is_ok())
1401        }
1402    }
1403
1404    #[test]
1405    fn merges_course_modules_with_metrics() {
1406        let timestamp = Utc.with_ymd_and_hms(2022, 6, 22, 0, 0, 0).unwrap();
1407        let module_id = Uuid::parse_str("9e831ecc-9751-42f1-ae7e-9b2f06e523e8").unwrap();
1408        let course_modules = vec![
1409            CourseModule::new(
1410                module_id,
1411                Uuid::parse_str("3fa4bee6-7390-415e-968f-ecdc5f28330e").unwrap(),
1412            )
1413            .set_timestamps(timestamp, timestamp, None)
1414            .set_registration_info(None, Some(5.0), None, false),
1415        ];
1416        let course_metrics_by_course_module_id = HashMap::from([(
1417            module_id,
1418            CourseExerciseMetrics {
1419                course_module_id: module_id,
1420                total_exercises: Some(4),
1421                score_maximum: Some(10),
1422            },
1423        )]);
1424        let user_metrics_by_course_module_id = HashMap::from([(
1425            module_id,
1426            UserCourseMetrics {
1427                course_module_id: module_id,
1428                score_given: Some(1.0),
1429                attempted_exercises: Some(3),
1430            },
1431        )]);
1432        let metrics = merge_modules_with_metrics(
1433            course_modules,
1434            &course_metrics_by_course_module_id,
1435            &user_metrics_by_course_module_id,
1436            "Default module",
1437        )
1438        .unwrap();
1439        assert_eq!(metrics.len(), 1);
1440        let metric = metrics.first().unwrap();
1441        assert_eq!(metric.attempted_exercises, Some(3));
1442        assert_eq!(&metric.course_module_name, "Default module");
1443        assert_eq!(metric.score_given, 1.0);
1444        assert_eq!(metric.score_maximum, Some(10));
1445        assert_eq!(metric.total_exercises, Some(4));
1446    }
1447
1448    #[tokio::test]
1449    async fn get_user_course_progress_open_closed_chapters() {
1450        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page);
1451        // creating a new course inserts one empty default module.
1452        // there will be one empty module and one module with two chapters
1453        // one of which is open
1454        let (new_chapter, _) = create_new_chapter(
1455            tx.as_mut(),
1456            PKeyPolicy::Generate,
1457            &NewChapter {
1458                name: "best chapter 1".to_string(),
1459                color: None,
1460                course_id: course,
1461                chapter_number: 2,
1462                front_page_id: None,
1463                deadline: None,
1464                opens_at: Some(
1465                    DateTime::parse_from_str(
1466                        // chapter is not open yet
1467                        "2983 Apr 13 12:09:14 +0000",
1468                        "%Y %b %d %H:%M:%S %z",
1469                    )
1470                    .unwrap()
1471                    .to_utc(),
1472                ),
1473                course_module_id: Some(course_module.id),
1474            },
1475            user,
1476            |_, _, _| unimplemented!(),
1477            |_| unimplemented!(),
1478        )
1479        .await
1480        .unwrap();
1481
1482        // insert a page with an exercise to the not-open chapter
1483        let page = insert_page(
1484            tx.as_mut(),
1485            NewPage {
1486                exercises: vec![],
1487                exercise_slides: vec![],
1488                exercise_tasks: vec![],
1489                content: vec![],
1490                url_path: "/page1".to_string(),
1491                title: "title".to_string(),
1492                course_id: Some(course),
1493                exam_id: None,
1494                chapter_id: Some(new_chapter.id),
1495                front_page_of_chapter_id: Some(new_chapter.id),
1496                content_search_language: None,
1497                hidden: false,
1498            },
1499            user,
1500            |_, _, _| unimplemented!(),
1501            |_| unimplemented!(),
1502        )
1503        .await
1504        .unwrap();
1505        let ex = exercises::insert(
1506            tx.as_mut(),
1507            PKeyPolicy::Generate,
1508            course,
1509            "ex 1",
1510            page.id,
1511            new_chapter.id,
1512            1,
1513        )
1514        .await
1515        .unwrap();
1516        exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1517            .await
1518            .unwrap();
1519        // another chapter
1520        let (new_chapter2, _) = create_new_chapter(
1521            tx.as_mut(),
1522            PKeyPolicy::Generate,
1523            &NewChapter {
1524                name: "best chapter 2".to_string(),
1525                color: None,
1526                course_id: course,
1527                chapter_number: 3,
1528                front_page_id: None,
1529                deadline: None,
1530                opens_at: Some(
1531                    DateTime::parse_from_str(
1532                        // chapter is open yet
1533                        "1983 Apr 13 12:09:14 +0000",
1534                        "%Y %b %d %H:%M:%S %z",
1535                    )
1536                    .unwrap()
1537                    .to_utc(),
1538                ),
1539                course_module_id: Some(course_module.id),
1540            },
1541            user,
1542            |_, _, _| unimplemented!(),
1543            |_| unimplemented!(),
1544        )
1545        .await
1546        .unwrap();
1547
1548        // insert a page with an exercise to the not-open chapter
1549        let page2 = insert_page(
1550            tx.as_mut(),
1551            NewPage {
1552                exercises: vec![],
1553                exercise_slides: vec![],
1554                exercise_tasks: vec![],
1555                content: vec![],
1556                url_path: "/page2".to_string(),
1557                title: "title".to_string(),
1558                course_id: Some(course),
1559                exam_id: None,
1560                chapter_id: Some(new_chapter2.id),
1561                front_page_of_chapter_id: Some(new_chapter2.id),
1562                content_search_language: None,
1563                hidden: false,
1564            },
1565            user,
1566            |_, _, _| unimplemented!(),
1567            |_| unimplemented!(),
1568        )
1569        .await
1570        .unwrap();
1571        let ex = exercises::insert(
1572            tx.as_mut(),
1573            PKeyPolicy::Generate,
1574            course,
1575            "ex 1",
1576            page2.id,
1577            new_chapter2.id,
1578            1,
1579        )
1580        .await
1581        .unwrap();
1582        exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1583            .await
1584            .unwrap();
1585
1586        // should list all modules and exercises
1587        let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1588            .await
1589            .unwrap();
1590        // should only list modules with chapters and the exercises from open chapters
1591        let progress_open_chapters = get_user_course_progress(tx.as_mut(), course, user, true)
1592            .await
1593            .unwrap();
1594
1595        assert_ne!(progress_all, progress_open_chapters);
1596        assert_eq!(progress_all.len(), 2);
1597        assert_eq!(progress_open_chapters.len(), 1);
1598        assert_eq!(
1599            progress_all[1].course_module_id,
1600            progress_open_chapters[0].course_module_id
1601        );
1602        assert_eq!(progress_all[1].total_exercises, Some(2));
1603        assert_eq!(progress_open_chapters[0].total_exercises, Some(1));
1604    }
1605
1606    #[tokio::test]
1607    async fn get_user_course_progress_filter_out_closed_module() {
1608        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1609        // creating a new course inserts one empty default module.
1610        // there will be one other module with one closed chapter only
1611        let (new_chapter, _) = create_new_chapter(
1612            tx.as_mut(),
1613            PKeyPolicy::Generate,
1614            &NewChapter {
1615                name: "best chapter".to_string(),
1616                color: None,
1617                course_id: course,
1618                chapter_number: 2,
1619                front_page_id: None,
1620                deadline: None,
1621                opens_at: Some(
1622                    DateTime::parse_from_str(
1623                        // chapter is not open yet
1624                        "2983 Apr 13 12:09:14 +0000",
1625                        "%Y %b %d %H:%M:%S %z",
1626                    )
1627                    .unwrap()
1628                    .to_utc(),
1629                ),
1630                course_module_id: Some(course_module.id),
1631            },
1632            user,
1633            |_, _, _| unimplemented!(),
1634            |_| unimplemented!(),
1635        )
1636        .await
1637        .unwrap();
1638
1639        // insert a page with an exercise to the not-open chapter
1640        let page = insert_page(
1641            tx.as_mut(),
1642            NewPage {
1643                exercises: vec![],
1644                exercise_slides: vec![],
1645                exercise_tasks: vec![],
1646                content: vec![],
1647                url_path: "/page2".to_string(),
1648                title: "title".to_string(),
1649                course_id: Some(course),
1650                exam_id: None,
1651                chapter_id: Some(new_chapter.id),
1652                front_page_of_chapter_id: Some(new_chapter.id),
1653                content_search_language: None,
1654                hidden: false,
1655            },
1656            user,
1657            |_, _, _| unimplemented!(),
1658            |_| unimplemented!(),
1659        )
1660        .await
1661        .unwrap();
1662        let ex = exercises::insert(
1663            tx.as_mut(),
1664            PKeyPolicy::Generate,
1665            course,
1666            "ex 1",
1667            page.id,
1668            new_chapter.id,
1669            1,
1670        )
1671        .await
1672        .unwrap();
1673        exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1674            .await
1675            .unwrap();
1676
1677        // should list one empty module and one module with only one chapter
1678        // which is closed
1679        let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1680            .await
1681            .unwrap();
1682        // should be empty
1683        let progress_open_chapters_modules =
1684            get_user_course_progress(tx.as_mut(), course, user, true)
1685                .await
1686                .unwrap();
1687
1688        assert_ne!(progress_all, progress_open_chapters_modules);
1689        assert_eq!(progress_all.len(), 2);
1690        assert_eq!(progress_open_chapters_modules.len(), 0);
1691        assert_eq!(progress_all[1].total_exercises, Some(1));
1692    }
1693
1694    #[tokio::test]
1695    async fn has_pending_manual_reviews_in_chapter_reflects_review_state() {
1696        insert_data!(
1697            :tx,
1698            :user,
1699            :org,
1700            :course,
1701            instance: _instance,
1702            :course_module,
1703            chapter: chapter_id,
1704            page: _page_id,
1705            exercise: exercise_id,
1706            slide: _exercise_slide_id,
1707            task: _exercise_task_id
1708        );
1709
1710        exercises::update_teacher_reviews_answer_after_locking(tx.as_mut(), exercise_id, true)
1711            .await
1712            .unwrap();
1713        get_or_create_user_exercise_state(tx.as_mut(), user, exercise_id, Some(course), None)
1714            .await
1715            .unwrap();
1716
1717        update_reviewing_stage(
1718            tx.as_mut(),
1719            user,
1720            CourseOrExamId::Course(course),
1721            exercise_id,
1722            ReviewingStage::WaitingForManualGrading,
1723        )
1724        .await
1725        .unwrap();
1726
1727        let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1728            .await
1729            .unwrap();
1730        assert!(has_pending);
1731
1732        update_reviewing_stage(
1733            tx.as_mut(),
1734            user,
1735            CourseOrExamId::Course(course),
1736            exercise_id,
1737            ReviewingStage::ReviewedAndLocked,
1738        )
1739        .await
1740        .unwrap();
1741
1742        let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1743            .await
1744            .unwrap();
1745        assert!(!has_pending);
1746    }
1747
1748    #[tokio::test]
1749    async fn has_pending_manual_reviews_in_chapter_counts_self_review_manual_flows() {
1750        insert_data!(
1751            :tx,
1752            :user,
1753            :org,
1754            :course,
1755            instance: _instance,
1756            :course_module,
1757            chapter: chapter_id,
1758            page: _page_id,
1759            exercise: exercise_id,
1760            slide: _exercise_slide_id,
1761            task: _exercise_task_id
1762        );
1763
1764        exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
1765            tx.as_mut(),
1766            exercise_id,
1767            false,
1768            true,
1769            false,
1770        )
1771        .await
1772        .unwrap();
1773        get_or_create_user_exercise_state(tx.as_mut(), user, exercise_id, Some(course), None)
1774            .await
1775            .unwrap();
1776
1777        update_reviewing_stage(
1778            tx.as_mut(),
1779            user,
1780            CourseOrExamId::Course(course),
1781            exercise_id,
1782            ReviewingStage::WaitingForManualGrading,
1783        )
1784        .await
1785        .unwrap();
1786
1787        let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1788            .await
1789            .unwrap();
1790        assert!(has_pending);
1791    }
1792}