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