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