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