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 *FROM user_exercise_states
508WHERE user_id = $1
509 AND exercise_id = $2
510 AND (course_id = $3 OR exam_id = $4)
511 AND deleted_at IS NULL
512"#,
513 user_id,
514 exercise_id,
515 course_id,
516 exam_id
517 )
518 .fetch_optional(&mut *conn)
519 .await?;
520
521 let res = if let Some(existing) = existing {
522 existing
523 } else {
524 sqlx::query_as!(
525 UserExerciseState,
526 r#"
527 INSERT INTO user_exercise_states (user_id, exercise_id, course_id, exam_id)
528 VALUES ($1, $2, $3, $4)
529 RETURNING * "#,
530 user_id,
531 exercise_id,
532 course_id,
533 exam_id
534 )
535 .fetch_one(&mut *conn)
536 .await?
537 };
538 Ok(res)
539}
540
541pub async fn get_or_create_user_exercise_state_for_users(
542 conn: &mut PgConnection,
543 user_ids: &[Uuid],
544 exercise_id: Uuid,
545 course_id: Option<Uuid>,
546 exam_id: Option<Uuid>,
547) -> ModelResult<HashMap<Uuid, UserExerciseState>> {
548 let existing = sqlx::query_as!(
549 UserExerciseState,
550 r#"
551SELECT *FROM user_exercise_states
552WHERE user_id IN (
553 SELECT UNNEST($1::uuid [])
554 )
555 AND exercise_id = $2
556 AND (course_id = $3 OR exam_id = $4)
557 AND deleted_at IS NULL
558"#,
559 user_ids,
560 exercise_id,
561 course_id,
562 exam_id
563 )
564 .fetch_all(&mut *conn)
565 .await?;
566
567 let mut res = HashMap::with_capacity(user_ids.len());
568 for item in existing.into_iter() {
569 res.insert(item.user_id, item);
570 }
571
572 let missing_user_ids = user_ids
573 .iter()
574 .filter(|user_id| !res.contains_key(user_id))
575 .copied()
576 .collect::<Vec<_>>();
577
578 let created = sqlx::query_as!(
579 UserExerciseState,
580 r#"
581 INSERT INTO user_exercise_states (user_id, exercise_id, course_id, exam_id)
582 SELECT UNNEST($1::uuid []), $2, $3, $4
583 RETURNING * "#,
584 &missing_user_ids,
585 exercise_id,
586 course_id,
587 exam_id
588 )
589 .fetch_all(&mut *conn)
590 .await?;
591
592 for item in created.into_iter() {
593 res.insert(item.user_id, item);
594 }
595 Ok(res)
596}
597
598pub async fn get_by_user_ids_and_exercise_id(
599 conn: &mut PgConnection,
600 user_ids: &[Uuid],
601 exercise_id: Uuid,
602) -> ModelResult<Vec<UserExerciseState>> {
603 let res = sqlx::query_as!(
604 UserExerciseState,
605 r#"
606SELECT *FROM user_exercise_states
607WHERE user_id = ANY($1)
608 AND exercise_id = $2
609 AND deleted_at IS NULL
610 "#,
611 user_ids,
612 exercise_id
613 )
614 .fetch_all(conn)
615 .await?;
616 Ok(res)
617}
618
619pub async fn get_by_course_id_and_user_ids_and_exercise_ids(
620 conn: &mut PgConnection,
621 course_id: Uuid,
622 user_ids: &[Uuid],
623 exercise_ids: &[Uuid],
624) -> ModelResult<Vec<UserExerciseState>> {
625 let res = sqlx::query_as!(
626 UserExerciseState,
627 r#"
628SELECT ues.id,
629 ues.user_id,
630 ues.exercise_id,
631 ues.course_id,
632 ues.exam_id,
633 ues.created_at,
634 ues.updated_at,
635 ues.deleted_at,
636 ues.score_given,
637 ues.grading_progress,
638 ues.activity_progress,
639 ues.reviewing_stage,
640 ues.selected_exercise_slide_id
641FROM user_exercise_states ues
642 JOIN exercises e ON e.id = ues.exercise_id
643WHERE ues.course_id = $1
644 AND e.course_id = $1
645 AND ues.user_id = ANY($2)
646 AND ues.exercise_id = ANY($3)
647 AND ues.deleted_at IS NULL
648 AND e.deleted_at IS NULL
649 "#,
650 course_id,
651 user_ids,
652 exercise_ids
653 )
654 .fetch_all(conn)
655 .await?;
656 Ok(res)
657}
658
659pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<UserExerciseState> {
660 let res = sqlx::query_as!(
661 UserExerciseState,
662 r#"
663SELECT *FROM user_exercise_states
664WHERE id = $1
665 AND deleted_at IS NULL
666 "#,
667 id,
668 )
669 .fetch_one(conn)
670 .await?;
671 Ok(res)
672}
673
674pub async fn recalculate_by_id_and_exercise_id(
675 conn: &mut PgConnection,
676 state_id: Uuid,
677 exercise_id: Uuid,
678) -> ModelResult<UserExerciseState> {
679 sqlx::query!(
680 r#"
681SELECT id
682FROM user_exercise_states
683WHERE id = $1
684 AND exercise_id = $2
685 AND deleted_at IS NULL
686 "#,
687 state_id,
688 exercise_id
689 )
690 .fetch_one(&mut *conn)
691 .await?;
692
693 crate::library::user_exercise_state_updater::update_user_exercise_state(conn, state_id).await
694}
695
696pub async fn get_by_ids(
697 conn: &mut PgConnection,
698 ids: &[Uuid],
699) -> ModelResult<Vec<UserExerciseState>> {
700 let res = sqlx::query_as!(
701 UserExerciseState,
702 r#"
703SELECT *FROM user_exercise_states
704WHERE id = ANY($1)
705AND deleted_at IS NULL
706"#,
707 &ids
708 )
709 .fetch_all(conn)
710 .await?;
711 Ok(res)
712}
713
714pub async fn get_user_total_course_points(
715 conn: &mut PgConnection,
716 user_id: Uuid,
717 course_id: Uuid,
718) -> ModelResult<Option<f32>> {
719 let res = sqlx::query!(
720 r#"
721SELECT SUM(score_given) AS "total_points"
722FROM user_exercise_states
723WHERE user_id = $1
724 AND course_id = $2
725 AND deleted_at IS NULL
726 GROUP BY user_id
727 "#,
728 user_id,
729 course_id,
730 )
731 .map(|x| x.total_points)
732 .fetch_one(conn)
733 .await?;
734 Ok(res)
735}
736
737pub async fn get_users_current_by_exercise(
738 conn: &mut PgConnection,
739 user_id: Uuid,
740 exercise: &Exercise,
741) -> ModelResult<UserExerciseState> {
742 let course_or_exam_id =
743 CourseOrExamId::from_course_and_exam_ids(exercise.course_id, exercise.exam_id)?;
744
745 let user_exercise_state =
746 get_user_exercise_state_if_exists(conn, user_id, exercise.id, course_or_exam_id)
747 .await?
748 .ok_or_else(|| {
749 ModelError::new(
750 ModelErrorType::PreconditionFailed,
751 "Missing user exercise state.".to_string(),
752 None,
753 )
754 })?;
755 Ok(user_exercise_state)
756}
757
758pub async fn get_user_exercise_state_if_exists(
759 conn: &mut PgConnection,
760 user_id: Uuid,
761 exercise_id: Uuid,
762 course_or_exam_id: CourseOrExamId,
763) -> ModelResult<Option<UserExerciseState>> {
764 let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
765 let res = sqlx::query_as!(
766 UserExerciseState,
767 r#"
768SELECT *FROM user_exercise_states
769WHERE user_id = $1
770 AND exercise_id = $2
771 AND (course_id = $3 OR exam_id = $4)
772 AND deleted_at IS NULL
773 "#,
774 user_id,
775 exercise_id,
776 course_id,
777 exam_id
778 )
779 .fetch_optional(conn)
780 .await?;
781 Ok(res)
782}
783
784pub async fn has_pending_manual_reviews_in_chapter(
786 conn: &mut PgConnection,
787 user_id: Uuid,
788 chapter_id: Uuid,
789) -> ModelResult<bool> {
790 struct PendingManualReviewsInChapterRow {
791 exists: bool,
792 }
793
794 let pending_manual_reviews = sqlx::query_as!(
795 PendingManualReviewsInChapterRow,
796 r#"
797SELECT EXISTS (
798 SELECT 1
799 FROM user_exercise_states ues
800 JOIN exercises e ON e.id = ues.exercise_id
801 WHERE ues.user_id = $1
802 AND e.chapter_id = $2
803 AND ues.reviewing_stage = 'waiting_for_manual_grading'::reviewing_stage
804 AND ues.deleted_at IS NULL
805 AND e.deleted_at IS NULL
806 ) as "exists!"
807 "#,
808 user_id,
809 chapter_id
810 )
811 .fetch_one(conn)
812 .await?
813 .exists;
814 Ok(pending_manual_reviews)
815}
816
817pub async fn get_all_for_user_and_course_or_exam(
818 conn: &mut PgConnection,
819 user_id: Uuid,
820 course_or_exam_id: CourseOrExamId,
821) -> ModelResult<Vec<UserExerciseState>> {
822 let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
823 let res = sqlx::query_as!(
824 UserExerciseState,
825 r#"
826SELECT *FROM user_exercise_states
827WHERE user_id = $1
828 AND (course_id = $2 OR exam_id = $3)
829 AND deleted_at IS NULL
830 "#,
831 user_id,
832 course_id,
833 exam_id
834 )
835 .fetch_all(conn)
836 .await?;
837 Ok(res)
838}
839
840pub async fn upsert_selected_exercise_slide_id(
841 conn: &mut PgConnection,
842 user_id: Uuid,
843 exercise_id: Uuid,
844 course_id: Option<Uuid>,
845 exam_id: Option<Uuid>,
846 selected_exercise_slide_id: Option<Uuid>,
847) -> ModelResult<()> {
848 let existing = sqlx::query!(
849 "
850SELECT
851FROM user_exercise_states
852WHERE user_id = $1
853 AND exercise_id = $2
854 AND (course_id = $3 OR exam_id = $4)
855 AND deleted_at IS NULL
856",
857 user_id,
858 exercise_id,
859 course_id,
860 exam_id
861 )
862 .fetch_optional(&mut *conn)
863 .await?;
864 if existing.is_some() {
865 sqlx::query!(
866 "
867UPDATE user_exercise_states
868SET selected_exercise_slide_id = $4
869WHERE user_id = $1
870 AND exercise_id = $2
871 AND (course_id = $3 OR exam_id = $5)
872 AND deleted_at IS NULL
873 ",
874 user_id,
875 exercise_id,
876 course_id,
877 selected_exercise_slide_id,
878 exam_id
879 )
880 .execute(&mut *conn)
881 .await?;
882 } else {
883 sqlx::query!(
884 "
885 INSERT INTO user_exercise_states (
886 user_id,
887 exercise_id,
888 course_id,
889 selected_exercise_slide_id,
890 exam_id
891 )
892 VALUES ($1, $2, $3, $4, $5)
893 ",
894 user_id,
895 exercise_id,
896 course_id,
897 selected_exercise_slide_id,
898 exam_id
899 )
900 .execute(&mut *conn)
901 .await?;
902 }
903 Ok(())
904}
905
906pub async fn update(
908 conn: &mut PgConnection,
909 user_exercise_state_update: UserExerciseStateUpdate,
910) -> ModelResult<UserExerciseState> {
911 let res = sqlx::query_as!(
912 UserExerciseState,
913 r#"
914UPDATE user_exercise_states
915SET score_given = $1,
916 activity_progress = $2,
917 reviewing_stage = $3,
918 grading_progress = $4
919WHERE id = $5
920 AND deleted_at IS NULL
921RETURNING * "#,
922 user_exercise_state_update.score_given,
923 user_exercise_state_update.activity_progress as ActivityProgress,
924 user_exercise_state_update.reviewing_stage as ReviewingStage,
925 user_exercise_state_update.grading_progress as GradingProgress,
926 user_exercise_state_update.id,
927 )
928 .fetch_one(conn)
929 .await?;
930 Ok(res)
931}
932
933pub async fn update_reviewing_stage(
934 conn: &mut PgConnection,
935 user_id: Uuid,
936 course_or_exam_id: CourseOrExamId,
937 exercise_id: Uuid,
938 new_reviewing_stage: ReviewingStage,
939) -> ModelResult<UserExerciseState> {
940 let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
941 let res = sqlx::query_as!(
942 UserExerciseState,
943 r#"
944UPDATE user_exercise_states
945SET reviewing_stage = $5
946WHERE user_id = $1
947AND (course_id = $2 OR exam_id = $3)
948AND exercise_id = $4
949RETURNING * "#,
950 user_id,
951 course_id,
952 exam_id,
953 exercise_id,
954 new_reviewing_stage as ReviewingStage
955 )
956 .fetch_one(conn)
957 .await?;
958 Ok(res)
959}
960
961pub async fn update_exercise_progress(
963 conn: &mut PgConnection,
964 id: Uuid,
965 reviewing_stage: ReviewingStage,
966) -> ModelResult<UserExerciseState> {
967 let res = sqlx::query_as!(
968 UserExerciseState,
969 r#"
970UPDATE user_exercise_states
971SET reviewing_stage = $1
972WHERE id = $2
973 AND deleted_at IS NULL
974RETURNING * "#,
975 reviewing_stage as ReviewingStage,
976 id
977 )
978 .fetch_one(conn)
979 .await?;
980 Ok(res)
981}
982
983pub struct ExerciseWithUserState {
988 exercise: Exercise,
989 user_exercise_state: UserExerciseState,
990 type_data: EwusCourseOrExam,
991}
992
993impl ExerciseWithUserState {
994 pub fn new(exercise: Exercise, user_exercise_state: UserExerciseState) -> ModelResult<Self> {
995 let state = EwusCourseOrExam::from_exercise_and_user_exercise_state(
996 &exercise,
997 &user_exercise_state,
998 )?;
999 Ok(Self {
1000 exercise,
1001 user_exercise_state,
1002 type_data: state,
1003 })
1004 }
1005
1006 pub fn exercise(&self) -> &Exercise {
1008 &self.exercise
1009 }
1010
1011 pub fn user_exercise_state(&self) -> &UserExerciseState {
1013 &self.user_exercise_state
1014 }
1015
1016 pub fn exercise_context(&self) -> &EwusCourseOrExam {
1017 &self.type_data
1018 }
1019
1020 pub fn set_user_exercise_state(
1021 &mut self,
1022 user_exercise_state: UserExerciseState,
1023 ) -> ModelResult<()> {
1024 self.type_data = EwusCourseOrExam::from_exercise_and_user_exercise_state(
1025 &self.exercise,
1026 &user_exercise_state,
1027 )?;
1028 self.user_exercise_state = user_exercise_state;
1029 Ok(())
1030 }
1031
1032 pub fn is_exam_exercise(&self) -> bool {
1033 match self.type_data {
1034 EwusCourseOrExam::Course(_) => false,
1035 EwusCourseOrExam::Exam(_) => true,
1036 }
1037 }
1038}
1039
1040pub struct EwusCourse {
1041 pub course_id: Uuid,
1042}
1043
1044pub struct EwusExam {
1045 pub exam_id: Uuid,
1046}
1047
1048pub enum EwusContext<C, E> {
1049 Course(C),
1050 Exam(E),
1051}
1052
1053pub enum EwusCourseOrExam {
1054 Course(EwusCourse),
1055 Exam(EwusExam),
1056}
1057
1058impl EwusCourseOrExam {
1059 pub fn from_exercise_and_user_exercise_state(
1060 exercise: &Exercise,
1061 user_exercise_state: &UserExerciseState,
1062 ) -> ModelResult<Self> {
1063 if exercise.id == user_exercise_state.exercise_id {
1064 let course_id = exercise.course_id;
1065 let exam_id = exercise.exam_id;
1066 match (course_id, exam_id) {
1067 (None, Some(exam_id)) => Ok(Self::Exam(EwusExam { exam_id })),
1068 (Some(course_id), None) => Ok(Self::Course(EwusCourse { course_id })),
1069 _ => Err(ModelError::new(
1070 ModelErrorType::Generic,
1071 "Invalid initializer data.".to_string(),
1072 None,
1073 )),
1074 }
1075 } else {
1076 Err(ModelError::new(
1077 ModelErrorType::Generic,
1078 "Exercise doesn't match the state.".to_string(),
1079 None,
1080 ))
1081 }
1082 }
1083}
1084
1085#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1086pub struct CourseUserPoints {
1087 pub user_id: Uuid,
1088 pub points_for_each_chapter: Vec<CourseUserPointsInner>,
1089}
1090
1091#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1092pub struct CourseUserPointsInner {
1093 pub chapter_number: i32,
1094 pub points_for_chapter: f32,
1095}
1096
1097#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1098pub struct ExamUserPoints {
1099 pub user_id: Uuid,
1100 pub email: String,
1101 pub points_for_exercise: Vec<ExamUserPointsInner>,
1102}
1103
1104#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1105pub struct ExamUserPointsInner {
1106 pub exercise_id: Uuid,
1107 pub score_given: f32,
1108}
1109
1110pub fn stream_course_points(
1111 conn: &mut PgConnection,
1112 course_id: Uuid,
1113) -> impl Stream<Item = sqlx::Result<CourseUserPoints>> + '_ {
1114 sqlx::query!(
1115 "
1116SELECT user_id,
1117 to_jsonb(array_agg(to_jsonb(uue) - 'email' - 'user_id')) AS points_for_each_chapter
1118FROM (
1119 SELECT ud.email,
1120 u.id AS user_id,
1121 c.chapter_number,
1122 COALESCE(SUM(ues.score_given), 0) AS points_for_chapter
1123 FROM user_exercise_states ues
1124 JOIN users u ON u.id = ues.user_id
1125 JOIN user_details ud ON ud.user_id = u.id
1126 JOIN exercises e ON e.id = ues.exercise_id
1127 JOIN chapters c on e.chapter_id = c.id
1128 WHERE ues.course_id = $1
1129 AND ues.deleted_at IS NULL
1130 AND c.deleted_at IS NULL
1131 AND u.deleted_at IS NULL
1132 AND e.deleted_at IS NULL
1133 GROUP BY ud.email,
1134 u.id,
1135 c.chapter_number
1136 ) as uue
1137GROUP BY user_id
1138
1139",
1140 course_id
1141 )
1142 .try_map(|i| {
1143 let user_id = i.user_id;
1144 let points_for_each_chapter = i.points_for_each_chapter.unwrap_or(Value::Null);
1145 serde_json::from_value(points_for_each_chapter)
1146 .map(|points_for_each_chapter| CourseUserPoints {
1147 user_id,
1148 points_for_each_chapter,
1149 })
1150 .map_err(|e| sqlx::Error::Decode(Box::new(e)))
1151 })
1152 .fetch(conn)
1153}
1154
1155pub fn stream_exam_points(
1156 conn: &mut PgConnection,
1157 exam_id: Uuid,
1158) -> impl Stream<Item = sqlx::Result<ExamUserPoints>> + '_ {
1159 sqlx::query!(
1160 "
1161SELECT user_id,
1162 email,
1163 to_jsonb(array_agg(to_jsonb(uue) - 'email' - 'user_id')) AS points_for_exercises
1164FROM (
1165 SELECT u.id AS user_id,
1166 ud.email,
1167 exercise_id,
1168 COALESCE(score_given, 0) as score_given
1169 FROM user_exercise_states ues
1170 JOIN users u ON u.id = ues.user_id
1171 JOIN user_details ud ON ud.user_id = u.id
1172 JOIN exercises e ON e.id = ues.exercise_id
1173 WHERE ues.exam_id = $1
1174 AND ues.deleted_at IS NULL
1175 AND u.deleted_at IS NULL
1176 AND e.deleted_at IS NULL
1177 ) as uue
1178GROUP BY user_id,
1179 email
1180",
1181 exam_id
1182 )
1183 .try_map(|i| {
1184 let user_id = i.user_id;
1185 let points_for_exercises = i.points_for_exercises.unwrap_or(Value::Null);
1186 serde_json::from_value(points_for_exercises)
1187 .map(|points_for_exercise| ExamUserPoints {
1188 user_id,
1189 points_for_exercise,
1190 email: i.email,
1191 })
1192 .map_err(|e| sqlx::Error::Decode(Box::new(e)))
1193 })
1194 .fetch(conn)
1195}
1196
1197pub async fn get_course_users_counts_by_exercise(
1198 conn: &mut PgConnection,
1199 course_id: Uuid,
1200) -> ModelResult<Vec<ExerciseUserCounts>> {
1201 let res = sqlx::query_as!(
1202 ExerciseUserCounts,
1203 r#"
1204SELECT exercises.name as exercise_name,
1205 exercises.order_number as exercise_order_number,
1206 pages.order_number as page_order_number,
1207 chapters.chapter_number,
1208 stat_data.*
1209FROM (
1210 SELECT exercise_id,
1211 COUNT(DISTINCT user_id) FILTER (
1212 WHERE ues.activity_progress = 'completed'
1213 ) as n_users_attempted,
1214 COUNT(DISTINCT user_id) FILTER (
1215 WHERE ues.score_given IS NOT NULL
1216 and ues.score_given > 0
1217 AND ues.activity_progress = 'completed'
1218 ) as n_users_with_some_points,
1219 COUNT(DISTINCT user_id) FILTER (
1220 WHERE ues.score_given IS NOT NULL
1221 and ues.score_given >= exercises.score_maximum
1222 and ues.activity_progress = 'completed'
1223 ) as n_users_with_max_points
1224 FROM exercises
1225 JOIN user_exercise_states ues on exercises.id = ues.exercise_id
1226 WHERE exercises.course_id = $1
1227 AND exercises.deleted_at IS NULL
1228 AND ues.deleted_at IS NULL
1229 GROUP BY exercise_id
1230 ) as stat_data
1231 JOIN exercises ON stat_data.exercise_id = exercises.id
1232 JOIN pages on exercises.page_id = pages.id
1233 JOIN chapters on pages.chapter_id = chapters.id
1234WHERE exercises.deleted_at IS NULL
1235 AND pages.deleted_at IS NULL
1236 AND chapters.deleted_at IS NULL
1237 "#,
1238 course_id
1239 )
1240 .fetch_all(conn)
1241 .await?;
1242 Ok(res)
1243}
1244
1245#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
1246
1247pub struct ExportedUserExerciseState {
1248 pub id: Uuid,
1249 pub user_id: Uuid,
1250 pub exercise_id: Uuid,
1251 pub course_id: Option<Uuid>,
1252 pub created_at: DateTime<Utc>,
1253 pub updated_at: DateTime<Utc>,
1254 pub score_given: Option<f32>,
1255 pub grading_progress: GradingProgress,
1256 pub activity_progress: ActivityProgress,
1257 pub reviewing_stage: ReviewingStage,
1258 pub selected_exercise_slide_id: Option<Uuid>,
1259}
1260
1261pub fn stream_user_exercise_states_for_course<'a>(
1262 conn: &'a mut PgConnection,
1263 course_ids: &'a [Uuid],
1264) -> impl Stream<Item = sqlx::Result<ExportedUserExerciseState>> + 'a {
1265 sqlx::query_as!(
1266 ExportedUserExerciseState,
1267 r#"
1268SELECT id,
1269 user_id,
1270 exercise_id,
1271 course_id,
1272 created_at,
1273 updated_at,
1274 score_given,
1275 grading_progress,
1276 activity_progress,
1277 reviewing_stage,
1278 selected_exercise_slide_id
1279FROM user_exercise_states
1280WHERE course_id = ANY($1)
1281 AND deleted_at IS NULL
1282 "#,
1283 course_ids
1284 )
1285 .fetch(conn)
1286}
1287
1288pub async fn get_all_for_course(
1289 conn: &mut PgConnection,
1290 course_id: Uuid,
1291) -> ModelResult<Vec<UserExerciseState>> {
1292 let res = sqlx::query_as!(
1293 UserExerciseState,
1294 r#"
1295SELECT *FROM user_exercise_states
1296WHERE course_id = $1
1297 AND deleted_at IS NULL
1298"#,
1299 course_id,
1300 )
1301 .fetch_all(&mut *conn)
1302 .await?;
1303 Ok(res)
1304}
1305
1306pub async fn get_returned_exercise_ids_for_user_and_course(
1307 conn: &mut PgConnection,
1308 exercise_ids: &[Uuid],
1309 user_id: Uuid,
1310 course_id: Uuid,
1311) -> ModelResult<Vec<Uuid>> {
1312 #[derive(sqlx::FromRow)]
1313 struct ExerciseIdRow {
1314 exercise_id: Uuid,
1315 }
1316
1317 let returned_exercise_ids: Vec<ExerciseIdRow> = sqlx::query_as::<_, ExerciseIdRow>(
1318 r#"
1319 SELECT DISTINCT exercise_id
1320 FROM user_exercise_states
1321 WHERE exercise_id = ANY($1::uuid[])
1322 AND user_id = $2
1323 AND course_id = $3
1324 AND deleted_at IS NULL
1325 AND activity_progress IN ('completed', 'submitted')
1326 "#,
1327 )
1328 .bind(exercise_ids)
1329 .bind(user_id)
1330 .bind(course_id)
1331 .fetch_all(conn)
1332 .await?;
1333
1334 Ok(returned_exercise_ids
1335 .into_iter()
1336 .map(|r| r.exercise_id)
1337 .collect())
1338}
1339
1340#[cfg(test)]
1341mod tests {
1342 use chrono::TimeZone;
1343
1344 use super::*;
1345 use crate::{
1346 chapters::NewChapter,
1347 exercise_slides, exercises,
1348 library::content_management::create_new_chapter,
1349 pages::{NewPage, insert_page},
1350 test_helper::*,
1351 };
1352
1353 mod getting_single_module_course_metrics {
1354 use super::*;
1355
1356 #[tokio::test]
1357 async fn works_without_any_user_exercise_states() {
1358 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1359 let res = get_single_module_metrics(tx.as_mut(), course, course_module.id, user).await;
1360 assert!(res.is_ok())
1361 }
1362 }
1363
1364 #[test]
1365 fn merges_course_modules_with_metrics() {
1366 let timestamp = Utc.with_ymd_and_hms(2022, 6, 22, 0, 0, 0).unwrap();
1367 let module_id = Uuid::parse_str("9e831ecc-9751-42f1-ae7e-9b2f06e523e8").unwrap();
1368 let course_modules = vec![
1369 CourseModule::new(
1370 module_id,
1371 Uuid::parse_str("3fa4bee6-7390-415e-968f-ecdc5f28330e").unwrap(),
1372 )
1373 .set_timestamps(timestamp, timestamp, None)
1374 .set_registration_info(None, Some(5.0), None, false),
1375 ];
1376 let course_metrics_by_course_module_id = HashMap::from([(
1377 module_id,
1378 CourseExerciseMetrics {
1379 course_module_id: module_id,
1380 total_exercises: Some(4),
1381 score_maximum: Some(10),
1382 },
1383 )]);
1384 let user_metrics_by_course_module_id = HashMap::from([(
1385 module_id,
1386 UserCourseMetrics {
1387 course_module_id: module_id,
1388 score_given: Some(1.0),
1389 attempted_exercises: Some(3),
1390 },
1391 )]);
1392 let metrics = merge_modules_with_metrics(
1393 course_modules,
1394 &course_metrics_by_course_module_id,
1395 &user_metrics_by_course_module_id,
1396 "Default module",
1397 )
1398 .unwrap();
1399 assert_eq!(metrics.len(), 1);
1400 let metric = metrics.first().unwrap();
1401 assert_eq!(metric.attempted_exercises, Some(3));
1402 assert_eq!(&metric.course_module_name, "Default module");
1403 assert_eq!(metric.score_given, 1.0);
1404 assert_eq!(metric.score_maximum, Some(10));
1405 assert_eq!(metric.total_exercises, Some(4));
1406 }
1407
1408 #[tokio::test]
1409 async fn get_user_course_progress_open_closed_chapters() {
1410 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, chapter: _chapter, page: _page);
1411 let (new_chapter, _) = create_new_chapter(
1415 tx.as_mut(),
1416 PKeyPolicy::Generate,
1417 &NewChapter {
1418 name: "best chapter 1".to_string(),
1419 color: None,
1420 course_id: course,
1421 chapter_number: 2,
1422 front_page_id: None,
1423 deadline: None,
1424 opens_at: Some(
1425 DateTime::parse_from_str(
1426 "2983 Apr 13 12:09:14 +0000",
1428 "%Y %b %d %H:%M:%S %z",
1429 )
1430 .unwrap()
1431 .to_utc(),
1432 ),
1433 course_module_id: Some(course_module.id),
1434 },
1435 user,
1436 |_, _, _| unimplemented!(),
1437 |_| unimplemented!(),
1438 )
1439 .await
1440 .unwrap();
1441
1442 let page = insert_page(
1444 tx.as_mut(),
1445 NewPage {
1446 exercises: vec![],
1447 exercise_slides: vec![],
1448 exercise_tasks: vec![],
1449 content: vec![],
1450 url_path: "/page1".to_string(),
1451 title: "title".to_string(),
1452 course_id: Some(course),
1453 exam_id: None,
1454 chapter_id: Some(new_chapter.id),
1455 front_page_of_chapter_id: Some(new_chapter.id),
1456 content_search_language: None,
1457 },
1458 user,
1459 |_, _, _| unimplemented!(),
1460 |_| unimplemented!(),
1461 )
1462 .await
1463 .unwrap();
1464 let ex = exercises::insert(
1465 tx.as_mut(),
1466 PKeyPolicy::Generate,
1467 course,
1468 "ex 1",
1469 page.id,
1470 new_chapter.id,
1471 1,
1472 )
1473 .await
1474 .unwrap();
1475 exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1476 .await
1477 .unwrap();
1478 let (new_chapter2, _) = create_new_chapter(
1480 tx.as_mut(),
1481 PKeyPolicy::Generate,
1482 &NewChapter {
1483 name: "best chapter 2".to_string(),
1484 color: None,
1485 course_id: course,
1486 chapter_number: 3,
1487 front_page_id: None,
1488 deadline: None,
1489 opens_at: Some(
1490 DateTime::parse_from_str(
1491 "1983 Apr 13 12:09:14 +0000",
1493 "%Y %b %d %H:%M:%S %z",
1494 )
1495 .unwrap()
1496 .to_utc(),
1497 ),
1498 course_module_id: Some(course_module.id),
1499 },
1500 user,
1501 |_, _, _| unimplemented!(),
1502 |_| unimplemented!(),
1503 )
1504 .await
1505 .unwrap();
1506
1507 let page2 = insert_page(
1509 tx.as_mut(),
1510 NewPage {
1511 exercises: vec![],
1512 exercise_slides: vec![],
1513 exercise_tasks: vec![],
1514 content: vec![],
1515 url_path: "/page2".to_string(),
1516 title: "title".to_string(),
1517 course_id: Some(course),
1518 exam_id: None,
1519 chapter_id: Some(new_chapter2.id),
1520 front_page_of_chapter_id: Some(new_chapter2.id),
1521 content_search_language: None,
1522 },
1523 user,
1524 |_, _, _| unimplemented!(),
1525 |_| unimplemented!(),
1526 )
1527 .await
1528 .unwrap();
1529 let ex = exercises::insert(
1530 tx.as_mut(),
1531 PKeyPolicy::Generate,
1532 course,
1533 "ex 1",
1534 page2.id,
1535 new_chapter2.id,
1536 1,
1537 )
1538 .await
1539 .unwrap();
1540 exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1541 .await
1542 .unwrap();
1543
1544 let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1546 .await
1547 .unwrap();
1548 let progress_open_chapters = get_user_course_progress(tx.as_mut(), course, user, true)
1550 .await
1551 .unwrap();
1552
1553 assert_ne!(progress_all, progress_open_chapters);
1554 assert_eq!(progress_all.len(), 2);
1555 assert_eq!(progress_open_chapters.len(), 1);
1556 assert_eq!(
1557 progress_all[1].course_module_id,
1558 progress_open_chapters[0].course_module_id
1559 );
1560 assert_eq!(progress_all[1].total_exercises, Some(2));
1561 assert_eq!(progress_open_chapters[0].total_exercises, Some(1));
1562 }
1563
1564 #[tokio::test]
1565 async fn get_user_course_progress_filter_out_closed_module() {
1566 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module);
1567 let (new_chapter, _) = create_new_chapter(
1570 tx.as_mut(),
1571 PKeyPolicy::Generate,
1572 &NewChapter {
1573 name: "best chapter".to_string(),
1574 color: None,
1575 course_id: course,
1576 chapter_number: 2,
1577 front_page_id: None,
1578 deadline: None,
1579 opens_at: Some(
1580 DateTime::parse_from_str(
1581 "2983 Apr 13 12:09:14 +0000",
1583 "%Y %b %d %H:%M:%S %z",
1584 )
1585 .unwrap()
1586 .to_utc(),
1587 ),
1588 course_module_id: Some(course_module.id),
1589 },
1590 user,
1591 |_, _, _| unimplemented!(),
1592 |_| unimplemented!(),
1593 )
1594 .await
1595 .unwrap();
1596
1597 let page = insert_page(
1599 tx.as_mut(),
1600 NewPage {
1601 exercises: vec![],
1602 exercise_slides: vec![],
1603 exercise_tasks: vec![],
1604 content: vec![],
1605 url_path: "/page2".to_string(),
1606 title: "title".to_string(),
1607 course_id: Some(course),
1608 exam_id: None,
1609 chapter_id: Some(new_chapter.id),
1610 front_page_of_chapter_id: Some(new_chapter.id),
1611 content_search_language: None,
1612 },
1613 user,
1614 |_, _, _| unimplemented!(),
1615 |_| unimplemented!(),
1616 )
1617 .await
1618 .unwrap();
1619 let ex = exercises::insert(
1620 tx.as_mut(),
1621 PKeyPolicy::Generate,
1622 course,
1623 "ex 1",
1624 page.id,
1625 new_chapter.id,
1626 1,
1627 )
1628 .await
1629 .unwrap();
1630 exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, ex, 1)
1631 .await
1632 .unwrap();
1633
1634 let progress_all = get_user_course_progress(tx.as_mut(), course, user, false)
1637 .await
1638 .unwrap();
1639 let progress_open_chapters_modules =
1641 get_user_course_progress(tx.as_mut(), course, user, true)
1642 .await
1643 .unwrap();
1644
1645 assert_ne!(progress_all, progress_open_chapters_modules);
1646 assert_eq!(progress_all.len(), 2);
1647 assert_eq!(progress_open_chapters_modules.len(), 0);
1648 assert_eq!(progress_all[1].total_exercises, Some(1));
1649 }
1650
1651 #[tokio::test]
1652 async fn has_pending_manual_reviews_in_chapter_reflects_review_state() {
1653 insert_data!(
1654 :tx,
1655 :user,
1656 :org,
1657 :course,
1658 instance: _instance,
1659 :course_module,
1660 chapter: chapter_id,
1661 page: _page_id,
1662 exercise: exercise_id,
1663 slide: _exercise_slide_id,
1664 task: _exercise_task_id
1665 );
1666
1667 exercises::update_teacher_reviews_answer_after_locking(tx.as_mut(), exercise_id, true)
1668 .await
1669 .unwrap();
1670 get_or_create_user_exercise_state(tx.as_mut(), user, exercise_id, Some(course), None)
1671 .await
1672 .unwrap();
1673
1674 update_reviewing_stage(
1675 tx.as_mut(),
1676 user,
1677 CourseOrExamId::Course(course),
1678 exercise_id,
1679 ReviewingStage::WaitingForManualGrading,
1680 )
1681 .await
1682 .unwrap();
1683
1684 let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1685 .await
1686 .unwrap();
1687 assert!(has_pending);
1688
1689 update_reviewing_stage(
1690 tx.as_mut(),
1691 user,
1692 CourseOrExamId::Course(course),
1693 exercise_id,
1694 ReviewingStage::ReviewedAndLocked,
1695 )
1696 .await
1697 .unwrap();
1698
1699 let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1700 .await
1701 .unwrap();
1702 assert!(!has_pending);
1703 }
1704
1705 #[tokio::test]
1706 async fn has_pending_manual_reviews_in_chapter_counts_self_review_manual_flows() {
1707 insert_data!(
1708 :tx,
1709 :user,
1710 :org,
1711 :course,
1712 instance: _instance,
1713 :course_module,
1714 chapter: chapter_id,
1715 page: _page_id,
1716 exercise: exercise_id,
1717 slide: _exercise_slide_id,
1718 task: _exercise_task_id
1719 );
1720
1721 exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
1722 tx.as_mut(),
1723 exercise_id,
1724 false,
1725 true,
1726 false,
1727 )
1728 .await
1729 .unwrap();
1730 get_or_create_user_exercise_state(tx.as_mut(), user, exercise_id, Some(course), None)
1731 .await
1732 .unwrap();
1733
1734 update_reviewing_stage(
1735 tx.as_mut(),
1736 user,
1737 CourseOrExamId::Course(course),
1738 exercise_id,
1739 ReviewingStage::WaitingForManualGrading,
1740 )
1741 .await
1742 .unwrap();
1743
1744 let has_pending = has_pending_manual_reviews_in_chapter(tx.as_mut(), user, chapter_id)
1745 .await
1746 .unwrap();
1747 assert!(has_pending);
1748 }
1749}