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    #[derive(sqlx::FromRow)]
1478    struct ExerciseIdRow {
1479        exercise_id: Uuid,
1480    }
1481
1482    let returned_exercise_ids: Vec<ExerciseIdRow> = sqlx::query_as::<_, ExerciseIdRow>(
1483        r#"
1484        SELECT DISTINCT exercise_id
1485        FROM user_exercise_states
1486        WHERE exercise_id = ANY($1::uuid[])
1487          AND user_id = $2
1488          AND course_id = $3
1489          AND deleted_at IS NULL
1490          AND activity_progress IN ('completed', 'submitted')
1491        "#,
1492    )
1493    .bind(exercise_ids)
1494    .bind(user_id)
1495    .bind(course_id)
1496    .fetch_all(conn)
1497    .await?;
1498
1499    Ok(returned_exercise_ids
1500        .into_iter()
1501        .map(|r| r.exercise_id)
1502        .collect())
1503}
1504
1505#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, FromRow)]
1506pub struct UserExerciseStateWithExerciseName {
1507    pub exercise_id: Uuid,
1508    pub exercise_name: String,
1509    pub reviewing_stage: ReviewingStage,
1510    pub score_given: Option<f32>,
1511}
1512
1513/// The user's exercise states in the given course currently sitting in one of `stages`, e.g. to
1514/// list what is awaiting peer/self/manual review.
1515pub async fn get_states_in_reviewing_stages_for_user_and_course(
1516    conn: &mut PgConnection,
1517    user_id: Uuid,
1518    course_id: Uuid,
1519    stages: &[ReviewingStage],
1520) -> ModelResult<Vec<UserExerciseStateWithExerciseName>> {
1521    let res = sqlx::query_as!(
1522        UserExerciseStateWithExerciseName,
1523        r#"
1524SELECT e.id AS exercise_id,
1525  e.name AS exercise_name,
1526  ues.reviewing_stage,
1527  ues.score_given
1528FROM user_exercise_states ues
1529  JOIN exercises e ON e.id = ues.exercise_id
1530WHERE ues.user_id = $1
1531  AND ues.course_id = $2
1532  AND ues.reviewing_stage = ANY($3::reviewing_stage[])
1533  AND ues.deleted_at IS NULL
1534  AND e.deleted_at IS NULL
1535        "#,
1536        user_id,
1537        course_id,
1538        stages as &[ReviewingStage],
1539    )
1540    .fetch_all(conn)
1541    .await?;
1542    Ok(res)
1543}
1544
1545#[cfg(test)]
1546mod tests {
1547    use chrono::TimeZone;
1548
1549    use super::*;
1550    use crate::{
1551        chapters::NewChapter,
1552        exercise_slides, exercises,
1553        library::content_management::create_new_chapter,
1554        pages::{NewPage, insert_page},
1555        test_helper::*,
1556    };
1557
1558    mod getting_single_module_course_metrics {
1559        use super::*;
1560
1561        #[tokio::test]
1562        async fn works_without_any_user_exercise_states() {
1563            insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1564            let res = get_single_module_metrics(tx.as_mut(), course, course_module.id, user).await;
1565            assert!(res.is_ok())
1566        }
1567    }
1568
1569    #[test]
1570    fn merges_course_modules_with_metrics() {
1571        let timestamp = Utc.with_ymd_and_hms(2022, 6, 22, 0, 0, 0).unwrap();
1572        let module_id = Uuid::parse_str("9e831ecc-9751-42f1-ae7e-9b2f06e523e8").unwrap();
1573        let course_modules = vec![
1574            CourseModule::new(
1575                module_id,
1576                Uuid::parse_str("3fa4bee6-7390-415e-968f-ecdc5f28330e").unwrap(),
1577            )
1578            .set_timestamps(timestamp, timestamp, None)
1579            .set_registration_info(None, Some(5.0), None, false, false),
1580        ];
1581        let course_metrics_by_course_module_id = HashMap::from([(
1582            module_id,
1583            CourseExerciseMetrics {
1584                course_module_id: module_id,
1585                total_exercises: Some(4),
1586                score_maximum: Some(10),
1587            },
1588        )]);
1589        let user_metrics_by_course_module_id = HashMap::from([(
1590            module_id,
1591            UserCourseMetrics {
1592                course_module_id: module_id,
1593                score_given: Some(1.0),
1594                attempted_exercises: Some(3),
1595            },
1596        )]);
1597        let metrics = merge_modules_with_metrics(
1598            course_modules,
1599            &course_metrics_by_course_module_id,
1600            &user_metrics_by_course_module_id,
1601            "Default module",
1602        )
1603        .unwrap();
1604        assert_eq!(metrics.len(), 1);
1605        let metric = metrics.first().unwrap();
1606        assert_eq!(metric.attempted_exercises, Some(3));
1607        assert_eq!(&metric.course_module_name, "Default module");
1608        assert_eq!(metric.score_given, 1.0);
1609        assert_eq!(metric.score_maximum, Some(10));
1610        assert_eq!(metric.total_exercises, Some(4));
1611    }
1612
1613    #[tokio::test]
1614    async fn get_user_course_progress_open_closed_chapters() {
1615        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page);
1616        // creating a new course inserts one empty default module.
1617        // there will be one empty module and one module with two chapters
1618        // one of which is open
1619        let (new_chapter, _) = create_new_chapter(
1620            tx.as_mut(),
1621            PKeyPolicy::Generate,
1622            &NewChapter {
1623                name: "best chapter 1".to_string(),
1624                color: None,
1625                course_id: course,
1626                chapter_number: 2,
1627                front_page_id: None,
1628                deadline: None,
1629                opens_at: Some(
1630                    DateTime::parse_from_str(
1631                        // chapter is not open yet
1632                        "2983 Apr 13 12:09:14 +0000",
1633                        "%Y %b %d %H:%M:%S %z",
1634                    )
1635                    .unwrap()
1636                    .to_utc(),
1637                ),
1638                course_module_id: Some(course_module.id),
1639            },
1640            user,
1641            |_, _, _| unimplemented!(),
1642            |_| unimplemented!(),
1643        )
1644        .await
1645        .unwrap();
1646
1647        // insert a page with an exercise to the not-open chapter
1648        let page = insert_page(
1649            tx.as_mut(),
1650            NewPage {
1651                exercises: vec![],
1652                exercise_slides: vec![],
1653                exercise_tasks: vec![],
1654                content: vec![],
1655                url_path: "/page1".to_string(),
1656                title: "title".to_string(),
1657                course_id: Some(course),
1658                exam_id: None,
1659                chapter_id: Some(new_chapter.id),
1660                front_page_of_chapter_id: Some(new_chapter.id),
1661                content_search_language: None,
1662                hidden: false,
1663            },
1664            user,
1665            |_, _, _| unimplemented!(),
1666            |_| unimplemented!(),
1667        )
1668        .await
1669        .unwrap();
1670        let ex = exercises::insert(
1671            tx.as_mut(),
1672            PKeyPolicy::Generate,
1673            course,
1674            "ex 1",
1675            page.id,
1676            new_chapter.id,
1677            1,
1678        )
1679        .await
1680        .unwrap();
1681        exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1682            .await
1683            .unwrap();
1684        // another chapter
1685        let (new_chapter2, _) = create_new_chapter(
1686            tx.as_mut(),
1687            PKeyPolicy::Generate,
1688            &NewChapter {
1689                name: "best chapter 2".to_string(),
1690                color: None,
1691                course_id: course,
1692                chapter_number: 3,
1693                front_page_id: None,
1694                deadline: None,
1695                opens_at: Some(
1696                    DateTime::parse_from_str(
1697                        // chapter is open yet
1698                        "1983 Apr 13 12:09:14 +0000",
1699                        "%Y %b %d %H:%M:%S %z",
1700                    )
1701                    .unwrap()
1702                    .to_utc(),
1703                ),
1704                course_module_id: Some(course_module.id),
1705            },
1706            user,
1707            |_, _, _| unimplemented!(),
1708            |_| unimplemented!(),
1709        )
1710        .await
1711        .unwrap();
1712
1713        // insert a page with an exercise to the not-open chapter
1714        let page2 = insert_page(
1715            tx.as_mut(),
1716            NewPage {
1717                exercises: vec![],
1718                exercise_slides: vec![],
1719                exercise_tasks: vec![],
1720                content: vec![],
1721                url_path: "/page2".to_string(),
1722                title: "title".to_string(),
1723                course_id: Some(course),
1724                exam_id: None,
1725                chapter_id: Some(new_chapter2.id),
1726                front_page_of_chapter_id: Some(new_chapter2.id),
1727                content_search_language: None,
1728                hidden: false,
1729            },
1730            user,
1731            |_, _, _| unimplemented!(),
1732            |_| unimplemented!(),
1733        )
1734        .await
1735        .unwrap();
1736        let ex = exercises::insert(
1737            tx.as_mut(),
1738            PKeyPolicy::Generate,
1739            course,
1740            "ex 1",
1741            page2.id,
1742            new_chapter2.id,
1743            1,
1744        )
1745        .await
1746        .unwrap();
1747        exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1748            .await
1749            .unwrap();
1750
1751        // should list all modules and exercises
1752        let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1753            .await
1754            .unwrap();
1755        // should only list modules with chapters and the exercises from open chapters
1756        let progress_open_chapters = get_user_course_progress(tx.as_mut(), course, user, true)
1757            .await
1758            .unwrap();
1759
1760        assert_ne!(progress_all, progress_open_chapters);
1761        assert_eq!(progress_all.len(), 2);
1762        assert_eq!(progress_open_chapters.len(), 1);
1763        assert_eq!(
1764            progress_all[1].course_module_id,
1765            progress_open_chapters[0].course_module_id
1766        );
1767        assert_eq!(progress_all[1].total_exercises, Some(2));
1768        assert_eq!(progress_open_chapters[0].total_exercises, Some(1));
1769    }
1770
1771    #[tokio::test]
1772    async fn get_user_course_progress_filter_out_closed_module() {
1773        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1774        // creating a new course inserts one empty default module.
1775        // there will be one other module with one closed chapter only
1776        let (new_chapter, _) = create_new_chapter(
1777            tx.as_mut(),
1778            PKeyPolicy::Generate,
1779            &NewChapter {
1780                name: "best chapter".to_string(),
1781                color: None,
1782                course_id: course,
1783                chapter_number: 2,
1784                front_page_id: None,
1785                deadline: None,
1786                opens_at: Some(
1787                    DateTime::parse_from_str(
1788                        // chapter is not open yet
1789                        "2983 Apr 13 12:09:14 +0000",
1790                        "%Y %b %d %H:%M:%S %z",
1791                    )
1792                    .unwrap()
1793                    .to_utc(),
1794                ),
1795                course_module_id: Some(course_module.id),
1796            },
1797            user,
1798            |_, _, _| unimplemented!(),
1799            |_| unimplemented!(),
1800        )
1801        .await
1802        .unwrap();
1803
1804        // insert a page with an exercise to the not-open chapter
1805        let page = insert_page(
1806            tx.as_mut(),
1807            NewPage {
1808                exercises: vec![],
1809                exercise_slides: vec![],
1810                exercise_tasks: vec![],
1811                content: vec![],
1812                url_path: "/page2".to_string(),
1813                title: "title".to_string(),
1814                course_id: Some(course),
1815                exam_id: None,
1816                chapter_id: Some(new_chapter.id),
1817                front_page_of_chapter_id: Some(new_chapter.id),
1818                content_search_language: None,
1819                hidden: false,
1820            },
1821            user,
1822            |_, _, _| unimplemented!(),
1823            |_| unimplemented!(),
1824        )
1825        .await
1826        .unwrap();
1827        let ex = exercises::insert(
1828            tx.as_mut(),
1829            PKeyPolicy::Generate,
1830            course,
1831            "ex 1",
1832            page.id,
1833            new_chapter.id,
1834            1,
1835        )
1836        .await
1837        .unwrap();
1838        exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1839            .await
1840            .unwrap();
1841
1842        // should list one empty module and one module with only one chapter
1843        // which is closed
1844        let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1845            .await
1846            .unwrap();
1847        // should be empty
1848        let progress_open_chapters_modules =
1849            get_user_course_progress(tx.as_mut(), course, user, true)
1850                .await
1851                .unwrap();
1852
1853        assert_ne!(progress_all, progress_open_chapters_modules);
1854        assert_eq!(progress_all.len(), 2);
1855        assert_eq!(progress_open_chapters_modules.len(), 0);
1856        assert_eq!(progress_all[1].total_exercises, Some(1));
1857    }
1858
1859    #[tokio::test]
1860    async fn has_pending_manual_reviews_in_chapter_reflects_review_state() {
1861        insert_data!(
1862            :tx,
1863            :user,
1864            :org,
1865            :course,
1866            instance: _instance,
1867            :course_module,
1868            chapter: chapter_id,
1869            page: _page_id,
1870            exercise: exercise_id,
1871            slide: _exercise_slide_id,
1872            task: _exercise_task_id
1873        );
1874
1875        exercises::update_teacher_reviews_answer_after_locking(tx.as_mut(), exercise_id, true)
1876            .await
1877            .unwrap();
1878        get_or_create_user_exercise_state(tx.as_mut(), user, exercise_id, Some(course), None)
1879            .await
1880            .unwrap();
1881
1882        update_reviewing_stage(
1883            tx.as_mut(),
1884            user,
1885            CourseOrExamId::Course(course),
1886            exercise_id,
1887            ReviewingStage::WaitingForManualGrading,
1888        )
1889        .await
1890        .unwrap();
1891
1892        let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1893            .await
1894            .unwrap();
1895        assert!(has_pending);
1896
1897        update_reviewing_stage(
1898            tx.as_mut(),
1899            user,
1900            CourseOrExamId::Course(course),
1901            exercise_id,
1902            ReviewingStage::ReviewedAndLocked,
1903        )
1904        .await
1905        .unwrap();
1906
1907        let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1908            .await
1909            .unwrap();
1910        assert!(!has_pending);
1911    }
1912
1913    #[tokio::test]
1914    async fn has_pending_manual_reviews_in_chapter_counts_self_review_manual_flows() {
1915        insert_data!(
1916            :tx,
1917            :user,
1918            :org,
1919            :course,
1920            instance: _instance,
1921            :course_module,
1922            chapter: chapter_id,
1923            page: _page_id,
1924            exercise: exercise_id,
1925            slide: _exercise_slide_id,
1926            task: _exercise_task_id
1927        );
1928
1929        exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
1930            tx.as_mut(),
1931            exercise_id,
1932            false,
1933            true,
1934            false,
1935        )
1936        .await
1937        .unwrap();
1938        get_or_create_user_exercise_state(tx.as_mut(), user, exercise_id, Some(course), None)
1939            .await
1940            .unwrap();
1941
1942        update_reviewing_stage(
1943            tx.as_mut(),
1944            user,
1945            CourseOrExamId::Course(course),
1946            exercise_id,
1947            ReviewingStage::WaitingForManualGrading,
1948        )
1949        .await
1950        .unwrap();
1951
1952        let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1953            .await
1954            .unwrap();
1955        assert!(has_pending);
1956    }
1957}