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