headless_lms_models/
user_exercise_states.rs

1use derive_more::Display;
2use std::collections::HashMap;
3
4use futures::Stream;
5use headless_lms_utils::numbers::option_f32_to_f32_two_decimals_with_none_as_zero;
6use serde_json::Value;
7
8use crate::{
9    course_instances,
10    course_modules::{self, CourseModule},
11    courses,
12    exercises::{ActivityProgress, Exercise, GradingProgress},
13    prelude::*,
14    user_course_settings,
15};
16
17#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, Display)]
18#[sqlx(type_name = "reviewing_stage", rename_all = "snake_case")]
19#[cfg_attr(feature = "ts_rs", derive(TS))]
20/**
21Tells 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`.
22*/
23pub enum ReviewingStage {
24    /**
25    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.
26    */
27    NotStarted,
28    /// In this stage the student is instructed to give peer reviews to other students.
29    PeerReview,
30    /// In this stage the student is instructed to review their own answer.
31    SelfReview,
32    /// 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.
33    WaitingForPeerReviews,
34    /**
35    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.
36
37    Reasons for ending up in this stage may be one of these:
38
39    1. The exercise is configured to require all answers to be reviewed by the teacher.
40    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.
41    */
42    WaitingForManualGrading,
43    /**
44    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
45
46    1. It is likely that we revealed the model solution to the student during the review process.
47    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.
48
49    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.
50    */
51    ReviewedAndLocked,
52}
53
54#[cfg_attr(feature = "ts_rs", derive(TS))]
55#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
56pub struct UserExerciseState {
57    pub id: Uuid,
58    pub user_id: Uuid,
59    pub exercise_id: Uuid,
60    pub course_instance_id: Option<Uuid>,
61    pub exam_id: Option<Uuid>,
62    pub created_at: DateTime<Utc>,
63    pub updated_at: DateTime<Utc>,
64    pub deleted_at: Option<DateTime<Utc>>,
65    pub score_given: Option<f32>,
66    pub grading_progress: GradingProgress,
67    pub activity_progress: ActivityProgress,
68    pub reviewing_stage: ReviewingStage,
69    pub selected_exercise_slide_id: Option<Uuid>,
70}
71
72impl UserExerciseState {
73    pub fn get_course_instance_id(&self) -> ModelResult<Uuid> {
74        self.course_instance_id.ok_or_else(|| {
75            ModelError::new(
76                ModelErrorType::Generic,
77                "Exercise is not part of a course instance.".to_string(),
78                None,
79            )
80        })
81    }
82
83    pub fn get_selected_exercise_slide_id(&self) -> ModelResult<Uuid> {
84        self.selected_exercise_slide_id.ok_or_else(|| {
85            ModelError::new(
86                ModelErrorType::Generic,
87                "No exercise slide selected.".to_string(),
88                None,
89            )
90        })
91    }
92}
93
94#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
95pub struct UserExerciseStateUpdate {
96    pub id: Uuid,
97    pub score_given: Option<f32>,
98    pub activity_progress: ActivityProgress,
99    pub reviewing_stage: ReviewingStage,
100    pub grading_progress: GradingProgress,
101}
102
103/// Either a course instance or exam id.
104///
105/// Exercises can either be part of courses or exams. Many user-related actions need to differentiate
106/// between two, so `CourseInstanceOrExamId` helps when handling these separate scenarios.
107#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy)]
108pub enum CourseInstanceOrExamId {
109    Instance(Uuid),
110    Exam(Uuid),
111}
112
113impl CourseInstanceOrExamId {
114    pub fn from_instance_and_exam_ids(
115        course_instance_id: Option<Uuid>,
116        exam_id: Option<Uuid>,
117    ) -> ModelResult<Self> {
118        match (course_instance_id, exam_id) {
119            (None, None) => Err(ModelError::new(
120                ModelErrorType::Generic,
121                "Expected either course instance or exam id, but neither were provided.",
122                None,
123            )),
124            (Some(instance_id), None) => Ok(Self::Instance(instance_id)),
125            (None, Some(exam_id)) => Ok(Self::Exam(exam_id)),
126            (Some(_), Some(_)) => Err(ModelError::new(
127                ModelErrorType::Generic,
128                "Expected either course instance or exam id, but both were provided.",
129                None,
130            )),
131        }
132    }
133
134    pub fn to_instance_and_exam_ids(&self) -> (Option<Uuid>, Option<Uuid>) {
135        match self {
136            CourseInstanceOrExamId::Instance(instance_id) => (Some(*instance_id), None),
137            CourseInstanceOrExamId::Exam(exam_id) => (None, Some(*exam_id)),
138        }
139    }
140}
141
142impl TryFrom<UserExerciseState> for CourseInstanceOrExamId {
143    type Error = ModelError;
144
145    fn try_from(user_exercise_state: UserExerciseState) -> Result<Self, Self::Error> {
146        Self::from_instance_and_exam_ids(
147            user_exercise_state.course_instance_id,
148            user_exercise_state.exam_id,
149        )
150    }
151}
152
153#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
154#[cfg_attr(feature = "ts_rs", derive(TS))]
155pub struct UserCourseInstanceProgress {
156    pub course_module_id: Uuid,
157    pub course_module_name: String,
158    pub course_module_order_number: i32,
159    pub score_given: f32,
160    pub score_required: Option<i32>,
161    pub score_maximum: Option<u32>,
162    pub total_exercises: Option<u32>,
163    pub attempted_exercises: Option<i32>,
164    pub attempted_exercises_required: Option<i32>,
165}
166
167#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
168#[cfg_attr(feature = "ts_rs", derive(TS))]
169pub struct UserCourseInstanceChapterExerciseProgress {
170    pub exercise_id: Uuid,
171    pub score_given: f32,
172}
173
174#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
175pub struct DatabaseUserCourseInstanceChapterExerciseProgress {
176    pub exercise_id: Uuid,
177    pub score_given: Option<f32>,
178}
179
180#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
181pub struct UserChapterMetrics {
182    pub score_given: Option<f32>,
183    pub attempted_exercises: Option<i64>,
184}
185
186#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
187pub struct UserCourseInstanceMetrics {
188    pub course_module_id: Uuid,
189    pub score_given: Option<f32>,
190    pub attempted_exercises: Option<i64>,
191}
192
193#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
194pub struct CourseInstanceExerciseMetrics {
195    course_module_id: Uuid,
196    total_exercises: Option<i64>,
197    score_maximum: Option<i64>,
198}
199
200#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
201#[cfg_attr(feature = "ts_rs", derive(TS))]
202pub struct ExerciseUserCounts {
203    exercise_name: String,
204    exercise_order_number: i32,
205    page_order_number: i32,
206    chapter_number: i32,
207    exercise_id: Uuid,
208    #[cfg_attr(feature = "ts_rs", ts(type = "number"))]
209    n_users_attempted: Option<i64>,
210    #[cfg_attr(feature = "ts_rs", ts(type = "number"))]
211    n_users_with_some_points: Option<i64>,
212    #[cfg_attr(feature = "ts_rs", ts(type = "number"))]
213    n_users_with_max_points: Option<i64>,
214}
215
216pub async fn get_course_instance_metrics(
217    conn: &mut PgConnection,
218    course_instance_id: Uuid,
219) -> ModelResult<Vec<CourseInstanceExerciseMetrics>> {
220    let res = sqlx::query_as!(
221        CourseInstanceExerciseMetrics,
222        r"
223SELECT chapters.course_module_id,
224  COUNT(exercises.id) AS total_exercises,
225  SUM(exercises.score_maximum) AS score_maximum
226FROM course_instances
227  LEFT JOIN exercises ON (course_instances.course_id = exercises.course_id)
228  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
229WHERE exercises.deleted_at IS NULL
230  AND course_instances.id = $1
231  AND chapters.course_module_id IS NOT NULL
232GROUP BY chapters.course_module_id
233        ",
234        course_instance_id
235    )
236    .fetch_all(conn)
237    .await?;
238    Ok(res)
239}
240
241pub async fn get_course_instance_metrics_indexed_by_module_id(
242    conn: &mut PgConnection,
243    course_instance_id: Uuid,
244) -> ModelResult<HashMap<Uuid, CourseInstanceExerciseMetrics>> {
245    let res = get_course_instance_metrics(conn, course_instance_id)
246        .await?
247        .into_iter()
248        .map(|x| (x.course_module_id, x))
249        .collect();
250    Ok(res)
251}
252
253/// Gets course instance metrics for a single module.
254pub async fn get_single_module_course_instance_metrics(
255    conn: &mut PgConnection,
256    course_instance_id: Uuid,
257    course_module_id: Uuid,
258    user_id: Uuid,
259) -> ModelResult<UserCourseInstanceMetrics> {
260    let res = sqlx::query!(
261        "
262SELECT COUNT(ues.exercise_id) AS attempted_exercises,
263  COALESCE(SUM(ues.score_given), 0) AS score_given
264FROM user_exercise_states AS ues
265  LEFT JOIN exercises ON (ues.exercise_id = exercises.id)
266  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
267WHERE chapters.course_module_id = $1
268  AND ues.course_instance_id = $2
269  AND ues.activity_progress IN ('completed', 'submitted')
270  AND ues.user_id = $3
271  AND ues.deleted_at IS NULL
272        ",
273        course_module_id,
274        course_instance_id,
275        user_id,
276    )
277    .map(|x| UserCourseInstanceMetrics {
278        course_module_id,
279        score_given: x.score_given,
280        attempted_exercises: x.attempted_exercises,
281    })
282    .fetch_one(conn)
283    .await?;
284    Ok(res)
285}
286
287pub async fn get_user_course_instance_metrics(
288    conn: &mut PgConnection,
289    course_instance_id: Uuid,
290    user_id: Uuid,
291) -> ModelResult<Vec<UserCourseInstanceMetrics>> {
292    let res = sqlx::query_as!(
293        UserCourseInstanceMetrics,
294        r"
295SELECT chapters.course_module_id,
296  COUNT(ues.exercise_id) AS attempted_exercises,
297  COALESCE(SUM(ues.score_given), 0) AS score_given
298FROM user_exercise_states AS ues
299  LEFT JOIN exercises ON (ues.exercise_id = exercises.id)
300  LEFT JOIN chapters ON (exercises.chapter_id = chapters.id)
301WHERE ues.course_instance_id = $1
302  AND ues.activity_progress IN ('completed', 'submitted')
303  AND ues.user_id = $2
304  AND ues.deleted_at IS NULL
305GROUP BY chapters.course_module_id;
306        ",
307        course_instance_id,
308        user_id,
309    )
310    .fetch_all(conn)
311    .await?;
312    Ok(res)
313}
314
315pub async fn get_user_course_instance_metrics_indexed_by_module_id(
316    conn: &mut PgConnection,
317    course_instance_id: Uuid,
318    user_id: Uuid,
319) -> ModelResult<HashMap<Uuid, UserCourseInstanceMetrics>> {
320    let res = get_user_course_instance_metrics(conn, course_instance_id, user_id)
321        .await?
322        .into_iter()
323        .map(|x| (x.course_module_id, x))
324        .collect();
325    Ok(res)
326}
327
328pub async fn get_user_course_instance_chapter_metrics(
329    conn: &mut PgConnection,
330    course_instance_id: Uuid,
331    exercise_ids: &[Uuid],
332    user_id: Uuid,
333) -> ModelResult<UserChapterMetrics> {
334    let res = sqlx::query_as!(
335        UserChapterMetrics,
336        r#"
337SELECT COUNT(ues.exercise_id) AS attempted_exercises,
338  COALESCE(SUM(ues.score_given), 0) AS score_given
339FROM user_exercise_states AS ues
340WHERE ues.exercise_id IN (
341    SELECT UNNEST($1::uuid [])
342  )
343  AND ues.deleted_at IS NULL
344  AND ues.activity_progress IN ('completed', 'submitted')
345  AND ues.user_id = $2
346  AND ues.course_instance_id = $3;
347                "#,
348        &exercise_ids,
349        user_id,
350        course_instance_id
351    )
352    .fetch_one(conn)
353    .await?;
354    Ok(res)
355}
356
357pub async fn get_user_course_instance_progress(
358    conn: &mut PgConnection,
359    course_instance_id: Uuid,
360    user_id: Uuid,
361) -> ModelResult<Vec<UserCourseInstanceProgress>> {
362    let course_metrics =
363        get_course_instance_metrics_indexed_by_module_id(&mut *conn, course_instance_id).await?;
364    let user_metrics =
365        get_user_course_instance_metrics_indexed_by_module_id(conn, course_instance_id, user_id)
366            .await?;
367    let course_id = course_instances::get_course_instance(conn, course_instance_id)
368        .await?
369        .course_id;
370    let course_name = courses::get_course(conn, course_id).await?.name;
371    let course_modules = course_modules::get_by_course_id(conn, course_id).await?;
372    merge_modules_with_metrics(course_modules, &course_metrics, &user_metrics, &course_name)
373}
374
375/// Gets the total amount of points that the user has received from an exam.
376///
377/// The caller should take into consideration that for an ongoing exam the result will be volatile.
378pub async fn get_user_total_exam_points(
379    conn: &mut PgConnection,
380    user_id: Uuid,
381    exam_id: Uuid,
382) -> ModelResult<Option<f32>> {
383    let res = sqlx::query!(
384        r#"
385SELECT SUM(score_given) AS "points"
386FROM user_exercise_states
387WHERE user_id = $2
388  AND exam_id = $1
389  AND deleted_at IS NULL
390        "#,
391        exam_id,
392        user_id,
393    )
394    .map(|x| x.points)
395    .fetch_one(conn)
396    .await?;
397    Ok(res)
398}
399
400fn merge_modules_with_metrics(
401    course_modules: Vec<CourseModule>,
402    course_metrics_by_course_module_id: &HashMap<Uuid, CourseInstanceExerciseMetrics>,
403    user_metrics_by_course_module_id: &HashMap<Uuid, UserCourseInstanceMetrics>,
404    default_course_module_name_placeholder: &str,
405) -> ModelResult<Vec<UserCourseInstanceProgress>> {
406    course_modules
407        .into_iter()
408        .map(|course_module| {
409            let user_metrics = user_metrics_by_course_module_id.get(&course_module.id);
410            let course_metrics = course_metrics_by_course_module_id.get(&course_module.id);
411            let requirements = course_module.completion_policy.automatic();
412            let progress = UserCourseInstanceProgress {
413                course_module_id: course_module.id,
414                // Only default course module doesn't have a name.
415                course_module_name: course_module
416                    .name
417                    .unwrap_or_else(|| default_course_module_name_placeholder.to_string()),
418                course_module_order_number: course_module.order_number,
419                score_given: option_f32_to_f32_two_decimals_with_none_as_zero(
420                    user_metrics.and_then(|x| x.score_given),
421                ),
422                score_required: requirements.and_then(|x| x.number_of_points_treshold),
423                score_maximum: course_metrics
424                    .and_then(|x| x.score_maximum)
425                    .map(TryInto::try_into)
426                    .transpose()?,
427                total_exercises: course_metrics
428                    .and_then(|x| x.total_exercises)
429                    .map(TryInto::try_into)
430                    .transpose()?,
431                attempted_exercises: user_metrics
432                    .and_then(|x| x.attempted_exercises)
433                    .map(TryInto::try_into)
434                    .transpose()?,
435                attempted_exercises_required: requirements
436                    .and_then(|x| x.number_of_exercises_attempted_treshold),
437            };
438            Ok(progress)
439        })
440        .collect::<ModelResult<_>>()
441}
442
443pub async fn get_user_course_instance_chapter_exercises_progress(
444    conn: &mut PgConnection,
445    course_instance_id: Uuid,
446    exercise_ids: &[Uuid],
447    user_id: Uuid,
448) -> ModelResult<Vec<DatabaseUserCourseInstanceChapterExerciseProgress>> {
449    let res = sqlx::query_as!(
450        DatabaseUserCourseInstanceChapterExerciseProgress,
451        r#"
452SELECT COALESCE(ues.score_given, 0) AS score_given,
453  ues.exercise_id AS exercise_id
454FROM user_exercise_states AS ues
455WHERE ues.deleted_at IS NULL
456  AND ues.exercise_id IN (
457    SELECT UNNEST($1::uuid [])
458  )
459  AND ues.course_instance_id = $2
460  AND ues.user_id = $3;
461        "#,
462        exercise_ids,
463        course_instance_id,
464        user_id,
465    )
466    .fetch_all(conn)
467    .await?;
468    Ok(res)
469}
470
471pub async fn get_or_create_user_exercise_state(
472    conn: &mut PgConnection,
473    user_id: Uuid,
474    exercise_id: Uuid,
475    course_instance_id: Option<Uuid>,
476    exam_id: Option<Uuid>,
477) -> ModelResult<UserExerciseState> {
478    let existing = sqlx::query_as!(
479        UserExerciseState,
480        r#"
481SELECT id,
482  user_id,
483  exercise_id,
484  course_instance_id,
485  exam_id,
486  created_at,
487  updated_at,
488  deleted_at,
489  score_given,
490  grading_progress AS "grading_progress: _",
491  activity_progress AS "activity_progress: _",
492  reviewing_stage AS "reviewing_stage: _",
493  selected_exercise_slide_id
494FROM user_exercise_states
495WHERE user_id = $1
496  AND exercise_id = $2
497  AND (course_instance_id = $3 OR exam_id = $4)
498  AND deleted_at IS NULL
499"#,
500        user_id,
501        exercise_id,
502        course_instance_id,
503        exam_id
504    )
505    .fetch_optional(&mut *conn)
506    .await?;
507
508    let res = if let Some(existing) = existing {
509        existing
510    } else {
511        sqlx::query_as!(
512            UserExerciseState,
513            r#"
514    INSERT INTO user_exercise_states (user_id, exercise_id, course_instance_id, exam_id)
515    VALUES ($1, $2, $3, $4)
516    RETURNING id,
517      user_id,
518      exercise_id,
519      course_instance_id,
520      exam_id,
521      created_at,
522      updated_at,
523      deleted_at,
524      score_given,
525      grading_progress as "grading_progress: _",
526      activity_progress as "activity_progress: _",
527      reviewing_stage AS "reviewing_stage: _",
528      selected_exercise_slide_id
529      "#,
530            user_id,
531            exercise_id,
532            course_instance_id,
533            exam_id
534        )
535        .fetch_one(&mut *conn)
536        .await?
537    };
538    Ok(res)
539}
540
541pub async fn get_or_create_user_exercise_state_for_users(
542    conn: &mut PgConnection,
543    user_ids: &[Uuid],
544    exercise_id: Uuid,
545    course_instance_id: Option<Uuid>,
546    exam_id: Option<Uuid>,
547) -> ModelResult<HashMap<Uuid, UserExerciseState>> {
548    let existing = sqlx::query_as!(
549        UserExerciseState,
550        r#"
551SELECT id,
552  user_id,
553  exercise_id,
554  course_instance_id,
555  exam_id,
556  created_at,
557  updated_at,
558  deleted_at,
559  score_given,
560  grading_progress AS "grading_progress: _",
561  activity_progress AS "activity_progress: _",
562  reviewing_stage AS "reviewing_stage: _",
563  selected_exercise_slide_id
564FROM user_exercise_states
565WHERE user_id IN (
566    SELECT UNNEST($1::uuid [])
567  )
568  AND exercise_id = $2
569  AND (course_instance_id = $3 OR exam_id = $4)
570  AND deleted_at IS NULL
571"#,
572        user_ids,
573        exercise_id,
574        course_instance_id,
575        exam_id
576    )
577    .fetch_all(&mut *conn)
578    .await?;
579
580    let mut res = HashMap::with_capacity(user_ids.len());
581    for item in existing.into_iter() {
582        res.insert(item.user_id, item);
583    }
584
585    let missing_user_ids = user_ids
586        .iter()
587        .filter(|user_id| !res.contains_key(user_id))
588        .copied()
589        .collect::<Vec<_>>();
590
591    let created = sqlx::query_as!(
592        UserExerciseState,
593        r#"
594    INSERT INTO user_exercise_states (user_id, exercise_id, course_instance_id, exam_id)
595    SELECT UNNEST($1::uuid []), $2, $3, $4
596    RETURNING id,
597      user_id,
598      exercise_id,
599      course_instance_id,
600      exam_id,
601      created_at,
602      updated_at,
603      deleted_at,
604      score_given,
605      grading_progress as "grading_progress: _",
606      activity_progress as "activity_progress: _",
607      reviewing_stage AS "reviewing_stage: _",
608      selected_exercise_slide_id
609      "#,
610        &missing_user_ids,
611        exercise_id,
612        course_instance_id,
613        exam_id
614    )
615    .fetch_all(&mut *conn)
616    .await?;
617
618    for item in created.into_iter() {
619        res.insert(item.user_id, item);
620    }
621    Ok(res)
622}
623
624pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<UserExerciseState> {
625    let res = sqlx::query_as!(
626        UserExerciseState,
627        r#"
628SELECT id,
629  user_id,
630  exercise_id,
631  course_instance_id,
632  exam_id,
633  created_at,
634  updated_at,
635  deleted_at,
636  score_given,
637  grading_progress AS "grading_progress: _",
638  activity_progress AS "activity_progress: _",
639  reviewing_stage AS "reviewing_stage: _",
640  selected_exercise_slide_id
641FROM user_exercise_states
642WHERE id = $1
643  AND deleted_at IS NULL
644        "#,
645        id,
646    )
647    .fetch_one(conn)
648    .await?;
649    Ok(res)
650}
651
652pub async fn get_by_ids(
653    conn: &mut PgConnection,
654    ids: &[Uuid],
655) -> ModelResult<Vec<UserExerciseState>> {
656    let res = sqlx::query_as!(
657        UserExerciseState,
658        r#"
659SELECT id,
660  user_id,
661  exercise_id,
662  course_instance_id,
663  exam_id,
664  created_at,
665  updated_at,
666  deleted_at,
667  score_given,
668  grading_progress AS "grading_progress: _",
669  activity_progress AS "activity_progress: _",
670  reviewing_stage AS "reviewing_stage: _",
671  selected_exercise_slide_id
672FROM user_exercise_states
673WHERE id = ANY($1)
674AND deleted_at IS NULL
675"#,
676        &ids
677    )
678    .fetch_all(conn)
679    .await?;
680    Ok(res)
681}
682
683pub async fn get_user_total_course_points(
684    conn: &mut PgConnection,
685    user_id: Uuid,
686    course_instance_id: Uuid,
687) -> ModelResult<Option<f32>> {
688    let res = sqlx::query!(
689        r#"
690SELECT SUM(score_given) AS "total_points"
691FROM user_exercise_states
692WHERE user_id = $1
693  AND course_instance_id = $2
694  AND deleted_at IS NULL
695  GROUP BY user_id
696        "#,
697        user_id,
698        course_instance_id,
699    )
700    .map(|x| x.total_points)
701    .fetch_one(conn)
702    .await?;
703    Ok(res)
704}
705
706pub async fn get_users_current_by_exercise(
707    conn: &mut PgConnection,
708    user_id: Uuid,
709    exercise: &Exercise,
710) -> ModelResult<UserExerciseState> {
711    let course_or_exam_id = CourseOrExamId::from(exercise.course_id, exercise.exam_id)?;
712    let course_instance_or_exam_id = match course_or_exam_id {
713        CourseOrExamId::Course(course_id) => {
714            user_course_settings::get_user_course_settings_by_course_id(conn, user_id, course_id)
715                .await?
716                .map(|settings| {
717                    CourseInstanceOrExamId::Instance(settings.current_course_instance_id)
718                })
719                .ok_or_else(|| {
720                    ModelError::new(
721                        ModelErrorType::PreconditionFailed,
722                        "Missing user course settings.".to_string(),
723                        None,
724                    )
725                })
726        }
727        CourseOrExamId::Exam(exam_id) => Ok(CourseInstanceOrExamId::Exam(exam_id)),
728    }?;
729    let user_exercise_state =
730        get_user_exercise_state_if_exists(conn, user_id, exercise.id, course_instance_or_exam_id)
731            .await?
732            .ok_or_else(|| {
733                ModelError::new(
734                    ModelErrorType::PreconditionFailed,
735                    "Missing user exercise state.".to_string(),
736                    None,
737                )
738            })?;
739    Ok(user_exercise_state)
740}
741
742pub async fn get_user_exercise_state_if_exists(
743    conn: &mut PgConnection,
744    user_id: Uuid,
745    exercise_id: Uuid,
746    course_instance_or_exam_id: CourseInstanceOrExamId,
747) -> ModelResult<Option<UserExerciseState>> {
748    let (course_instance_id, exam_id) = course_instance_or_exam_id.to_instance_and_exam_ids();
749    let res = sqlx::query_as!(
750        UserExerciseState,
751        r#"
752SELECT id,
753  user_id,
754  exercise_id,
755  course_instance_id,
756  exam_id,
757  created_at,
758  updated_at,
759  deleted_at,
760  score_given,
761  grading_progress AS "grading_progress: _",
762  activity_progress AS "activity_progress: _",
763  reviewing_stage AS "reviewing_stage: _",
764  selected_exercise_slide_id
765FROM user_exercise_states
766WHERE user_id = $1
767  AND exercise_id = $2
768  AND (course_instance_id = $3 OR exam_id = $4)
769  AND deleted_at IS NULL
770      "#,
771        user_id,
772        exercise_id,
773        course_instance_id,
774        exam_id
775    )
776    .fetch_optional(conn)
777    .await?;
778    Ok(res)
779}
780
781pub async fn get_all_for_user_and_course_instance_or_exam(
782    conn: &mut PgConnection,
783    user_id: Uuid,
784    course_instance_or_exam_id: CourseInstanceOrExamId,
785) -> ModelResult<Vec<UserExerciseState>> {
786    let (course_instance_id, exam_id) = course_instance_or_exam_id.to_instance_and_exam_ids();
787    let res = sqlx::query_as!(
788        UserExerciseState,
789        r#"
790SELECT id,
791  user_id,
792  exercise_id,
793  course_instance_id,
794  exam_id,
795  created_at,
796  updated_at,
797  deleted_at,
798  score_given,
799  grading_progress AS "grading_progress: _",
800  activity_progress AS "activity_progress: _",
801  reviewing_stage AS "reviewing_stage: _",
802  selected_exercise_slide_id
803FROM user_exercise_states
804WHERE user_id = $1
805  AND (course_instance_id = $2 OR exam_id = $3)
806  AND deleted_at IS NULL
807      "#,
808        user_id,
809        course_instance_id,
810        exam_id
811    )
812    .fetch_all(conn)
813    .await?;
814    Ok(res)
815}
816
817pub async fn upsert_selected_exercise_slide_id(
818    conn: &mut PgConnection,
819    user_id: Uuid,
820    exercise_id: Uuid,
821    course_instance_id: Option<Uuid>,
822    exam_id: Option<Uuid>,
823    selected_exercise_slide_id: Option<Uuid>,
824) -> ModelResult<()> {
825    let existing = sqlx::query!(
826        "
827SELECT
828FROM user_exercise_states
829WHERE user_id = $1
830  AND exercise_id = $2
831  AND (course_instance_id = $3 OR exam_id = $4)
832  AND deleted_at IS NULL
833",
834        user_id,
835        exercise_id,
836        course_instance_id,
837        exam_id
838    )
839    .fetch_optional(&mut *conn)
840    .await?;
841    if existing.is_some() {
842        sqlx::query!(
843            "
844UPDATE user_exercise_states
845SET selected_exercise_slide_id = $4
846WHERE user_id = $1
847  AND exercise_id = $2
848  AND (course_instance_id = $3 OR exam_id = $5)
849  AND deleted_at IS NULL
850    ",
851            user_id,
852            exercise_id,
853            course_instance_id,
854            selected_exercise_slide_id,
855            exam_id
856        )
857        .execute(&mut *conn)
858        .await?;
859    } else {
860        sqlx::query!(
861            "
862    INSERT INTO user_exercise_states (
863        user_id,
864        exercise_id,
865        course_instance_id,
866        selected_exercise_slide_id,
867        exam_id
868      )
869    VALUES ($1, $2, $3, $4, $5)
870    ",
871            user_id,
872            exercise_id,
873            course_instance_id,
874            selected_exercise_slide_id,
875            exam_id
876        )
877        .execute(&mut *conn)
878        .await?;
879    }
880    Ok(())
881}
882
883/// TODO: should be moved to the user_exercise_state_updater as a private module so that this cannot be called outside of that module
884pub async fn update(
885    conn: &mut PgConnection,
886    user_exercise_state_update: UserExerciseStateUpdate,
887) -> ModelResult<UserExerciseState> {
888    let res = sqlx::query_as!(
889        UserExerciseState,
890        r#"
891UPDATE user_exercise_states
892SET score_given = $1,
893  activity_progress = $2,
894  reviewing_stage = $3,
895  grading_progress = $4
896WHERE id = $5
897  AND deleted_at IS NULL
898RETURNING id,
899  user_id,
900  exercise_id,
901  course_instance_id,
902  exam_id,
903  created_at,
904  updated_at,
905  deleted_at,
906  score_given,
907  grading_progress AS "grading_progress: _",
908  activity_progress AS "activity_progress: _",
909  reviewing_stage AS "reviewing_stage: _",
910  selected_exercise_slide_id
911        "#,
912        user_exercise_state_update.score_given,
913        user_exercise_state_update.activity_progress as ActivityProgress,
914        user_exercise_state_update.reviewing_stage as ReviewingStage,
915        user_exercise_state_update.grading_progress as GradingProgress,
916        user_exercise_state_update.id,
917    )
918    .fetch_one(conn)
919    .await?;
920    Ok(res)
921}
922
923pub async fn update_reviewing_stage(
924    conn: &mut PgConnection,
925    user_id: Uuid,
926    course_instance_or_exam_id: CourseInstanceOrExamId,
927    exercise_id: Uuid,
928    new_reviewing_stage: ReviewingStage,
929) -> ModelResult<UserExerciseState> {
930    let (course_instance_id, exam_id) = course_instance_or_exam_id.to_instance_and_exam_ids();
931    let res = sqlx::query_as!(
932        UserExerciseState,
933        r#"
934UPDATE user_exercise_states
935SET reviewing_stage = $5
936WHERE user_id = $1
937AND (course_instance_id = $2 OR exam_id = $3)
938AND exercise_id = $4
939RETURNING id,
940  user_id,
941  exercise_id,
942  course_instance_id,
943  exam_id,
944  created_at,
945  updated_at,
946  deleted_at,
947  score_given,
948  grading_progress AS "grading_progress: _",
949  activity_progress AS "activity_progress: _",
950  reviewing_stage AS "reviewing_stage: _",
951  selected_exercise_slide_id
952        "#,
953        user_id,
954        course_instance_id,
955        exam_id,
956        exercise_id,
957        new_reviewing_stage as ReviewingStage
958    )
959    .fetch_one(conn)
960    .await?;
961    Ok(res)
962}
963
964/// TODO: should be removed
965pub async fn update_exercise_progress(
966    conn: &mut PgConnection,
967    id: Uuid,
968    reviewing_stage: ReviewingStage,
969) -> ModelResult<UserExerciseState> {
970    let res = sqlx::query_as!(
971        UserExerciseState,
972        r#"
973UPDATE user_exercise_states
974SET reviewing_stage = $1
975WHERE id = $2
976  AND deleted_at IS NULL
977RETURNING id,
978  user_id,
979  exercise_id,
980  course_instance_id,
981  exam_id,
982  created_at,
983  updated_at,
984  deleted_at,
985  score_given,
986  grading_progress AS "grading_progress: _",
987  activity_progress AS "activity_progress: _",
988  reviewing_stage AS "reviewing_stage: _",
989  selected_exercise_slide_id
990        "#,
991        reviewing_stage as ReviewingStage,
992        id
993    )
994    .fetch_one(conn)
995    .await?;
996    Ok(res)
997}
998
999/// Convenience struct that combines user state to the exercise.
1000///
1001/// Many operations require information about both the user state and the exercise. However, because
1002/// exercises can either belong to a course or an exam, and each course instance will have their
1003/// own `UserExerciseState`, it can get difficult to track the proper context.
1004pub struct ExerciseWithUserState {
1005    exercise: Exercise,
1006    user_exercise_state: UserExerciseState,
1007    type_data: EwusCourseOrExam,
1008}
1009
1010impl ExerciseWithUserState {
1011    pub fn new(exercise: Exercise, user_exercise_state: UserExerciseState) -> ModelResult<Self> {
1012        let state = EwusCourseOrExam::from_exercise_and_user_exercise_state(
1013            &exercise,
1014            &user_exercise_state,
1015        )?;
1016        Ok(Self {
1017            exercise,
1018            user_exercise_state,
1019            type_data: state,
1020        })
1021    }
1022
1023    /// Provides a reference to the inner `Exercise`.
1024    pub fn exercise(&self) -> &Exercise {
1025        &self.exercise
1026    }
1027
1028    /// Provides a reference to the inner `UserExerciseState`.
1029    pub fn user_exercise_state(&self) -> &UserExerciseState {
1030        &self.user_exercise_state
1031    }
1032
1033    pub fn exercise_context(&self) -> &EwusCourseOrExam {
1034        &self.type_data
1035    }
1036
1037    pub fn set_user_exercise_state(
1038        &mut self,
1039        user_exercise_state: UserExerciseState,
1040    ) -> ModelResult<()> {
1041        self.type_data = EwusCourseOrExam::from_exercise_and_user_exercise_state(
1042            &self.exercise,
1043            &user_exercise_state,
1044        )?;
1045        self.user_exercise_state = user_exercise_state;
1046        Ok(())
1047    }
1048
1049    pub fn is_exam_exercise(&self) -> bool {
1050        match self.type_data {
1051            EwusCourseOrExam::Course(_) => false,
1052            EwusCourseOrExam::Exam(_) => true,
1053        }
1054    }
1055}
1056
1057pub struct EwusCourse {
1058    pub course_id: Uuid,
1059    pub course_instance_id: Uuid,
1060}
1061
1062pub struct EwusExam {
1063    pub exam_id: Uuid,
1064}
1065
1066pub enum EwusContext<C, E> {
1067    Course(C),
1068    Exam(E),
1069}
1070
1071pub enum EwusCourseOrExam {
1072    Course(EwusCourse),
1073    Exam(EwusExam),
1074}
1075
1076impl EwusCourseOrExam {
1077    pub fn from_exercise_and_user_exercise_state(
1078        exercise: &Exercise,
1079        user_exercise_state: &UserExerciseState,
1080    ) -> ModelResult<Self> {
1081        if exercise.id == user_exercise_state.exercise_id {
1082            let course_id = exercise.course_id;
1083            let course_instance_id = user_exercise_state.course_instance_id;
1084            let exam_id = exercise.exam_id;
1085            match (course_id, course_instance_id, exam_id) {
1086                (None, None, Some(exam_id)) => Ok(Self::Exam(EwusExam { exam_id })),
1087                (Some(course_id), Some(course_instance_id), None) => Ok(Self::Course(EwusCourse {
1088                    course_id,
1089                    course_instance_id,
1090                })),
1091                _ => Err(ModelError::new(
1092                    ModelErrorType::Generic,
1093                    "Invalid initializer data.".to_string(),
1094                    None,
1095                )),
1096            }
1097        } else {
1098            Err(ModelError::new(
1099                ModelErrorType::Generic,
1100                "Exercise doesn't match the state.".to_string(),
1101                None,
1102            ))
1103        }
1104    }
1105}
1106
1107#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1108pub struct CourseInstanceUserPoints {
1109    pub user_id: Uuid,
1110    pub points_for_each_chapter: Vec<CourseInstanceUserPointsInner>,
1111}
1112
1113#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1114pub struct CourseInstanceUserPointsInner {
1115    pub chapter_number: i32,
1116    pub points_for_chapter: f32,
1117}
1118
1119#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1120pub struct ExamUserPoints {
1121    pub user_id: Uuid,
1122    pub email: String,
1123    pub points_for_exercise: Vec<ExamUserPointsInner>,
1124}
1125
1126#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1127pub struct ExamUserPointsInner {
1128    pub exercise_id: Uuid,
1129    pub score_given: f32,
1130}
1131
1132pub fn stream_course_instance_points(
1133    conn: &mut PgConnection,
1134    course_instance_id: Uuid,
1135) -> impl Stream<Item = sqlx::Result<CourseInstanceUserPoints>> + '_ {
1136    sqlx::query!(
1137        "
1138SELECT user_id,
1139  to_jsonb(array_agg(to_jsonb(uue) - 'email' - 'user_id')) AS points_for_each_chapter
1140FROM (
1141    SELECT ud.email,
1142      u.id AS user_id,
1143      c.chapter_number,
1144      COALESCE(SUM(ues.score_given), 0) AS points_for_chapter
1145    FROM user_exercise_states ues
1146      JOIN users u ON u.id = ues.user_id
1147      JOIN user_details ud ON ud.user_id = u.id
1148      JOIN exercises e ON e.id = ues.exercise_id
1149      JOIN chapters c on e.chapter_id = c.id
1150    WHERE ues.course_instance_id = $1
1151      AND ues.deleted_at IS NULL
1152      AND c.deleted_at IS NULL
1153      AND u.deleted_at IS NULL
1154      AND e.deleted_at IS NULL
1155    GROUP BY ud.email,
1156      u.id,
1157      c.chapter_number
1158  ) as uue
1159GROUP BY user_id
1160
1161",
1162        course_instance_id
1163    )
1164    .try_map(|i| {
1165        let user_id = i.user_id;
1166        let points_for_each_chapter = i.points_for_each_chapter.unwrap_or(Value::Null);
1167        serde_json::from_value(points_for_each_chapter)
1168            .map(|points_for_each_chapter| CourseInstanceUserPoints {
1169                user_id,
1170                points_for_each_chapter,
1171            })
1172            .map_err(|e| sqlx::Error::Decode(Box::new(e)))
1173    })
1174    .fetch(conn)
1175}
1176
1177pub fn stream_exam_points(
1178    conn: &mut PgConnection,
1179    exam_id: Uuid,
1180) -> impl Stream<Item = sqlx::Result<ExamUserPoints>> + '_ {
1181    sqlx::query!(
1182        "
1183SELECT user_id,
1184  email,
1185  to_jsonb(array_agg(to_jsonb(uue) - 'email' - 'user_id')) AS points_for_exercises
1186FROM (
1187    SELECT u.id AS user_id,
1188      ud.email,
1189      exercise_id,
1190      COALESCE(score_given, 0) as score_given
1191    FROM user_exercise_states ues
1192      JOIN users u ON u.id = ues.user_id
1193      JOIN user_details ud ON ud.user_id = u.id
1194      JOIN exercises e ON e.id = ues.exercise_id
1195    WHERE ues.exam_id = $1
1196      AND ues.deleted_at IS NULL
1197      AND u.deleted_at IS NULL
1198      AND e.deleted_at IS NULL
1199  ) as uue
1200GROUP BY user_id,
1201  email
1202",
1203        exam_id
1204    )
1205    .try_map(|i| {
1206        let user_id = i.user_id;
1207        let points_for_exercises = i.points_for_exercises.unwrap_or(Value::Null);
1208        serde_json::from_value(points_for_exercises)
1209            .map(|points_for_exercise| ExamUserPoints {
1210                user_id,
1211                points_for_exercise,
1212                email: i.email,
1213            })
1214            .map_err(|e| sqlx::Error::Decode(Box::new(e)))
1215    })
1216    .fetch(conn)
1217}
1218
1219pub async fn get_course_users_counts_by_exercise(
1220    conn: &mut PgConnection,
1221    course_id: Uuid,
1222) -> ModelResult<Vec<ExerciseUserCounts>> {
1223    let res = sqlx::query_as!(
1224        ExerciseUserCounts,
1225        r#"
1226SELECT exercises.name as exercise_name,
1227  exercises.order_number as exercise_order_number,
1228  pages.order_number as page_order_number,
1229  chapters.chapter_number,
1230  stat_data.*
1231FROM (
1232    SELECT exercise_id,
1233      COUNT(DISTINCT user_id) FILTER (
1234        WHERE ues.activity_progress = 'completed'
1235      ) as n_users_attempted,
1236      COUNT(DISTINCT user_id) FILTER (
1237        WHERE ues.score_given IS NOT NULL
1238          and ues.score_given > 0
1239          AND ues.activity_progress = 'completed'
1240      ) as n_users_with_some_points,
1241      COUNT(DISTINCT user_id) FILTER (
1242        WHERE ues.score_given IS NOT NULL
1243          and ues.score_given >= exercises.score_maximum
1244          and ues.activity_progress = 'completed'
1245      ) as n_users_with_max_points
1246    FROM exercises
1247      JOIN user_exercise_states ues on exercises.id = ues.exercise_id
1248    WHERE exercises.course_id = $1
1249      AND exercises.deleted_at IS NULL
1250      AND ues.deleted_at IS NULL
1251    GROUP BY exercise_id
1252  ) as stat_data
1253  JOIN exercises ON stat_data.exercise_id = exercises.id
1254  JOIN pages on exercises.page_id = pages.id
1255  JOIN chapters on pages.chapter_id = chapters.id
1256WHERE exercises.deleted_at IS NULL
1257  AND pages.deleted_at IS NULL
1258  AND chapters.deleted_at IS NULL
1259          "#,
1260        course_id
1261    )
1262    .fetch_all(conn)
1263    .await?;
1264    Ok(res)
1265}
1266
1267#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1268#[cfg_attr(feature = "ts_rs", derive(TS))]
1269pub struct ExportedUserExerciseState {
1270    pub id: Uuid,
1271    pub user_id: Uuid,
1272    pub exercise_id: Uuid,
1273    pub course_instance_id: Option<Uuid>,
1274    pub created_at: DateTime<Utc>,
1275    pub updated_at: DateTime<Utc>,
1276    pub score_given: Option<f32>,
1277    pub grading_progress: GradingProgress,
1278    pub activity_progress: ActivityProgress,
1279    pub reviewing_stage: ReviewingStage,
1280    pub selected_exercise_slide_id: Option<Uuid>,
1281}
1282
1283pub fn stream_user_exercise_states_for_course<'a>(
1284    conn: &'a mut PgConnection,
1285    course_instance_ids: &'a [Uuid],
1286) -> impl Stream<Item = sqlx::Result<ExportedUserExerciseState>> + 'a {
1287    sqlx::query_as!(
1288        ExportedUserExerciseState,
1289        r#"
1290SELECT id,
1291  user_id,
1292  exercise_id,
1293  course_instance_id,
1294  created_at,
1295  updated_at,
1296  score_given,
1297  grading_progress AS "grading_progress: _",
1298  activity_progress AS "activity_progress: _",
1299  reviewing_stage AS "reviewing_stage: _",
1300  selected_exercise_slide_id
1301FROM user_exercise_states
1302WHERE course_instance_id = ANY($1)
1303  AND deleted_at IS NULL
1304        "#,
1305        course_instance_ids
1306    )
1307    .fetch(conn)
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use chrono::TimeZone;
1313
1314    use super::*;
1315    use crate::test_helper::*;
1316
1317    mod getting_single_module_course_instance_metrics {
1318        use super::*;
1319
1320        #[tokio::test]
1321        async fn works_without_any_user_exercise_states() {
1322            insert_data!(:tx, :user, :org, :course, :instance, :course_module);
1323            let res = get_single_module_course_instance_metrics(
1324                tx.as_mut(),
1325                instance.id,
1326                course_module.id,
1327                user,
1328            )
1329            .await;
1330            assert!(res.is_ok())
1331        }
1332    }
1333
1334    #[test]
1335    fn merges_course_modules_with_metrics() {
1336        let timestamp = Utc.with_ymd_and_hms(2022, 6, 22, 0, 0, 0).unwrap();
1337        let module_id = Uuid::parse_str("9e831ecc-9751-42f1-ae7e-9b2f06e523e8").unwrap();
1338        let course_modules = vec![
1339            CourseModule::new(
1340                module_id,
1341                Uuid::parse_str("3fa4bee6-7390-415e-968f-ecdc5f28330e").unwrap(),
1342            )
1343            .set_timestamps(timestamp, timestamp, None)
1344            .set_registration_info(None, Some(5.0), None, false),
1345        ];
1346        let course_metrics_by_course_module_id = HashMap::from([(
1347            module_id,
1348            CourseInstanceExerciseMetrics {
1349                course_module_id: module_id,
1350                total_exercises: Some(4),
1351                score_maximum: Some(10),
1352            },
1353        )]);
1354        let user_metrics_by_course_module_id = HashMap::from([(
1355            module_id,
1356            UserCourseInstanceMetrics {
1357                course_module_id: module_id,
1358                score_given: Some(1.0),
1359                attempted_exercises: Some(3),
1360            },
1361        )]);
1362        let metrics = merge_modules_with_metrics(
1363            course_modules,
1364            &course_metrics_by_course_module_id,
1365            &user_metrics_by_course_module_id,
1366            "Default module",
1367        )
1368        .unwrap();
1369        assert_eq!(metrics.len(), 1);
1370        let metric = metrics.first().unwrap();
1371        assert_eq!(metric.attempted_exercises, Some(3));
1372        assert_eq!(&metric.course_module_name, "Default module");
1373        assert_eq!(metric.score_given, 1.0);
1374        assert_eq!(metric.score_maximum, Some(10));
1375        assert_eq!(metric.total_exercises, Some(4));
1376    }
1377}