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