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