Skip to main content

headless_lms_models/
user_exercise_states.rs

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