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 id,
508  user_id,
509  exercise_id,
510  course_id,
511  exam_id,
512  created_at,
513  updated_at,
514  deleted_at,
515  score_given,
516  grading_progress AS "grading_progress: _",
517  activity_progress AS "activity_progress: _",
518  reviewing_stage AS "reviewing_stage: _",
519  selected_exercise_slide_id
520FROM user_exercise_states
521WHERE user_id = $1
522  AND exercise_id = $2
523  AND (course_id = $3 OR exam_id = $4)
524  AND deleted_at IS NULL
525"#,
526        user_id,
527        exercise_id,
528        course_id,
529        exam_id
530    )
531    .fetch_optional(&mut *conn)
532    .await?;
533
534    let res = if let Some(existing) = existing {
535        existing
536    } else {
537        sqlx::query_as!(
538            UserExerciseState,
539            r#"
540    INSERT INTO user_exercise_states (user_id, exercise_id, course_id, exam_id)
541    VALUES ($1, $2, $3, $4)
542    RETURNING id,
543      user_id,
544      exercise_id,
545      course_id,
546      exam_id,
547      created_at,
548      updated_at,
549      deleted_at,
550      score_given,
551      grading_progress as "grading_progress: _",
552      activity_progress as "activity_progress: _",
553      reviewing_stage AS "reviewing_stage: _",
554      selected_exercise_slide_id
555      "#,
556            user_id,
557            exercise_id,
558            course_id,
559            exam_id
560        )
561        .fetch_one(&mut *conn)
562        .await?
563    };
564    Ok(res)
565}
566
567pub async fn get_or_create_user_exercise_state_for_users(
568    conn: &mut PgConnection,
569    user_ids: &[Uuid],
570    exercise_id: Uuid,
571    course_id: Option<Uuid>,
572    exam_id: Option<Uuid>,
573) -> ModelResult<HashMap<Uuid, UserExerciseState>> {
574    let existing = sqlx::query_as!(
575        UserExerciseState,
576        r#"
577SELECT id,
578  user_id,
579  exercise_id,
580  course_id,
581  exam_id,
582  created_at,
583  updated_at,
584  deleted_at,
585  score_given,
586  grading_progress AS "grading_progress: _",
587  activity_progress AS "activity_progress: _",
588  reviewing_stage AS "reviewing_stage: _",
589  selected_exercise_slide_id
590FROM user_exercise_states
591WHERE user_id IN (
592    SELECT UNNEST($1::uuid [])
593  )
594  AND exercise_id = $2
595  AND (course_id = $3 OR exam_id = $4)
596  AND deleted_at IS NULL
597"#,
598        user_ids,
599        exercise_id,
600        course_id,
601        exam_id
602    )
603    .fetch_all(&mut *conn)
604    .await?;
605
606    let mut res = HashMap::with_capacity(user_ids.len());
607    for item in existing.into_iter() {
608        res.insert(item.user_id, item);
609    }
610
611    let missing_user_ids = user_ids
612        .iter()
613        .filter(|user_id| !res.contains_key(user_id))
614        .copied()
615        .collect::<Vec<_>>();
616
617    let created = sqlx::query_as!(
618        UserExerciseState,
619        r#"
620    INSERT INTO user_exercise_states (user_id, exercise_id, course_id, exam_id)
621    SELECT UNNEST($1::uuid []), $2, $3, $4
622    RETURNING id,
623      user_id,
624      exercise_id,
625      course_id,
626      exam_id,
627      created_at,
628      updated_at,
629      deleted_at,
630      score_given,
631      grading_progress as "grading_progress: _",
632      activity_progress as "activity_progress: _",
633      reviewing_stage AS "reviewing_stage: _",
634      selected_exercise_slide_id
635      "#,
636        &missing_user_ids,
637        exercise_id,
638        course_id,
639        exam_id
640    )
641    .fetch_all(&mut *conn)
642    .await?;
643
644    for item in created.into_iter() {
645        res.insert(item.user_id, item);
646    }
647    Ok(res)
648}
649
650pub async fn get_by_user_ids_and_exercise_id(
651    conn: &mut PgConnection,
652    user_ids: &[Uuid],
653    exercise_id: Uuid,
654) -> ModelResult<Vec<UserExerciseState>> {
655    let res = sqlx::query_as!(
656        UserExerciseState,
657        r#"
658SELECT id,
659  user_id,
660  exercise_id,
661  course_id,
662  exam_id,
663  created_at,
664  updated_at,
665  deleted_at,
666  score_given,
667  grading_progress AS "grading_progress: _",
668  activity_progress AS "activity_progress: _",
669  reviewing_stage AS "reviewing_stage: _",
670  selected_exercise_slide_id
671FROM user_exercise_states
672WHERE user_id = ANY($1)
673  AND exercise_id = $2
674  AND deleted_at IS NULL
675        "#,
676        user_ids,
677        exercise_id
678    )
679    .fetch_all(conn)
680    .await?;
681    Ok(res)
682}
683
684pub async fn get_by_course_id_and_user_ids_and_exercise_ids(
685    conn: &mut PgConnection,
686    course_id: Uuid,
687    user_ids: &[Uuid],
688    exercise_ids: &[Uuid],
689) -> ModelResult<Vec<UserExerciseState>> {
690    let res = sqlx::query_as!(
691        UserExerciseState,
692        r#"
693SELECT ues.id,
694  ues.user_id,
695  ues.exercise_id,
696  ues.course_id,
697  ues.exam_id,
698  ues.created_at,
699  ues.updated_at,
700  ues.deleted_at,
701  ues.score_given,
702  ues.grading_progress AS "grading_progress: _",
703  ues.activity_progress AS "activity_progress: _",
704  ues.reviewing_stage AS "reviewing_stage: _",
705  ues.selected_exercise_slide_id
706FROM user_exercise_states ues
707  JOIN exercises e ON e.id = ues.exercise_id
708WHERE ues.course_id = $1
709  AND e.course_id = $1
710  AND ues.user_id = ANY($2)
711  AND ues.exercise_id = ANY($3)
712  AND ues.deleted_at IS NULL
713  AND e.deleted_at IS NULL
714        "#,
715        course_id,
716        user_ids,
717        exercise_ids
718    )
719    .fetch_all(conn)
720    .await?;
721    Ok(res)
722}
723
724pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<UserExerciseState> {
725    let res = sqlx::query_as!(
726        UserExerciseState,
727        r#"
728SELECT id,
729  user_id,
730  exercise_id,
731  course_id,
732  exam_id,
733  created_at,
734  updated_at,
735  deleted_at,
736  score_given,
737  grading_progress AS "grading_progress: _",
738  activity_progress AS "activity_progress: _",
739  reviewing_stage AS "reviewing_stage: _",
740  selected_exercise_slide_id
741FROM user_exercise_states
742WHERE id = $1
743  AND deleted_at IS NULL
744        "#,
745        id,
746    )
747    .fetch_one(conn)
748    .await?;
749    Ok(res)
750}
751
752pub async fn recalculate_by_id_and_exercise_id(
753    conn: &mut PgConnection,
754    state_id: Uuid,
755    exercise_id: Uuid,
756) -> ModelResult<UserExerciseState> {
757    sqlx::query!(
758        r#"
759SELECT id
760FROM user_exercise_states
761WHERE id = $1
762  AND exercise_id = $2
763  AND deleted_at IS NULL
764        "#,
765        state_id,
766        exercise_id
767    )
768    .fetch_one(&mut *conn)
769    .await?;
770
771    crate::library::user_exercise_state_updater::update_user_exercise_state(conn, state_id).await
772}
773
774pub async fn get_by_ids(
775    conn: &mut PgConnection,
776    ids: &[Uuid],
777) -> ModelResult<Vec<UserExerciseState>> {
778    let res = sqlx::query_as!(
779        UserExerciseState,
780        r#"
781SELECT id,
782  user_id,
783  exercise_id,
784  course_id,
785  exam_id,
786  created_at,
787  updated_at,
788  deleted_at,
789  score_given,
790  grading_progress AS "grading_progress: _",
791  activity_progress AS "activity_progress: _",
792  reviewing_stage AS "reviewing_stage: _",
793  selected_exercise_slide_id
794FROM user_exercise_states
795WHERE id = ANY($1)
796AND deleted_at IS NULL
797"#,
798        &ids
799    )
800    .fetch_all(conn)
801    .await?;
802    Ok(res)
803}
804
805pub async fn get_user_total_course_points(
806    conn: &mut PgConnection,
807    user_id: Uuid,
808    course_id: Uuid,
809) -> ModelResult<Option<f32>> {
810    let res = sqlx::query!(
811        r#"
812SELECT SUM(score_given) AS "total_points"
813FROM user_exercise_states
814WHERE user_id = $1
815  AND course_id = $2
816  AND deleted_at IS NULL
817  GROUP BY user_id
818        "#,
819        user_id,
820        course_id,
821    )
822    .map(|x| x.total_points)
823    .fetch_one(conn)
824    .await?;
825    Ok(res)
826}
827
828pub async fn get_users_current_by_exercise(
829    conn: &mut PgConnection,
830    user_id: Uuid,
831    exercise: &Exercise,
832) -> ModelResult<UserExerciseState> {
833    let course_or_exam_id =
834        CourseOrExamId::from_course_and_exam_ids(exercise.course_id, exercise.exam_id)?;
835
836    let user_exercise_state =
837        get_user_exercise_state_if_exists(conn, user_id, exercise.id, course_or_exam_id)
838            .await?
839            .ok_or_else(|| {
840                ModelError::new(
841                    ModelErrorType::PreconditionFailed,
842                    "Missing user exercise state.".to_string(),
843                    None,
844                )
845            })?;
846    Ok(user_exercise_state)
847}
848
849pub async fn get_user_exercise_state_if_exists(
850    conn: &mut PgConnection,
851    user_id: Uuid,
852    exercise_id: Uuid,
853    course_or_exam_id: CourseOrExamId,
854) -> ModelResult<Option<UserExerciseState>> {
855    let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
856    let res = sqlx::query_as!(
857        UserExerciseState,
858        r#"
859SELECT id,
860  user_id,
861  exercise_id,
862  course_id,
863  exam_id,
864  created_at,
865  updated_at,
866  deleted_at,
867  score_given,
868  grading_progress AS "grading_progress: _",
869  activity_progress AS "activity_progress: _",
870  reviewing_stage AS "reviewing_stage: _",
871  selected_exercise_slide_id
872FROM user_exercise_states
873WHERE user_id = $1
874  AND exercise_id = $2
875  AND (course_id = $3 OR exam_id = $4)
876  AND deleted_at IS NULL
877      "#,
878        user_id,
879        exercise_id,
880        course_id,
881        exam_id
882    )
883    .fetch_optional(conn)
884    .await?;
885    Ok(res)
886}
887
888/// Returns true when user has chapter exercises pending teacher review.
889pub async fn has_pending_manual_reviews_in_chapter(
890    conn: &mut PgConnection,
891    user_id: Uuid,
892    chapter_id: Uuid,
893) -> ModelResult<bool> {
894    struct PendingManualReviewsInChapterRow {
895        exists: bool,
896    }
897
898    let pending_manual_reviews = sqlx::query_as!(
899        PendingManualReviewsInChapterRow,
900        r#"
901SELECT EXISTS (
902    SELECT 1
903    FROM user_exercise_states ues
904    JOIN exercises e ON e.id = ues.exercise_id
905    WHERE ues.user_id = $1
906      AND e.chapter_id = $2
907      AND ues.reviewing_stage = 'waiting_for_manual_grading'::reviewing_stage
908      AND ues.deleted_at IS NULL
909      AND e.deleted_at IS NULL
910 ) as "exists!"
911        "#,
912        user_id,
913        chapter_id
914    )
915    .fetch_one(conn)
916    .await?
917    .exists;
918    Ok(pending_manual_reviews)
919}
920
921pub async fn get_all_for_user_and_course_or_exam(
922    conn: &mut PgConnection,
923    user_id: Uuid,
924    course_or_exam_id: CourseOrExamId,
925) -> ModelResult<Vec<UserExerciseState>> {
926    let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
927    let res = sqlx::query_as!(
928        UserExerciseState,
929        r#"
930SELECT id,
931  user_id,
932  exercise_id,
933  course_id,
934  exam_id,
935  created_at,
936  updated_at,
937  deleted_at,
938  score_given,
939  grading_progress AS "grading_progress: _",
940  activity_progress AS "activity_progress: _",
941  reviewing_stage AS "reviewing_stage: _",
942  selected_exercise_slide_id
943FROM user_exercise_states
944WHERE user_id = $1
945  AND (course_id = $2 OR exam_id = $3)
946  AND deleted_at IS NULL
947      "#,
948        user_id,
949        course_id,
950        exam_id
951    )
952    .fetch_all(conn)
953    .await?;
954    Ok(res)
955}
956
957pub async fn upsert_selected_exercise_slide_id(
958    conn: &mut PgConnection,
959    user_id: Uuid,
960    exercise_id: Uuid,
961    course_id: Option<Uuid>,
962    exam_id: Option<Uuid>,
963    selected_exercise_slide_id: Option<Uuid>,
964) -> ModelResult<()> {
965    let existing = sqlx::query!(
966        "
967SELECT
968FROM user_exercise_states
969WHERE user_id = $1
970  AND exercise_id = $2
971  AND (course_id = $3 OR exam_id = $4)
972  AND deleted_at IS NULL
973",
974        user_id,
975        exercise_id,
976        course_id,
977        exam_id
978    )
979    .fetch_optional(&mut *conn)
980    .await?;
981    if existing.is_some() {
982        sqlx::query!(
983            "
984UPDATE user_exercise_states
985SET selected_exercise_slide_id = $4
986WHERE user_id = $1
987  AND exercise_id = $2
988  AND (course_id = $3 OR exam_id = $5)
989  AND deleted_at IS NULL
990    ",
991            user_id,
992            exercise_id,
993            course_id,
994            selected_exercise_slide_id,
995            exam_id
996        )
997        .execute(&mut *conn)
998        .await?;
999    } else {
1000        sqlx::query!(
1001            "
1002    INSERT INTO user_exercise_states (
1003        user_id,
1004        exercise_id,
1005        course_id,
1006        selected_exercise_slide_id,
1007        exam_id
1008      )
1009    VALUES ($1, $2, $3, $4, $5)
1010    ",
1011            user_id,
1012            exercise_id,
1013            course_id,
1014            selected_exercise_slide_id,
1015            exam_id
1016        )
1017        .execute(&mut *conn)
1018        .await?;
1019    }
1020    Ok(())
1021}
1022
1023/// TODO: should be moved to the user_exercise_state_updater as a private module so that this cannot be called outside of that module
1024pub async fn update(
1025    conn: &mut PgConnection,
1026    user_exercise_state_update: UserExerciseStateUpdate,
1027) -> ModelResult<UserExerciseState> {
1028    let res = sqlx::query_as!(
1029        UserExerciseState,
1030        r#"
1031UPDATE user_exercise_states
1032SET score_given = $1,
1033  activity_progress = $2,
1034  reviewing_stage = $3,
1035  grading_progress = $4
1036WHERE id = $5
1037  AND deleted_at IS NULL
1038RETURNING id,
1039  user_id,
1040  exercise_id,
1041  course_id,
1042  exam_id,
1043  created_at,
1044  updated_at,
1045  deleted_at,
1046  score_given,
1047  grading_progress AS "grading_progress: _",
1048  activity_progress AS "activity_progress: _",
1049  reviewing_stage AS "reviewing_stage: _",
1050  selected_exercise_slide_id
1051        "#,
1052        user_exercise_state_update.score_given,
1053        user_exercise_state_update.activity_progress as ActivityProgress,
1054        user_exercise_state_update.reviewing_stage as ReviewingStage,
1055        user_exercise_state_update.grading_progress as GradingProgress,
1056        user_exercise_state_update.id,
1057    )
1058    .fetch_one(conn)
1059    .await?;
1060    Ok(res)
1061}
1062
1063pub async fn update_reviewing_stage(
1064    conn: &mut PgConnection,
1065    user_id: Uuid,
1066    course_or_exam_id: CourseOrExamId,
1067    exercise_id: Uuid,
1068    new_reviewing_stage: ReviewingStage,
1069) -> ModelResult<UserExerciseState> {
1070    let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
1071    let res = sqlx::query_as!(
1072        UserExerciseState,
1073        r#"
1074UPDATE user_exercise_states
1075SET reviewing_stage = $5
1076WHERE user_id = $1
1077AND (course_id = $2 OR exam_id = $3)
1078AND exercise_id = $4
1079RETURNING id,
1080  user_id,
1081  exercise_id,
1082  course_id,
1083  exam_id,
1084  created_at,
1085  updated_at,
1086  deleted_at,
1087  score_given,
1088  grading_progress AS "grading_progress: _",
1089  activity_progress AS "activity_progress: _",
1090  reviewing_stage AS "reviewing_stage: _",
1091  selected_exercise_slide_id
1092        "#,
1093        user_id,
1094        course_id,
1095        exam_id,
1096        exercise_id,
1097        new_reviewing_stage as ReviewingStage
1098    )
1099    .fetch_one(conn)
1100    .await?;
1101    Ok(res)
1102}
1103
1104/// TODO: should be removed
1105pub async fn update_exercise_progress(
1106    conn: &mut PgConnection,
1107    id: Uuid,
1108    reviewing_stage: ReviewingStage,
1109) -> ModelResult<UserExerciseState> {
1110    let res = sqlx::query_as!(
1111        UserExerciseState,
1112        r#"
1113UPDATE user_exercise_states
1114SET reviewing_stage = $1
1115WHERE id = $2
1116  AND deleted_at IS NULL
1117RETURNING id,
1118  user_id,
1119  exercise_id,
1120  course_id,
1121  exam_id,
1122  created_at,
1123  updated_at,
1124  deleted_at,
1125  score_given,
1126  grading_progress AS "grading_progress: _",
1127  activity_progress AS "activity_progress: _",
1128  reviewing_stage AS "reviewing_stage: _",
1129  selected_exercise_slide_id
1130        "#,
1131        reviewing_stage as ReviewingStage,
1132        id
1133    )
1134    .fetch_one(conn)
1135    .await?;
1136    Ok(res)
1137}
1138
1139/// Convenience struct that combines user state to the exercise.
1140///
1141/// Many operations require information about both the user state and the exercise. However, because
1142/// exercises can either belong to a course or an exam it can get difficult to track the proper context.
1143pub struct ExerciseWithUserState {
1144    exercise: Exercise,
1145    user_exercise_state: UserExerciseState,
1146    type_data: EwusCourseOrExam,
1147}
1148
1149impl ExerciseWithUserState {
1150    pub fn new(exercise: Exercise, user_exercise_state: UserExerciseState) -> ModelResult<Self> {
1151        let state = EwusCourseOrExam::from_exercise_and_user_exercise_state(
1152            &exercise,
1153            &user_exercise_state,
1154        )?;
1155        Ok(Self {
1156            exercise,
1157            user_exercise_state,
1158            type_data: state,
1159        })
1160    }
1161
1162    /// Provides a reference to the inner `Exercise`.
1163    pub fn exercise(&self) -> &Exercise {
1164        &self.exercise
1165    }
1166
1167    /// Provides a reference to the inner `UserExerciseState`.
1168    pub fn user_exercise_state(&self) -> &UserExerciseState {
1169        &self.user_exercise_state
1170    }
1171
1172    pub fn exercise_context(&self) -> &EwusCourseOrExam {
1173        &self.type_data
1174    }
1175
1176    pub fn set_user_exercise_state(
1177        &mut self,
1178        user_exercise_state: UserExerciseState,
1179    ) -> ModelResult<()> {
1180        self.type_data = EwusCourseOrExam::from_exercise_and_user_exercise_state(
1181            &self.exercise,
1182            &user_exercise_state,
1183        )?;
1184        self.user_exercise_state = user_exercise_state;
1185        Ok(())
1186    }
1187
1188    pub fn is_exam_exercise(&self) -> bool {
1189        match self.type_data {
1190            EwusCourseOrExam::Course(_) => false,
1191            EwusCourseOrExam::Exam(_) => true,
1192        }
1193    }
1194}
1195
1196pub struct EwusCourse {
1197    pub course_id: Uuid,
1198}
1199
1200pub struct EwusExam {
1201    pub exam_id: Uuid,
1202}
1203
1204pub enum EwusContext<C, E> {
1205    Course(C),
1206    Exam(E),
1207}
1208
1209pub enum EwusCourseOrExam {
1210    Course(EwusCourse),
1211    Exam(EwusExam),
1212}
1213
1214impl EwusCourseOrExam {
1215    pub fn from_exercise_and_user_exercise_state(
1216        exercise: &Exercise,
1217        user_exercise_state: &UserExerciseState,
1218    ) -> ModelResult<Self> {
1219        if exercise.id == user_exercise_state.exercise_id {
1220            let course_id = exercise.course_id;
1221            let exam_id = exercise.exam_id;
1222            match (course_id, exam_id) {
1223                (None, Some(exam_id)) => Ok(Self::Exam(EwusExam { exam_id })),
1224                (Some(course_id), None) => Ok(Self::Course(EwusCourse { course_id })),
1225                _ => Err(ModelError::new(
1226                    ModelErrorType::Generic,
1227                    "Invalid initializer data.".to_string(),
1228                    None,
1229                )),
1230            }
1231        } else {
1232            Err(ModelError::new(
1233                ModelErrorType::Generic,
1234                "Exercise doesn't match the state.".to_string(),
1235                None,
1236            ))
1237        }
1238    }
1239}
1240
1241#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1242pub struct CourseUserPoints {
1243    pub user_id: Uuid,
1244    pub points_for_each_chapter: Vec<CourseUserPointsInner>,
1245}
1246
1247#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1248pub struct CourseUserPointsInner {
1249    pub chapter_number: i32,
1250    pub points_for_chapter: f32,
1251}
1252
1253#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1254pub struct ExamUserPoints {
1255    pub user_id: Uuid,
1256    pub email: String,
1257    pub points_for_exercise: Vec<ExamUserPointsInner>,
1258}
1259
1260#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1261pub struct ExamUserPointsInner {
1262    pub exercise_id: Uuid,
1263    pub score_given: f32,
1264}
1265
1266pub fn stream_course_points(
1267    conn: &mut PgConnection,
1268    course_id: Uuid,
1269) -> impl Stream<Item = sqlx::Result<CourseUserPoints>> + '_ {
1270    sqlx::query!(
1271        "
1272SELECT user_id,
1273  to_jsonb(array_agg(to_jsonb(uue) - 'email' - 'user_id')) AS points_for_each_chapter
1274FROM (
1275    SELECT ud.email,
1276      u.id AS user_id,
1277      c.chapter_number,
1278      COALESCE(SUM(ues.score_given), 0) AS points_for_chapter
1279    FROM user_exercise_states ues
1280      JOIN users u ON u.id = ues.user_id
1281      JOIN user_details ud ON ud.user_id = u.id
1282      JOIN exercises e ON e.id = ues.exercise_id
1283      JOIN chapters c on e.chapter_id = c.id
1284    WHERE ues.course_id = $1
1285      AND ues.deleted_at IS NULL
1286      AND c.deleted_at IS NULL
1287      AND u.deleted_at IS NULL
1288      AND e.deleted_at IS NULL
1289    GROUP BY ud.email,
1290      u.id,
1291      c.chapter_number
1292  ) as uue
1293GROUP BY user_id
1294
1295",
1296        course_id
1297    )
1298    .try_map(|i| {
1299        let user_id = i.user_id;
1300        let points_for_each_chapter = i.points_for_each_chapter.unwrap_or(Value::Null);
1301        serde_json::from_value(points_for_each_chapter)
1302            .map(|points_for_each_chapter| CourseUserPoints {
1303                user_id,
1304                points_for_each_chapter,
1305            })
1306            .map_err(|e| sqlx::Error::Decode(Box::new(e)))
1307    })
1308    .fetch(conn)
1309}
1310
1311pub fn stream_exam_points(
1312    conn: &mut PgConnection,
1313    exam_id: Uuid,
1314) -> impl Stream<Item = sqlx::Result<ExamUserPoints>> + '_ {
1315    sqlx::query!(
1316        "
1317SELECT user_id,
1318  email,
1319  to_jsonb(array_agg(to_jsonb(uue) - 'email' - 'user_id')) AS points_for_exercises
1320FROM (
1321    SELECT u.id AS user_id,
1322      ud.email,
1323      exercise_id,
1324      COALESCE(score_given, 0) as score_given
1325    FROM user_exercise_states ues
1326      JOIN users u ON u.id = ues.user_id
1327      JOIN user_details ud ON ud.user_id = u.id
1328      JOIN exercises e ON e.id = ues.exercise_id
1329    WHERE ues.exam_id = $1
1330      AND ues.deleted_at IS NULL
1331      AND u.deleted_at IS NULL
1332      AND e.deleted_at IS NULL
1333  ) as uue
1334GROUP BY user_id,
1335  email
1336",
1337        exam_id
1338    )
1339    .try_map(|i| {
1340        let user_id = i.user_id;
1341        let points_for_exercises = i.points_for_exercises.unwrap_or(Value::Null);
1342        serde_json::from_value(points_for_exercises)
1343            .map(|points_for_exercise| ExamUserPoints {
1344                user_id,
1345                points_for_exercise,
1346                email: i.email,
1347            })
1348            .map_err(|e| sqlx::Error::Decode(Box::new(e)))
1349    })
1350    .fetch(conn)
1351}
1352
1353pub async fn get_course_users_counts_by_exercise(
1354    conn: &mut PgConnection,
1355    course_id: Uuid,
1356) -> ModelResult<Vec<ExerciseUserCounts>> {
1357    let res = sqlx::query_as!(
1358        ExerciseUserCounts,
1359        r#"
1360SELECT exercises.name as exercise_name,
1361  exercises.order_number as exercise_order_number,
1362  pages.order_number as page_order_number,
1363  chapters.chapter_number,
1364  stat_data.*
1365FROM (
1366    SELECT exercise_id,
1367      COUNT(DISTINCT user_id) FILTER (
1368        WHERE ues.activity_progress = 'completed'
1369      ) as n_users_attempted,
1370      COUNT(DISTINCT user_id) FILTER (
1371        WHERE ues.score_given IS NOT NULL
1372          and ues.score_given > 0
1373          AND ues.activity_progress = 'completed'
1374      ) as n_users_with_some_points,
1375      COUNT(DISTINCT user_id) FILTER (
1376        WHERE ues.score_given IS NOT NULL
1377          and ues.score_given >= exercises.score_maximum
1378          and ues.activity_progress = 'completed'
1379      ) as n_users_with_max_points
1380    FROM exercises
1381      JOIN user_exercise_states ues on exercises.id = ues.exercise_id
1382    WHERE exercises.course_id = $1
1383      AND exercises.deleted_at IS NULL
1384      AND ues.deleted_at IS NULL
1385    GROUP BY exercise_id
1386  ) as stat_data
1387  JOIN exercises ON stat_data.exercise_id = exercises.id
1388  JOIN pages on exercises.page_id = pages.id
1389  JOIN chapters on pages.chapter_id = chapters.id
1390WHERE exercises.deleted_at IS NULL
1391  AND pages.deleted_at IS NULL
1392  AND chapters.deleted_at IS NULL
1393          "#,
1394        course_id
1395    )
1396    .fetch_all(conn)
1397    .await?;
1398    Ok(res)
1399}
1400
1401#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1402
1403pub struct ExportedUserExerciseState {
1404    pub id: Uuid,
1405    pub user_id: Uuid,
1406    pub exercise_id: Uuid,
1407    pub course_id: Option<Uuid>,
1408    pub created_at: DateTime<Utc>,
1409    pub updated_at: DateTime<Utc>,
1410    pub score_given: Option<f32>,
1411    pub grading_progress: GradingProgress,
1412    pub activity_progress: ActivityProgress,
1413    pub reviewing_stage: ReviewingStage,
1414    pub selected_exercise_slide_id: Option<Uuid>,
1415}
1416
1417pub fn stream_user_exercise_states_for_course<'a>(
1418    conn: &'a mut PgConnection,
1419    course_ids: &'a [Uuid],
1420) -> impl Stream<Item = sqlx::Result<ExportedUserExerciseState>> + 'a {
1421    sqlx::query_as!(
1422        ExportedUserExerciseState,
1423        r#"
1424SELECT id,
1425  user_id,
1426  exercise_id,
1427  course_id,
1428  created_at,
1429  updated_at,
1430  score_given,
1431  grading_progress AS "grading_progress: _",
1432  activity_progress AS "activity_progress: _",
1433  reviewing_stage AS "reviewing_stage: _",
1434  selected_exercise_slide_id
1435FROM user_exercise_states
1436WHERE course_id = ANY($1)
1437  AND deleted_at IS NULL
1438        "#,
1439        course_ids
1440    )
1441    .fetch(conn)
1442}
1443
1444pub async fn get_all_for_course(
1445    conn: &mut PgConnection,
1446    course_id: Uuid,
1447) -> ModelResult<Vec<UserExerciseState>> {
1448    let res = sqlx::query_as!(
1449        UserExerciseState,
1450        r#"
1451SELECT id,
1452  user_id,
1453  exercise_id,
1454  course_id,
1455  exam_id,
1456  created_at,
1457  updated_at,
1458  deleted_at,
1459  score_given,
1460  grading_progress AS "grading_progress: _",
1461  activity_progress AS "activity_progress: _",
1462  reviewing_stage AS "reviewing_stage: _",
1463  selected_exercise_slide_id
1464FROM user_exercise_states
1465WHERE course_id = $1
1466  AND deleted_at IS NULL
1467"#,
1468        course_id,
1469    )
1470    .fetch_all(&mut *conn)
1471    .await?;
1472    Ok(res)
1473}
1474
1475pub async fn get_returned_exercise_ids_for_user_and_course(
1476    conn: &mut PgConnection,
1477    exercise_ids: &[Uuid],
1478    user_id: Uuid,
1479    course_id: Uuid,
1480) -> ModelResult<Vec<Uuid>> {
1481    #[derive(sqlx::FromRow)]
1482    struct ExerciseIdRow {
1483        exercise_id: Uuid,
1484    }
1485
1486    let returned_exercise_ids: Vec<ExerciseIdRow> = sqlx::query_as::<_, ExerciseIdRow>(
1487        r#"
1488        SELECT DISTINCT exercise_id
1489        FROM user_exercise_states
1490        WHERE exercise_id = ANY($1::uuid[])
1491          AND user_id = $2
1492          AND course_id = $3
1493          AND deleted_at IS NULL
1494          AND activity_progress IN ('completed', 'submitted')
1495        "#,
1496    )
1497    .bind(exercise_ids)
1498    .bind(user_id)
1499    .bind(course_id)
1500    .fetch_all(conn)
1501    .await?;
1502
1503    Ok(returned_exercise_ids
1504        .into_iter()
1505        .map(|r| r.exercise_id)
1506        .collect())
1507}
1508
1509#[cfg(test)]
1510mod tests {
1511    use chrono::TimeZone;
1512
1513    use super::*;
1514    use crate::{
1515        chapters::NewChapter,
1516        exercise_slides, exercises,
1517        library::content_management::create_new_chapter,
1518        pages::{NewPage, insert_page},
1519        test_helper::*,
1520    };
1521
1522    mod getting_single_module_course_metrics {
1523        use super::*;
1524
1525        #[tokio::test]
1526        async fn works_without_any_user_exercise_states() {
1527            insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1528            let res = get_single_module_metrics(tx.as_mut(), course, course_module.id, user).await;
1529            assert!(res.is_ok())
1530        }
1531    }
1532
1533    #[test]
1534    fn merges_course_modules_with_metrics() {
1535        let timestamp = Utc.with_ymd_and_hms(2022, 6, 22, 0, 0, 0).unwrap();
1536        let module_id = Uuid::parse_str("9e831ecc-9751-42f1-ae7e-9b2f06e523e8").unwrap();
1537        let course_modules = vec![
1538            CourseModule::new(
1539                module_id,
1540                Uuid::parse_str("3fa4bee6-7390-415e-968f-ecdc5f28330e").unwrap(),
1541            )
1542            .set_timestamps(timestamp, timestamp, None)
1543            .set_registration_info(None, Some(5.0), None, false),
1544        ];
1545        let course_metrics_by_course_module_id = HashMap::from([(
1546            module_id,
1547            CourseExerciseMetrics {
1548                course_module_id: module_id,
1549                total_exercises: Some(4),
1550                score_maximum: Some(10),
1551            },
1552        )]);
1553        let user_metrics_by_course_module_id = HashMap::from([(
1554            module_id,
1555            UserCourseMetrics {
1556                course_module_id: module_id,
1557                score_given: Some(1.0),
1558                attempted_exercises: Some(3),
1559            },
1560        )]);
1561        let metrics = merge_modules_with_metrics(
1562            course_modules,
1563            &course_metrics_by_course_module_id,
1564            &user_metrics_by_course_module_id,
1565            "Default module",
1566        )
1567        .unwrap();
1568        assert_eq!(metrics.len(), 1);
1569        let metric = metrics.first().unwrap();
1570        assert_eq!(metric.attempted_exercises, Some(3));
1571        assert_eq!(&metric.course_module_name, "Default module");
1572        assert_eq!(metric.score_given, 1.0);
1573        assert_eq!(metric.score_maximum, Some(10));
1574        assert_eq!(metric.total_exercises, Some(4));
1575    }
1576
1577    #[tokio::test]
1578    async fn get_user_course_progress_open_closed_chapters() {
1579        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page);
1580        // creating a new course inserts one empty default module.
1581        // there will be one empty module and one module with two chapters
1582        // one of which is open
1583        let (new_chapter, _) = create_new_chapter(
1584            tx.as_mut(),
1585            PKeyPolicy::Generate,
1586            &NewChapter {
1587                name: "best chapter 1".to_string(),
1588                color: None,
1589                course_id: course,
1590                chapter_number: 2,
1591                front_page_id: None,
1592                deadline: None,
1593                opens_at: Some(
1594                    DateTime::parse_from_str(
1595                        // chapter is not open yet
1596                        "2983 Apr 13 12:09:14 +0000",
1597                        "%Y %b %d %H:%M:%S %z",
1598                    )
1599                    .unwrap()
1600                    .to_utc(),
1601                ),
1602                course_module_id: Some(course_module.id),
1603            },
1604            user,
1605            |_, _, _| unimplemented!(),
1606            |_| unimplemented!(),
1607        )
1608        .await
1609        .unwrap();
1610
1611        // insert a page with an exercise to the not-open chapter
1612        let page = insert_page(
1613            tx.as_mut(),
1614            NewPage {
1615                exercises: vec![],
1616                exercise_slides: vec![],
1617                exercise_tasks: vec![],
1618                content: vec![],
1619                url_path: "/page1".to_string(),
1620                title: "title".to_string(),
1621                course_id: Some(course),
1622                exam_id: None,
1623                chapter_id: Some(new_chapter.id),
1624                front_page_of_chapter_id: Some(new_chapter.id),
1625                content_search_language: None,
1626            },
1627            user,
1628            |_, _, _| unimplemented!(),
1629            |_| unimplemented!(),
1630        )
1631        .await
1632        .unwrap();
1633        let ex = exercises::insert(
1634            tx.as_mut(),
1635            PKeyPolicy::Generate,
1636            course,
1637            "ex 1",
1638            page.id,
1639            new_chapter.id,
1640            1,
1641        )
1642        .await
1643        .unwrap();
1644        exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1645            .await
1646            .unwrap();
1647        // another chapter
1648        let (new_chapter2, _) = create_new_chapter(
1649            tx.as_mut(),
1650            PKeyPolicy::Generate,
1651            &NewChapter {
1652                name: "best chapter 2".to_string(),
1653                color: None,
1654                course_id: course,
1655                chapter_number: 3,
1656                front_page_id: None,
1657                deadline: None,
1658                opens_at: Some(
1659                    DateTime::parse_from_str(
1660                        // chapter is open yet
1661                        "1983 Apr 13 12:09:14 +0000",
1662                        "%Y %b %d %H:%M:%S %z",
1663                    )
1664                    .unwrap()
1665                    .to_utc(),
1666                ),
1667                course_module_id: Some(course_module.id),
1668            },
1669            user,
1670            |_, _, _| unimplemented!(),
1671            |_| unimplemented!(),
1672        )
1673        .await
1674        .unwrap();
1675
1676        // insert a page with an exercise to the not-open chapter
1677        let page2 = insert_page(
1678            tx.as_mut(),
1679            NewPage {
1680                exercises: vec![],
1681                exercise_slides: vec![],
1682                exercise_tasks: vec![],
1683                content: vec![],
1684                url_path: "/page2".to_string(),
1685                title: "title".to_string(),
1686                course_id: Some(course),
1687                exam_id: None,
1688                chapter_id: Some(new_chapter2.id),
1689                front_page_of_chapter_id: Some(new_chapter2.id),
1690                content_search_language: None,
1691            },
1692            user,
1693            |_, _, _| unimplemented!(),
1694            |_| unimplemented!(),
1695        )
1696        .await
1697        .unwrap();
1698        let ex = exercises::insert(
1699            tx.as_mut(),
1700            PKeyPolicy::Generate,
1701            course,
1702            "ex 1",
1703            page2.id,
1704            new_chapter2.id,
1705            1,
1706        )
1707        .await
1708        .unwrap();
1709        exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1710            .await
1711            .unwrap();
1712
1713        // should list all modules and exercises
1714        let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1715            .await
1716            .unwrap();
1717        // should only list modules with chapters and the exercises from open chapters
1718        let progress_open_chapters = get_user_course_progress(tx.as_mut(), course, user, true)
1719            .await
1720            .unwrap();
1721
1722        assert_ne!(progress_all, progress_open_chapters);
1723        assert_eq!(progress_all.len(), 2);
1724        assert_eq!(progress_open_chapters.len(), 1);
1725        assert_eq!(
1726            progress_all[1].course_module_id,
1727            progress_open_chapters[0].course_module_id
1728        );
1729        assert_eq!(progress_all[1].total_exercises, Some(2));
1730        assert_eq!(progress_open_chapters[0].total_exercises, Some(1));
1731    }
1732
1733    #[tokio::test]
1734    async fn get_user_course_progress_filter_out_closed_module() {
1735        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1736        // creating a new course inserts one empty default module.
1737        // there will be one other module with one closed chapter only
1738        let (new_chapter, _) = create_new_chapter(
1739            tx.as_mut(),
1740            PKeyPolicy::Generate,
1741            &NewChapter {
1742                name: "best chapter".to_string(),
1743                color: None,
1744                course_id: course,
1745                chapter_number: 2,
1746                front_page_id: None,
1747                deadline: None,
1748                opens_at: Some(
1749                    DateTime::parse_from_str(
1750                        // chapter is not open yet
1751                        "2983 Apr 13 12:09:14 +0000",
1752                        "%Y %b %d %H:%M:%S %z",
1753                    )
1754                    .unwrap()
1755                    .to_utc(),
1756                ),
1757                course_module_id: Some(course_module.id),
1758            },
1759            user,
1760            |_, _, _| unimplemented!(),
1761            |_| unimplemented!(),
1762        )
1763        .await
1764        .unwrap();
1765
1766        // insert a page with an exercise to the not-open chapter
1767        let page = insert_page(
1768            tx.as_mut(),
1769            NewPage {
1770                exercises: vec![],
1771                exercise_slides: vec![],
1772                exercise_tasks: vec![],
1773                content: vec![],
1774                url_path: "/page2".to_string(),
1775                title: "title".to_string(),
1776                course_id: Some(course),
1777                exam_id: None,
1778                chapter_id: Some(new_chapter.id),
1779                front_page_of_chapter_id: Some(new_chapter.id),
1780                content_search_language: None,
1781            },
1782            user,
1783            |_, _, _| unimplemented!(),
1784            |_| unimplemented!(),
1785        )
1786        .await
1787        .unwrap();
1788        let ex = exercises::insert(
1789            tx.as_mut(),
1790            PKeyPolicy::Generate,
1791            course,
1792            "ex 1",
1793            page.id,
1794            new_chapter.id,
1795            1,
1796        )
1797        .await
1798        .unwrap();
1799        exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1800            .await
1801            .unwrap();
1802
1803        // should list one empty module and one module with only one chapter
1804        // which is closed
1805        let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1806            .await
1807            .unwrap();
1808        // should be empty
1809        let progress_open_chapters_modules =
1810            get_user_course_progress(tx.as_mut(), course, user, true)
1811                .await
1812                .unwrap();
1813
1814        assert_ne!(progress_all, progress_open_chapters_modules);
1815        assert_eq!(progress_all.len(), 2);
1816        assert_eq!(progress_open_chapters_modules.len(), 0);
1817        assert_eq!(progress_all[1].total_exercises, Some(1));
1818    }
1819
1820    #[tokio::test]
1821    async fn has_pending_manual_reviews_in_chapter_reflects_review_state() {
1822        insert_data!(
1823            :tx,
1824            :user,
1825            :org,
1826            :course,
1827            instance: _instance,
1828            :course_module,
1829            chapter: chapter_id,
1830            page: _page_id,
1831            exercise: exercise_id,
1832            slide: _exercise_slide_id,
1833            task: _exercise_task_id
1834        );
1835
1836        exercises::update_teacher_reviews_answer_after_locking(tx.as_mut(), exercise_id, true)
1837            .await
1838            .unwrap();
1839        get_or_create_user_exercise_state(tx.as_mut(), user, exercise_id, Some(course), None)
1840            .await
1841            .unwrap();
1842
1843        update_reviewing_stage(
1844            tx.as_mut(),
1845            user,
1846            CourseOrExamId::Course(course),
1847            exercise_id,
1848            ReviewingStage::WaitingForManualGrading,
1849        )
1850        .await
1851        .unwrap();
1852
1853        let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1854            .await
1855            .unwrap();
1856        assert!(has_pending);
1857
1858        update_reviewing_stage(
1859            tx.as_mut(),
1860            user,
1861            CourseOrExamId::Course(course),
1862            exercise_id,
1863            ReviewingStage::ReviewedAndLocked,
1864        )
1865        .await
1866        .unwrap();
1867
1868        let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1869            .await
1870            .unwrap();
1871        assert!(!has_pending);
1872    }
1873
1874    #[tokio::test]
1875    async fn has_pending_manual_reviews_in_chapter_counts_self_review_manual_flows() {
1876        insert_data!(
1877            :tx,
1878            :user,
1879            :org,
1880            :course,
1881            instance: _instance,
1882            :course_module,
1883            chapter: chapter_id,
1884            page: _page_id,
1885            exercise: exercise_id,
1886            slide: _exercise_slide_id,
1887            task: _exercise_task_id
1888        );
1889
1890        exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
1891            tx.as_mut(),
1892            exercise_id,
1893            false,
1894            true,
1895            false,
1896        )
1897        .await
1898        .unwrap();
1899        get_or_create_user_exercise_state(tx.as_mut(), user, exercise_id, Some(course), None)
1900            .await
1901            .unwrap();
1902
1903        update_reviewing_stage(
1904            tx.as_mut(),
1905            user,
1906            CourseOrExamId::Course(course),
1907            exercise_id,
1908            ReviewingStage::WaitingForManualGrading,
1909        )
1910        .await
1911        .unwrap();
1912
1913        let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1914            .await
1915            .unwrap();
1916        assert!(has_pending);
1917    }
1918}