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))]
20pub enum ReviewingStage {
24 NotStarted,
28 PeerReview,
30 SelfReview,
32 WaitingForPeerReviews,
34 WaitingForManualGrading,
43 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#[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
253pub 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
375pub 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 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
883pub 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
964pub 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
999pub 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 pub fn exercise(&self) -> &Exercise {
1025 &self.exercise
1026 }
1027
1028 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}