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