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