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