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")]
18pub enum ReviewingStage {
22 NotStarted,
26 PeerReview,
28 SelfReview,
30 WaitingForPeerReviews,
32 WaitingForManualGrading,
41 ReviewedAndLocked,
50 Locked,
52 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 pub automatic_completion: bool,
118 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
244pub 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#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
410pub struct UserCourseModuleProgress {
411 pub score_given: f32,
412 pub score_maximum: Option<u32>,
414 pub score_required: Option<i32>,
416 pub total_exercises: Option<u32>,
418 pub attempted_exercises: i32,
420 pub attempted_exercises_required: Option<i32>,
422 pub automatic_completion: bool,
424 pub requires_exam: bool,
426}
427
428pub 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
526pub 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 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
911pub 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
944pub 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
1071pub 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
1126pub 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
1148pub 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 pub fn exercise(&self) -> &Exercise {
1173 &self.exercise
1174 }
1175
1176 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
1513pub 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 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 "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 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 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 "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 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 let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1753 .await
1754 .unwrap();
1755 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 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 "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 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 let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1845 .await
1846 .unwrap();
1847 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}