1use derive_more::Display;
2use futures::future::BoxFuture;
3use itertools::Itertools;
4use tracing::info;
5use url::Url;
6use utoipa::ToSchema;
7
8use crate::{
9 exams, exercise_reset_logs,
10 exercise_service_info::ExerciseServiceInfoApi,
11 exercise_slide_submissions::{
12 ExerciseSlideSubmission, get_exercise_slide_submission_counts_for_exercise_user,
13 },
14 exercise_slides::{self, CourseMaterialExerciseSlide},
15 exercise_tasks,
16 peer_or_self_review_configs::CourseMaterialPeerOrSelfReviewConfig,
17 peer_or_self_review_question_submissions::PeerOrSelfReviewQuestionSubmission,
18 peer_or_self_review_questions::PeerOrSelfReviewQuestion,
19 peer_or_self_review_submissions::PeerOrSelfReviewSubmissionWithSubmissionOwner,
20 peer_review_queue_entries::PeerReviewQueueEntry,
21 prelude::*,
22 teacher_grading_decisions::{TeacherDecisionType, TeacherGradingDecision},
23 user_chapter_locking_statuses,
24 user_course_exercise_service_variables::UserCourseExerciseServiceVariable,
25 user_course_settings,
26 user_exercise_states::{self, ReviewingStage, UserExerciseState},
27};
28use std::collections::{HashMap, HashSet};
29
30#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
31
32pub struct Exercise {
33 pub id: Uuid,
34 pub created_at: DateTime<Utc>,
35 pub updated_at: DateTime<Utc>,
36 pub name: String,
37 pub course_id: Option<Uuid>,
38 pub exam_id: Option<Uuid>,
39 pub page_id: Uuid,
40 pub chapter_id: Option<Uuid>,
41 pub deadline: Option<DateTime<Utc>>,
42 pub deleted_at: Option<DateTime<Utc>>,
43 pub score_maximum: i32,
44 pub order_number: i32,
45 pub copied_from: Option<Uuid>,
46 pub max_tries_per_slide: Option<i32>,
47 pub limit_number_of_tries: bool,
48 pub needs_peer_review: bool,
49 pub needs_self_review: bool,
50 pub use_course_default_peer_or_self_review_config: bool,
51 pub exercise_language_group_id: Option<Uuid>,
52 pub teacher_reviews_answer_after_locking: bool,
53}
54
55impl Exercise {
56 pub fn get_course_id(&self) -> ModelResult<Uuid> {
57 self.course_id.ok_or_else(|| {
58 ModelError::new(
59 ModelErrorType::Generic,
60 "Exercise is not related to a course.".to_string(),
61 None,
62 )
63 })
64 }
65}
66
67#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
68
69pub struct ExerciseGradingStatus {
70 pub exercise_id: Uuid,
71 pub exercise_name: String,
72 pub score_maximum: i32,
73 pub score_given: Option<f32>,
74 pub teacher_decision: Option<TeacherDecisionType>,
75 pub submission_id: Uuid,
76 pub updated_at: DateTime<Utc>,
77}
78
79#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
80
81pub struct ExerciseStatusSummaryForUser {
82 pub exercise: Exercise,
83 pub user_exercise_state: Option<UserExerciseState>,
84 pub exercise_slide_submissions: Vec<ExerciseSlideSubmission>,
85 pub given_peer_or_self_review_submissions: Vec<PeerOrSelfReviewSubmissionWithSubmissionOwner>,
86 pub given_peer_or_self_review_question_submissions: Vec<PeerOrSelfReviewQuestionSubmission>,
87 pub received_peer_or_self_review_submissions:
88 Vec<PeerOrSelfReviewSubmissionWithSubmissionOwner>,
89 pub received_peer_or_self_review_question_submissions: Vec<PeerOrSelfReviewQuestionSubmission>,
90 pub peer_review_queue_entry: Option<PeerReviewQueueEntry>,
91 pub teacher_grading_decision: Option<TeacherGradingDecision>,
92 pub peer_or_self_review_questions: Vec<PeerOrSelfReviewQuestion>,
93}
94
95#[derive(Debug, Serialize, Deserialize, ToSchema)]
96
97pub struct CourseMaterialExercise {
98 pub exercise: Exercise,
99 pub can_post_submission: bool,
100 pub current_exercise_slide: CourseMaterialExerciseSlide,
101 pub exercise_status: Option<ExerciseStatus>,
103 pub exercise_slide_submission_counts: HashMap<Uuid, i64>,
104 pub peer_or_self_review_config: Option<CourseMaterialPeerOrSelfReviewConfig>,
105 pub previous_exercise_slide_submission: Option<ExerciseSlideSubmission>,
106 pub user_course_instance_exercise_service_variables: Vec<UserCourseExerciseServiceVariable>,
107 pub should_show_reset_message: Option<String>,
108 pub teacher_grading_decision: Option<CourseMaterialTeacherGradingDecision>,
110}
111
112#[derive(Debug, Serialize, Deserialize, ToSchema)]
117pub struct CourseMaterialTeacherGradingDecision {
118 pub teacher_decision: TeacherDecisionType,
119 pub justification: Option<String>,
120}
121
122impl CourseMaterialExercise {
123 pub fn clear_grading_information(&mut self) {
124 self.exercise_status = None;
125 self.teacher_grading_decision = None;
126 self.current_exercise_slide
127 .exercise_tasks
128 .iter_mut()
129 .for_each(|task| {
130 task.model_solution_spec = None;
131 task.previous_submission_grading = None;
132 });
133 }
134
135 pub fn clear_model_solution_specs(&mut self) {
136 self.current_exercise_slide
137 .exercise_tasks
138 .iter_mut()
139 .for_each(|task| {
140 task.model_solution_spec = None;
141 });
142 }
143}
144
145#[derive(
151 Debug,
152 Serialize,
153 Deserialize,
154 PartialEq,
155 Eq,
156 Clone,
157 Copy,
158 Default,
159 Display,
160 sqlx::Type,
161 ToSchema,
162)]
163#[sqlx(type_name = "activity_progress", rename_all = "kebab-case")]
164pub enum ActivityProgress {
165 #[default]
167 Initialized,
168 Started,
170 InProgress,
172 Submitted,
174 Completed,
176}
177
178#[derive(
185 Clone,
186 Copy,
187 Debug,
188 Deserialize,
189 Eq,
190 Serialize,
191 Ord,
192 PartialEq,
193 PartialOrd,
194 Display,
195 sqlx::Type,
196 ToSchema,
197)]
198#[sqlx(type_name = "grading_progress", rename_all = "kebab-case")]
199pub enum GradingProgress {
200 Failed,
202 NotReady,
204 PendingManual,
206 Pending,
208 FullyGraded,
210}
211
212impl GradingProgress {
213 pub fn is_complete(self) -> bool {
214 self == Self::FullyGraded || self == Self::Failed
215 }
216}
217
218#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
219
220pub struct ExerciseStatus {
221 pub score_given: Option<f32>,
223 pub activity_progress: ActivityProgress,
224 pub grading_progress: GradingProgress,
225 pub reviewing_stage: ReviewingStage,
226}
227
228#[allow(clippy::too_many_arguments)]
229pub async fn insert(
230 conn: &mut PgConnection,
231 pkey_policy: PKeyPolicy<Uuid>,
232 course_id: Uuid,
233 name: &str,
234 page_id: Uuid,
235 chapter_id: Uuid,
236 order_number: i32,
237) -> ModelResult<Uuid> {
238 let course = crate::courses::get_course(conn, course_id).await?;
239 let exercise_language_group_id = crate::exercise_language_groups::insert(
240 conn,
241 PKeyPolicy::Generate,
242 course.course_language_group_id,
243 )
244 .await?;
245
246 let res = sqlx::query!(
247 "
248INSERT INTO exercises (
249 id,
250 course_id,
251 name,
252 page_id,
253 chapter_id,
254 order_number,
255 exercise_language_group_id
256 )
257VALUES ($1, $2, $3, $4, $5, $6, $7)
258RETURNING *
259 ",
260 pkey_policy.into_uuid(),
261 course_id,
262 name,
263 page_id,
264 chapter_id,
265 order_number,
266 exercise_language_group_id,
267 )
268 .fetch_one(conn)
269 .await?;
270 Ok(res.id)
271}
272
273pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
274 let exercise = sqlx::query_as!(
275 Exercise,
276 "
277SELECT *
278FROM exercises
279WHERE id = $1
280",
281 id
282 )
283 .fetch_one(conn)
284 .await?;
285 Ok(exercise)
286}
287
288pub async fn get_non_deleted_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
289 let exercise = sqlx::query_as!(
290 Exercise,
291 "
292SELECT *
293FROM exercises
294WHERE id = $1
295 AND deleted_at IS NULL
296",
297 id
298 )
299 .fetch_one(conn)
300 .await?;
301 Ok(exercise)
302}
303
304pub async fn get_non_deleted_by_ids(
305 conn: &mut PgConnection,
306 ids: &[Uuid],
307) -> ModelResult<Vec<Exercise>> {
308 let exercises = sqlx::query_as!(
309 Exercise,
310 "
311SELECT *
312FROM exercises
313WHERE id = ANY($1)
314 AND deleted_at IS NULL
315",
316 ids
317 )
318 .fetch_all(conn)
319 .await?;
320 Ok(exercises)
321}
322
323pub async fn get_exercise_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Exercise> {
324 let exercise = sqlx::query_as!(Exercise, "SELECT * FROM exercises WHERE id = $1;", id)
325 .fetch_one(conn)
326 .await?;
327 Ok(exercise)
328}
329
330pub async fn get_exercises_by_course_id(
331 conn: &mut PgConnection,
332 course_id: Uuid,
333) -> ModelResult<Vec<Exercise>> {
334 let exercises = sqlx::query_as!(
335 Exercise,
336 r#"
337SELECT *
338FROM exercises
339WHERE course_id = $1
340 AND deleted_at IS NULL
341"#,
342 course_id
343 )
344 .fetch_all(&mut *conn)
345 .await?;
346 Ok(exercises)
347}
348
349pub async fn get_exercise_submissions_and_status_by_course_instance_id(
350 conn: &mut PgConnection,
351 course_instance_id: Uuid,
352 user_id: Uuid,
353) -> ModelResult<Vec<ExerciseGradingStatus>> {
354 let exercises = sqlx::query_as!(
355 ExerciseGradingStatus,
356 r#"
357 SELECT
358 e.id as exercise_id,
359 e.name as exercise_name,
360 e.score_maximum,
361 ues.score_given,
362 tgd.teacher_decision,
363 ess.id as submission_id,
364 ess.updated_at
365 FROM exercises e
366 LEFT JOIN user_exercise_states ues on e.id = ues.exercise_id
367 LEFT JOIN teacher_grading_decisions tgd on tgd.user_exercise_state_id = ues.id
368 LEFT JOIN exercise_slide_submissions ess on e.id = ess.exercise_id
369 WHERE e.course_id = (
370 SELECT course_id
371 FROM course_instances
372 WHERE id = $1
373 )
374 AND e.deleted_at IS NULL
375 AND ess.user_id = $2
376 AND ues.user_id = $2
377 ORDER BY e.order_number ASC;
378"#,
379 course_instance_id,
380 user_id
381 )
382 .fetch_all(conn)
383 .await?;
384 Ok(exercises)
385}
386
387pub async fn get_exercises_by_chapter_id(
388 conn: &mut PgConnection,
389 chapter_id: Uuid,
390) -> ModelResult<Vec<Exercise>> {
391 let exercises = sqlx::query_as!(
392 Exercise,
393 r#"
394SELECT *
395FROM exercises
396WHERE chapter_id = $1
397 AND deleted_at IS NULL
398"#,
399 chapter_id
400 )
401 .fetch_all(&mut *conn)
402 .await?;
403 Ok(exercises)
404}
405
406pub async fn get_exercises_by_chapter_ids(
407 conn: &mut PgConnection,
408 chapter_ids: &[Uuid],
409) -> ModelResult<Vec<Exercise>> {
410 if chapter_ids.is_empty() {
411 return Ok(Vec::new());
412 }
413 let exercises = sqlx::query_as!(
414 Exercise,
415 r#"
416SELECT *
417FROM exercises
418WHERE chapter_id = ANY($1)
419 AND deleted_at IS NULL
420"#,
421 chapter_ids as &[Uuid]
422 )
423 .fetch_all(&mut *conn)
424 .await?;
425 Ok(exercises)
426}
427
428pub async fn get_exercises_by_page_id(
429 conn: &mut PgConnection,
430 page_id: Uuid,
431) -> ModelResult<Vec<Exercise>> {
432 let exercises = sqlx::query_as!(
433 Exercise,
434 r#"
435SELECT *
436 FROM exercises
437WHERE page_id = $1
438 AND deleted_at IS NULL;
439"#,
440 page_id,
441 )
442 .fetch_all(&mut *conn)
443 .await?;
444 Ok(exercises)
445}
446
447pub async fn get_exercises_by_exam_id(
448 conn: &mut PgConnection,
449 exam_id: Uuid,
450) -> ModelResult<Vec<Exercise>> {
451 let exercises = sqlx::query_as!(
452 Exercise,
453 r#"
454SELECT *
455FROM exercises
456WHERE exam_id = $1
457 AND deleted_at IS NULL
458"#,
459 exam_id,
460 )
461 .fetch_all(&mut *conn)
462 .await?;
463 Ok(exercises)
464}
465
466pub async fn get_course_or_exam_id(
467 conn: &mut PgConnection,
468 id: Uuid,
469) -> ModelResult<CourseOrExamId> {
470 let res = sqlx::query!(
471 "
472SELECT course_id,
473 exam_id
474FROM exercises
475WHERE id = $1
476",
477 id
478 )
479 .fetch_one(conn)
480 .await?;
481 CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
482}
483
484pub async fn get_course_material_exercise(
485 conn: &mut PgConnection,
486 user_id: Option<Uuid>,
487 exercise_id: Uuid,
488 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
489 file_store: &dyn FileStore,
490 app_conf: &ApplicationConfiguration,
491) -> ModelResult<CourseMaterialExercise> {
492 let mut exercise = get_by_id(conn, exercise_id).await?;
493 if exercise.deadline.is_none()
494 && let Some(chapter_id) = exercise.chapter_id
495 {
496 let chapter = crate::chapters::get_chapter(conn, chapter_id).await?;
497 exercise.deadline = chapter.deadline;
498 }
499 let (current_exercise_slide, instance_or_exam_id) = get_or_select_exercise_slide(
500 &mut *conn,
501 user_id,
502 &exercise,
503 fetch_service_info,
504 file_store,
505 app_conf,
506 )
507 .await?;
508 info!(
509 "Current exercise slide id: {:#?}",
510 current_exercise_slide.id
511 );
512
513 let user_exercise_state = match (user_id, instance_or_exam_id) {
514 (Some(user_id), Some(course_or_exam_id)) => {
515 user_exercise_states::get_user_exercise_state_if_exists(
516 conn,
517 user_id,
518 exercise.id,
519 course_or_exam_id,
520 )
521 .await?
522 }
523 _ => None,
524 };
525
526 let teacher_grading_decision = if let Some(user_id) = user_id {
527 let decision = crate::teacher_grading_decisions::try_to_get_latest_grading_decision_still_addressed_to_student(
528 &mut *conn, user_id, exercise.id,
529 )
530 .await?;
531 decision.and_then(|d| {
532 if d.teacher_decision != TeacherDecisionType::FullPoints && d.hidden != Some(true) {
533 Some(CourseMaterialTeacherGradingDecision {
534 teacher_decision: d.teacher_decision,
535 justification: d.justification,
536 })
537 } else {
538 None
539 }
540 })
541 } else {
542 None
543 };
544
545 let can_post_submission =
546 determine_can_post_submission(&mut *conn, user_id, &exercise, &user_exercise_state).await?;
547
548 let previous_exercise_slide_submission = match user_id {
549 Some(user_id) => {
550 crate::exercise_slide_submissions::try_to_get_users_latest_exercise_slide_submission(
551 conn,
552 current_exercise_slide.id,
553 user_id,
554 )
555 .await?
556 }
557 _ => None,
558 };
559
560 let exercise_status = user_exercise_state.map(|user_exercise_state| ExerciseStatus {
561 score_given: user_exercise_state.score_given,
562 activity_progress: user_exercise_state.activity_progress,
563 grading_progress: user_exercise_state.grading_progress,
564 reviewing_stage: user_exercise_state.reviewing_stage,
565 });
566
567 let exercise_slide_submission_counts = if let Some(user_id) = user_id {
568 if let Some(cioreid) = instance_or_exam_id {
569 get_exercise_slide_submission_counts_for_exercise_user(
570 conn,
571 exercise_id,
572 cioreid,
573 user_id,
574 )
575 .await?
576 } else {
577 HashMap::new()
578 }
579 } else {
580 HashMap::new()
581 };
582
583 let peer_or_self_review_config = if let Some(course_id) = exercise.course_id {
584 if exercise.needs_peer_review || exercise.needs_self_review {
585 let prc = crate::peer_or_self_review_configs::get_by_exercise_or_course_id(
586 conn, &exercise, course_id,
587 )
588 .await
589 .optional()?;
590 prc.map(|prc| CourseMaterialPeerOrSelfReviewConfig {
591 id: prc.id,
592 course_id: prc.course_id,
593 exercise_id: prc.exercise_id,
594 peer_reviews_to_give: prc.peer_reviews_to_give,
595 peer_reviews_to_receive: prc.peer_reviews_to_receive,
596 })
597 } else {
598 None
599 }
600 } else {
601 None
602 };
603
604 let user_course_instance_exercise_service_variables = match (user_id, instance_or_exam_id) {
605 (Some(user_id), Some(course_or_exam_id)) => {
606 Some(crate::user_course_exercise_service_variables::get_all_variables_for_user_and_course_or_exam(conn, user_id, course_or_exam_id).await?)
607 }
608 _ => None,
609 }.unwrap_or_default();
610
611 let should_show_reset_message = if let Some(user_id) = user_id {
612 crate::exercise_reset_logs::user_should_see_reset_message_for_exercise(
613 conn,
614 user_id,
615 exercise_id,
616 )
617 .await?
618 } else {
619 None
620 };
621
622 Ok(CourseMaterialExercise {
623 exercise,
624 can_post_submission,
625 current_exercise_slide,
626 exercise_status,
627 exercise_slide_submission_counts,
628 peer_or_self_review_config,
629 user_course_instance_exercise_service_variables,
630 previous_exercise_slide_submission,
631 should_show_reset_message,
632 teacher_grading_decision,
633 })
634}
635
636async fn determine_can_post_submission(
637 conn: &mut PgConnection,
638 user_id: Option<Uuid>,
639 exercise: &Exercise,
640 user_exercise_state: &Option<UserExerciseState>,
641) -> Result<bool, ModelError> {
642 if let Some(user_exercise_state) = user_exercise_state {
643 if user_exercise_state.reviewing_stage != ReviewingStage::NotStarted {
645 return Ok(false);
646 }
647 }
648
649 let can_post_submission = if let Some(user_id) = user_id {
650 if let Some(exam_id) = exercise.exam_id {
651 exams::verify_exam_submission_can_be_made(conn, exam_id, user_id).await?
652 } else {
653 true
654 }
655 } else {
656 false
657 };
658 Ok(can_post_submission)
659}
660
661pub async fn get_or_select_exercise_slide(
662 conn: &mut PgConnection,
663 user_id: Option<Uuid>,
664 exercise: &Exercise,
665 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
666 file_store: &dyn FileStore,
667 app_conf: &ApplicationConfiguration,
668) -> ModelResult<(CourseMaterialExerciseSlide, Option<CourseOrExamId>)> {
669 match (user_id, exercise.course_id, exercise.exam_id) {
670 (None, ..) => {
671 let random_slide =
673 exercise_slides::get_random_exercise_slide_for_exercise(conn, exercise.id).await?;
674 let random_slide_tasks = exercise_tasks::get_course_material_exercise_tasks(
675 conn,
676 random_slide.id,
677 None,
678 fetch_service_info,
679 file_store,
680 app_conf,
681 )
682 .await?;
683 Ok((
684 CourseMaterialExerciseSlide {
685 id: random_slide.id,
686 exercise_tasks: random_slide_tasks,
687 },
688 None,
689 ))
690 }
691 (Some(user_id), Some(course_id), None) => {
692 let user_course_settings = user_course_settings::get_user_course_settings_by_course_id(
694 conn, user_id, course_id,
695 )
696 .await?;
697 match user_course_settings {
698 Some(settings) if settings.current_course_id == course_id => {
699 let course_or_exam_id: CourseOrExamId = exercise.try_into()?;
701 let tasks =
702 exercise_tasks::get_or_select_user_exercise_slide_for_course_or_exam(
703 conn,
704 user_id,
705 exercise.id,
706 course_or_exam_id,
707 fetch_service_info,
708 file_store,
709 app_conf,
710 )
711 .await?;
712 Ok((tasks, Some(CourseOrExamId::Course(course_id))))
713 }
714 Some(_) => {
715 let exercise_tasks =
718 exercise_tasks::get_existing_users_exercise_slide_for_course(
719 conn,
720 user_id,
721 exercise.id,
722 course_id,
723 &fetch_service_info,
724 file_store,
725 app_conf,
726 )
727 .await?;
728 if let Some(exercise_tasks) = exercise_tasks {
729 Ok((exercise_tasks, Some(CourseOrExamId::Course(course_id))))
730 } else {
731 let random_slide = exercise_slides::get_random_exercise_slide_for_exercise(
733 conn,
734 exercise.id,
735 )
736 .await?;
737 let random_tasks = exercise_tasks::get_course_material_exercise_tasks(
738 conn,
739 random_slide.id,
740 Some(user_id),
741 &fetch_service_info,
742 file_store,
743 app_conf,
744 )
745 .await?;
746
747 Ok((
748 CourseMaterialExerciseSlide {
749 id: random_slide.id,
750 exercise_tasks: random_tasks,
751 },
752 None,
753 ))
754 }
755 }
756 None => {
757 Err(ModelError::new(
760 ModelErrorType::PreconditionFailed,
761 "User must be enrolled to the course".to_string(),
762 None,
763 ))
764 }
765 }
766 }
767 (Some(user_id), _, Some(exam_id)) => {
768 info!("selecting exam task");
769 let tasks = exercise_tasks::get_or_select_user_exercise_slide_for_course_or_exam(
771 conn,
772 user_id,
773 exercise.id,
774 CourseOrExamId::Exam(exam_id),
775 fetch_service_info,
776 file_store,
777 app_conf,
778 )
779 .await?;
780 info!("selecting exam task {:#?}", tasks);
781 Ok((tasks, Some(CourseOrExamId::Exam(exam_id))))
782 }
783 (Some(_), ..) => Err(ModelError::new(
784 ModelErrorType::Generic,
785 "The selected exercise is not attached to any course or exam".to_string(),
786 None,
787 )),
788 }
789}
790
791pub async fn delete_exercises_by_page_id(
792 conn: &mut PgConnection,
793 page_id: Uuid,
794) -> ModelResult<Vec<Uuid>> {
795 let deleted_ids = sqlx::query!(
796 "
797UPDATE exercises
798SET deleted_at = now()
799WHERE page_id = $1
800AND deleted_at IS NULL
801RETURNING *;
802 ",
803 page_id
804 )
805 .fetch_all(conn)
806 .await?
807 .into_iter()
808 .map(|x| x.id)
809 .collect();
810 Ok(deleted_ids)
811}
812
813pub async fn update_teacher_reviews_answer_after_locking(
814 conn: &mut PgConnection,
815 exercise_id: Uuid,
816 teacher_reviews_answer_after_locking: bool,
817) -> ModelResult<Exercise> {
818 let exercise = sqlx::query_as!(
819 Exercise,
820 r#"
821UPDATE exercises
822SET teacher_reviews_answer_after_locking = $2
823WHERE id = $1
824 AND deleted_at IS NULL
825RETURNING *
826 "#,
827 exercise_id,
828 teacher_reviews_answer_after_locking
829 )
830 .fetch_one(conn)
831 .await?;
832
833 Ok(exercise)
834}
835
836pub async fn set_exercise_to_use_exercise_specific_peer_or_self_review_config(
837 conn: &mut PgConnection,
838 exercise_id: Uuid,
839 needs_peer_review: bool,
840 needs_self_review: bool,
841 use_course_default_peer_or_self_review_config: bool,
842) -> ModelResult<Uuid> {
843 let id = sqlx::query!(
844 "
845UPDATE exercises
846SET use_course_default_peer_or_self_review_config = $1,
847 needs_peer_review = $2,
848 needs_self_review = $3
849WHERE id = $4
850RETURNING *;
851 ",
852 use_course_default_peer_or_self_review_config,
853 needs_peer_review,
854 needs_self_review,
855 exercise_id
856 )
857 .fetch_one(conn)
858 .await?;
859
860 Ok(id.id)
861}
862
863pub async fn get_all_exercise_statuses_by_user_id_and_course_id(
864 conn: &mut PgConnection,
865 course_id: Uuid,
866 user_id: Uuid,
867) -> ModelResult<Vec<ExerciseStatusSummaryForUser>> {
868 let course_or_exam_id = CourseOrExamId::Course(course_id);
869 let exercises = crate::exercises::get_exercises_by_course_id(&mut *conn, course_id).await?;
871 let mut user_exercise_states =
872 crate::user_exercise_states::get_all_for_user_and_course_or_exam(
873 &mut *conn,
874 user_id,
875 course_or_exam_id,
876 )
877 .await?
878 .into_iter()
879 .map(|ues| (ues.exercise_id, ues))
880 .collect::<HashMap<_, _>>();
881 let mut exercise_slide_submissions =
882 crate::exercise_slide_submissions::get_users_all_submissions_for_course_or_exam(
883 &mut *conn,
884 user_id,
885 course_or_exam_id,
886 )
887 .await?
888 .into_iter()
889 .into_group_map_by(|o| o.exercise_id);
890 let mut given_peer_or_self_review_submissions = crate::peer_or_self_review_submissions::get_all_given_peer_or_self_review_submissions_for_user_and_course(&mut *conn, user_id, course_id).await?.into_iter()
891 .into_group_map_by(|o| o.exercise_id);
892 let given_submission_ids: Vec<Uuid> = given_peer_or_self_review_submissions
893 .values()
894 .flatten()
895 .map(|prs| prs.exercise_slide_submission_id)
896 .collect();
897 let submission_owner_user_ids =
898 crate::exercise_slide_submissions::get_user_ids_by_submission_ids(
899 &mut *conn,
900 &given_submission_ids,
901 )
902 .await?;
903 let mut received_peer_or_self_review_submissions = crate::peer_or_self_review_submissions::get_all_received_peer_or_self_review_submissions_for_user_and_course(&mut *conn, user_id, course_id).await?.into_iter()
904 .into_group_map_by(|o| o.exercise_id);
905 let given_peer_or_self_review_submission_ids = given_peer_or_self_review_submissions
906 .values()
907 .flatten()
908 .map(|x| x.id)
909 .collect::<Vec<_>>();
910 let mut given_peer_or_self_review_question_submissions = crate::peer_or_self_review_question_submissions::get_question_submissions_from_from_peer_or_self_review_submission_ids(&mut *conn, &given_peer_or_self_review_submission_ids).await?
911 .into_iter()
912 .into_group_map_by(|o| {
913 let peer_review_submission = given_peer_or_self_review_submissions.clone().into_iter()
914 .find(|(_exercise_id, prs)| prs.iter().any(|p| p.id == o.peer_or_self_review_submission_id))
915 .unwrap_or_else(|| (Uuid::nil(), vec![]));
916 peer_review_submission.0
917 });
918 let received_peer_or_self_review_submission_ids = received_peer_or_self_review_submissions
919 .values()
920 .flatten()
921 .map(|x| x.id)
922 .collect::<Vec<_>>();
923 let mut received_peer_or_self_review_question_submissions = crate::peer_or_self_review_question_submissions::get_question_submissions_from_from_peer_or_self_review_submission_ids(&mut *conn, &received_peer_or_self_review_submission_ids).await?.into_iter()
924 .into_group_map_by(|o| {
925 let peer_review_submission = received_peer_or_self_review_submissions.clone().into_iter()
926 .find(|(_exercise_id, prs)| prs.iter().any(|p| p.id == o.peer_or_self_review_submission_id))
927 .unwrap_or_else(|| (Uuid::nil(), vec![]));
928 peer_review_submission.0
929 });
930 let mut peer_review_queue_entries =
931 crate::peer_review_queue_entries::get_all_by_user_and_course_id(
932 &mut *conn, user_id, course_id,
933 )
934 .await?
935 .into_iter()
936 .map(|x| (x.exercise_id, x))
937 .collect::<HashMap<_, _>>();
938 let mut teacher_grading_decisions = crate::teacher_grading_decisions::get_all_latest_grading_decisions_by_user_id_and_course_id(&mut *conn, user_id, course_id).await?.into_iter()
939 .filter_map(|tgd| {
940 let user_exercise_state = user_exercise_states.clone().into_iter()
941 .find(|(_exercise_id, ues)| ues.id == tgd.user_exercise_state_id)?;
942 Some((user_exercise_state.0, tgd))
943 }).collect::<HashMap<_, _>>();
944 let all_peer_or_self_review_question_ids = given_peer_or_self_review_question_submissions
945 .iter()
946 .chain(received_peer_or_self_review_question_submissions.iter())
947 .flat_map(|(_exercise_id, prqs)| prqs.iter().map(|p| p.peer_or_self_review_question_id))
948 .collect::<Vec<_>>();
949 let all_peer_or_self_review_questions = crate::peer_or_self_review_questions::get_by_ids(
950 &mut *conn,
951 &all_peer_or_self_review_question_ids,
952 )
953 .await?;
954
955 let res = exercises
961 .into_iter()
962 .map(|exercise| {
963 let user_exercise_state = user_exercise_states.remove(&exercise.id);
964 let exercise_slide_submissions = exercise_slide_submissions
965 .remove(&exercise.id)
966 .unwrap_or_default();
967 let given_peer_or_self_review_submissions = given_peer_or_self_review_submissions
968 .remove(&exercise.id)
969 .unwrap_or_default()
970 .into_iter()
971 .map(|prs| {
972 let submission_owner_user_id = submission_owner_user_ids
973 .get(&prs.exercise_slide_submission_id)
974 .copied();
975 PeerOrSelfReviewSubmissionWithSubmissionOwner {
976 submission: prs,
977 submission_owner_user_id,
978 }
979 })
980 .collect();
981 let received_peer_or_self_review_submissions = received_peer_or_self_review_submissions
982 .remove(&exercise.id)
983 .unwrap_or_default()
984 .into_iter()
985 .map(|prs| PeerOrSelfReviewSubmissionWithSubmissionOwner {
986 submission: prs,
987 submission_owner_user_id: None,
988 })
989 .collect();
990 let given_peer_or_self_review_question_submissions =
991 given_peer_or_self_review_question_submissions
992 .remove(&exercise.id)
993 .unwrap_or_default();
994 let received_peer_or_self_review_question_submissions =
995 received_peer_or_self_review_question_submissions
996 .remove(&exercise.id)
997 .unwrap_or_default();
998 let peer_review_queue_entry = peer_review_queue_entries.remove(&exercise.id);
999 let teacher_grading_decision = teacher_grading_decisions.remove(&exercise.id);
1000 let peer_or_self_review_question_ids = given_peer_or_self_review_question_submissions
1001 .iter()
1002 .chain(received_peer_or_self_review_question_submissions.iter())
1003 .map(|prqs| prqs.peer_or_self_review_question_id)
1004 .unique()
1005 .collect::<Vec<_>>();
1006 let peer_or_self_review_questions = all_peer_or_self_review_questions
1007 .iter()
1008 .filter(|prq| peer_or_self_review_question_ids.contains(&prq.id))
1009 .cloned()
1010 .collect::<Vec<_>>();
1011 ExerciseStatusSummaryForUser {
1012 exercise,
1013 user_exercise_state,
1014 exercise_slide_submissions,
1015 given_peer_or_self_review_submissions,
1016 received_peer_or_self_review_submissions,
1017 given_peer_or_self_review_question_submissions,
1018 received_peer_or_self_review_question_submissions,
1019 peer_review_queue_entry,
1020 teacher_grading_decision,
1021 peer_or_self_review_questions,
1022 }
1023 })
1024 .collect::<Vec<_>>();
1025 Ok(res)
1026}
1027
1028pub async fn get_exercises_by_module_containing_exercise_type(
1029 conn: &mut PgConnection,
1030 exercise_type: &str,
1031 course_module_id: Uuid,
1032) -> ModelResult<Vec<Exercise>> {
1033 let res: Vec<Exercise> = sqlx::query_as!(
1034 Exercise,
1035 r#"
1036SELECT DISTINCT(ex.*)
1037FROM exercises ex
1038 JOIN exercise_slides slides ON ex.id = slides.exercise_id
1039 JOIN exercise_tasks tasks ON slides.id = tasks.exercise_slide_id
1040 JOIN chapters c ON ex.chapter_id = c.id
1041where tasks.exercise_type = $1
1042 AND c.course_module_id = $2
1043 AND ex.deleted_at IS NULL
1044 AND tasks.deleted_at IS NULL
1045 and c.deleted_at IS NULL
1046 and slides.deleted_at IS NULL
1047 "#,
1048 exercise_type,
1049 course_module_id
1050 )
1051 .fetch_all(conn)
1052 .await?;
1053 Ok(res)
1054}
1055
1056pub async fn collect_user_ids_and_exercise_ids_for_reset(
1058 conn: &mut PgConnection,
1059 user_ids: &[Uuid],
1060 exercise_ids: &[Uuid],
1061 threshold: Option<f64>,
1062 reset_all_below_max: bool,
1063 reset_only_locked_reviews: bool,
1064) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1065 let results = sqlx::query!(
1066 r#"
1067SELECT DISTINCT ues.user_id,
1068 ues.exercise_id
1069FROM user_exercise_states ues
1070 LEFT JOIN exercises e ON ues.exercise_id = e.id
1071WHERE ues.user_id = ANY($1)
1072 AND ues.exercise_id = ANY($2)
1073 AND ues.deleted_at IS NULL
1074 AND (
1075 $3 = FALSE
1076 OR ues.score_given < e.score_maximum
1077 )
1078 AND (
1079 $4::FLOAT IS NULL
1080 OR ues.score_given < $4::FLOAT
1081 )
1082 AND (
1083 $5 = FALSE
1084 OR ues.reviewing_stage = 'reviewed_and_locked'
1085 )
1086 "#,
1087 user_ids,
1088 exercise_ids,
1089 reset_all_below_max,
1090 threshold,
1091 reset_only_locked_reviews
1092 )
1093 .fetch_all(&mut *conn)
1094 .await?;
1095
1096 let mut user_exercise_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
1097 for row in &results {
1098 user_exercise_map
1099 .entry(row.user_id)
1100 .or_default()
1101 .push(row.exercise_id);
1102 }
1103
1104 Ok(user_exercise_map.into_iter().collect())
1105}
1106
1107async fn get_chapter_ids_for_exercises_in_course(
1109 conn: &mut PgConnection,
1110 exercise_ids: &[Uuid],
1111 course_id: Uuid,
1112) -> ModelResult<Vec<Uuid>> {
1113 let mut chapter_ids = HashSet::new();
1114 for exercise_id in exercise_ids {
1115 let exercise = get_exercise_by_id(conn, *exercise_id).await?;
1116 if exercise.course_id == Some(course_id)
1117 && let Some(chapter_id) = exercise.chapter_id
1118 {
1119 chapter_ids.insert(chapter_id);
1120 }
1121 }
1122 Ok(chapter_ids.into_iter().collect())
1123}
1124
1125pub async fn reset_exercises_for_selected_users(
1127 conn: &mut PgConnection,
1128 users_and_exercises: &[(Uuid, Vec<Uuid>)],
1129 reset_by: Option<Uuid>,
1130 course_id: Uuid,
1131 reason: Option<String>,
1132) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1133 let mut successful_resets = Vec::new();
1134 let mut tx = conn.begin().await?;
1135 for (user_id, exercise_ids) in users_and_exercises {
1136 sqlx::query!(
1137 r#"
1138UPDATE exercise_slide_submissions
1139SET deleted_at = NOW()
1140WHERE user_id = $1
1141 AND exercise_id = ANY($2)
1142 AND deleted_at IS NULL
1143 "#,
1144 user_id,
1145 exercise_ids
1146 )
1147 .execute(&mut *tx)
1148 .await?;
1149
1150 sqlx::query!(
1151 r#"
1152UPDATE exercise_task_submissions
1153SET deleted_at = NOW()
1154WHERE exercise_slide_submission_id IN (
1155 SELECT id
1156 FROM exercise_slide_submissions
1157 WHERE user_id = $1
1158 AND exercise_id = ANY($2)
1159 )
1160 AND deleted_at IS NULL
1161 "#,
1162 user_id,
1163 exercise_ids
1164 )
1165 .execute(&mut *tx)
1166 .await?;
1167
1168 sqlx::query!(
1169 r#"
1170UPDATE peer_review_queue_entries
1171SET deleted_at = NOW()
1172WHERE user_id = $1
1173 AND exercise_id = ANY($2)
1174 AND deleted_at IS NULL
1175 "#,
1176 user_id,
1177 exercise_ids
1178 )
1179 .execute(&mut *tx)
1180 .await?;
1181
1182 sqlx::query!(
1183 r#"
1184UPDATE exercise_task_gradings
1185SET deleted_at = NOW()
1186WHERE exercise_task_submission_id IN (
1187 SELECT id
1188 FROM exercise_task_submissions
1189 WHERE exercise_slide_submission_id IN (
1190 SELECT id
1191 FROM exercise_slide_submissions
1192 WHERE user_id = $1
1193 AND exercise_id = ANY($2)
1194 )
1195 )
1196 AND deleted_at IS NULL
1197 "#,
1198 user_id,
1199 exercise_ids
1200 )
1201 .execute(&mut *tx)
1202 .await?;
1203
1204 sqlx::query!(
1207 r#"
1208UPDATE user_exercise_states
1209SET deleted_at = NOW()
1210WHERE user_id = $1
1211 AND exercise_id = ANY($2)
1212 AND deleted_at IS NULL
1213 "#,
1214 user_id,
1215 exercise_ids
1216 )
1217 .execute(&mut *tx)
1218 .await?;
1219
1220 sqlx::query!(
1221 r#"
1222UPDATE user_exercise_task_states
1223SET deleted_at = NOW()
1224WHERE user_exercise_slide_state_id IN (
1225 SELECT id
1226 FROM user_exercise_slide_states
1227 WHERE user_exercise_state_id IN (
1228 SELECT id
1229 FROM user_exercise_states
1230 WHERE user_id = $1
1231 AND exercise_id = ANY($2)
1232 )
1233 )
1234 AND deleted_at IS NULL
1235 "#,
1236 user_id,
1237 exercise_ids
1238 )
1239 .execute(&mut *tx)
1240 .await?;
1241
1242 sqlx::query!(
1243 r#"
1244UPDATE user_exercise_slide_states
1245SET deleted_at = NOW()
1246WHERE user_exercise_state_id IN (
1247 SELECT id
1248 FROM user_exercise_states
1249 WHERE user_id = $1
1250 AND exercise_id = ANY($2)
1251 )
1252 AND deleted_at IS NULL
1253 "#,
1254 user_id,
1255 exercise_ids
1256 )
1257 .execute(&mut *tx)
1258 .await?;
1259
1260 exercise_reset_logs::log_exercise_reset(
1262 &mut tx,
1263 reset_by,
1264 *user_id,
1265 exercise_ids,
1266 course_id,
1267 reason.clone(),
1268 )
1269 .await?;
1270
1271 let chapter_ids =
1272 get_chapter_ids_for_exercises_in_course(&mut tx, exercise_ids, course_id).await?;
1273 user_chapter_locking_statuses::unlock_chapters_for_user(
1275 &mut tx,
1276 *user_id,
1277 course_id,
1278 &chapter_ids,
1279 )
1280 .await?;
1281
1282 successful_resets.push((*user_id, exercise_ids.to_vec()));
1283 }
1284 tx.commit().await?;
1285 Ok(successful_resets)
1286}
1287
1288pub async fn reset_progress_by_course_id_user_ids_and_exercise_ids(
1289 conn: &mut PgConnection,
1290 course_id: Uuid,
1291 user_ids: &[Uuid],
1292 exercise_ids: &[Uuid],
1293 reset_by: Option<Uuid>,
1294 reason: Option<String>,
1295) -> ModelResult<Vec<(Uuid, Vec<Uuid>)>> {
1296 let mut successful_resets = Vec::new();
1297 let mut tx = conn.begin().await?;
1298 let validated_exercise_ids = sqlx::query!(
1299 r#"
1300SELECT id
1301FROM exercises
1302WHERE id = ANY($1)
1303 AND course_id = $2
1304 AND deleted_at IS NULL
1305 "#,
1306 exercise_ids,
1307 course_id
1308 )
1309 .fetch_all(&mut *tx)
1310 .await?
1311 .into_iter()
1312 .map(|row| row.id)
1313 .collect::<Vec<_>>();
1314
1315 if validated_exercise_ids.is_empty() {
1316 tx.commit().await?;
1317 return Ok(successful_resets);
1318 }
1319
1320 for user_id in user_ids {
1321 sqlx::query!(
1322 r#"
1323UPDATE exercise_slide_submissions
1324SET deleted_at = NOW()
1325WHERE user_id = $1
1326 AND course_id = $2
1327 AND exercise_id = ANY($3)
1328 AND deleted_at IS NULL
1329 "#,
1330 user_id,
1331 course_id,
1332 &validated_exercise_ids
1333 )
1334 .execute(&mut *tx)
1335 .await?;
1336
1337 sqlx::query!(
1338 r#"
1339UPDATE exercise_task_submissions
1340SET deleted_at = NOW()
1341WHERE exercise_slide_submission_id IN (
1342 SELECT id
1343 FROM exercise_slide_submissions
1344 WHERE user_id = $1
1345 AND course_id = $2
1346 AND exercise_id = ANY($3)
1347 )
1348 AND deleted_at IS NULL
1349 "#,
1350 user_id,
1351 course_id,
1352 &validated_exercise_ids
1353 )
1354 .execute(&mut *tx)
1355 .await?;
1356
1357 sqlx::query!(
1358 r#"
1359UPDATE peer_review_queue_entries
1360SET deleted_at = NOW()
1361WHERE user_id = $1
1362 AND exercise_id = ANY($2)
1363 AND deleted_at IS NULL
1364 "#,
1365 user_id,
1366 &validated_exercise_ids
1367 )
1368 .execute(&mut *tx)
1369 .await?;
1370
1371 sqlx::query!(
1372 r#"
1373UPDATE exercise_task_gradings
1374SET deleted_at = NOW()
1375WHERE exercise_task_submission_id IN (
1376 SELECT ets.id
1377 FROM exercise_task_submissions ets
1378 JOIN exercise_slide_submissions ess
1379 ON ess.id = ets.exercise_slide_submission_id
1380 WHERE ess.user_id = $1
1381 AND ess.course_id = $2
1382 AND ess.exercise_id = ANY($3)
1383 )
1384 AND deleted_at IS NULL
1385 "#,
1386 user_id,
1387 course_id,
1388 &validated_exercise_ids
1389 )
1390 .execute(&mut *tx)
1391 .await?;
1392
1393 sqlx::query!(
1394 r#"
1395UPDATE user_exercise_task_states
1396SET deleted_at = NOW()
1397WHERE user_exercise_slide_state_id IN (
1398 SELECT uess.id
1399 FROM user_exercise_slide_states uess
1400 JOIN user_exercise_states ues ON ues.id = uess.user_exercise_state_id
1401 WHERE ues.user_id = $1
1402 AND ues.course_id = $2
1403 AND ues.exercise_id = ANY($3)
1404 )
1405 AND deleted_at IS NULL
1406 "#,
1407 user_id,
1408 course_id,
1409 &validated_exercise_ids
1410 )
1411 .execute(&mut *tx)
1412 .await?;
1413
1414 sqlx::query!(
1415 r#"
1416UPDATE user_exercise_slide_states
1417SET deleted_at = NOW()
1418WHERE user_exercise_state_id IN (
1419 SELECT id
1420 FROM user_exercise_states
1421 WHERE user_id = $1
1422 AND course_id = $2
1423 AND exercise_id = ANY($3)
1424 )
1425 AND deleted_at IS NULL
1426 "#,
1427 user_id,
1428 course_id,
1429 &validated_exercise_ids
1430 )
1431 .execute(&mut *tx)
1432 .await?;
1433
1434 sqlx::query!(
1437 r#"
1438UPDATE user_exercise_states
1439SET deleted_at = NOW()
1440WHERE user_id = $1
1441 AND course_id = $2
1442 AND exercise_id = ANY($3)
1443 AND deleted_at IS NULL
1444 "#,
1445 user_id,
1446 course_id,
1447 &validated_exercise_ids
1448 )
1449 .execute(&mut *tx)
1450 .await?;
1451
1452 exercise_reset_logs::log_exercise_reset(
1453 &mut tx,
1454 reset_by,
1455 *user_id,
1456 &validated_exercise_ids,
1457 course_id,
1458 reason.clone(),
1459 )
1460 .await?;
1461
1462 let chapter_ids =
1463 get_chapter_ids_for_exercises_in_course(&mut tx, &validated_exercise_ids, course_id)
1464 .await?;
1465 user_chapter_locking_statuses::unlock_chapters_for_user(
1466 &mut tx,
1467 *user_id,
1468 course_id,
1469 &chapter_ids,
1470 )
1471 .await?;
1472
1473 successful_resets.push((*user_id, validated_exercise_ids.clone()));
1474 }
1475 tx.commit().await?;
1476 Ok(successful_resets)
1477}
1478
1479pub async fn set_deadline(
1484 conn: &mut PgConnection,
1485 id: Uuid,
1486 deadline: Option<DateTime<Utc>>,
1487) -> ModelResult<()> {
1488 sqlx::query!(
1489 "UPDATE exercises SET deadline = $2 WHERE id = $1",
1490 id,
1491 deadline
1492 )
1493 .execute(conn)
1494 .await?;
1495 Ok(())
1496}
1497
1498pub async fn set_try_limit(
1503 conn: &mut PgConnection,
1504 id: Uuid,
1505 limit_number_of_tries: bool,
1506 max_tries_per_slide: Option<i32>,
1507) -> ModelResult<()> {
1508 sqlx::query!(
1509 "
1510UPDATE exercises
1511SET limit_number_of_tries = $2,
1512 max_tries_per_slide = $3
1513WHERE id = $1
1514",
1515 id,
1516 limit_number_of_tries,
1517 max_tries_per_slide
1518 )
1519 .execute(conn)
1520 .await?;
1521 Ok(())
1522}
1523
1524#[cfg(test)]
1525mod test {
1526 use super::*;
1527 use crate::{
1528 chapters,
1529 course_instance_enrollments::{self, NewCourseInstanceEnrollment},
1530 courses,
1531 exercise_service_info::{self, PathInfo},
1532 exercise_services::{self, ExerciseServiceNewOrUpdate},
1533 test_helper::Conn,
1534 test_helper::*,
1535 user_chapter_locking_statuses::{self, ChapterLockingStatus},
1536 user_exercise_states,
1537 };
1538 use chrono::TimeZone;
1539 use sqlx::PgConnection;
1540
1541 async fn insert_exercise_service_with_info(tx: &mut PgConnection) {
1542 let exercise_service = exercise_services::insert_exercise_service(
1543 tx,
1544 &ExerciseServiceNewOrUpdate {
1545 name: "text-exercise".to_string(),
1546 slug: TEST_HELPER_EXERCISE_SERVICE_NAME.to_string(),
1547 public_url: "https://example.com".to_string(),
1548 internal_url: None,
1549 max_reprocessing_submissions_at_once: 1,
1550 },
1551 )
1552 .await
1553 .unwrap();
1554 exercise_service_info::insert(
1555 tx,
1556 &PathInfo {
1557 exercise_service_id: exercise_service.id,
1558 user_interface_iframe_path: "/iframe".to_string(),
1559 grade_endpoint_path: "/grade".to_string(),
1560 public_spec_endpoint_path: "/public-spec".to_string(),
1561 model_solution_spec_endpoint_path: "test-only-empty-path".to_string(),
1562 has_custom_view: false,
1563 supports_native_client: false,
1564 produces_file_answers: false,
1565 declares_spec_files: false,
1566 },
1567 )
1568 .await
1569 .unwrap();
1570 }
1571
1572 #[tokio::test]
1573 async fn selects_course_material_exercise_for_enrolled_student() {
1574 insert_data!(
1575 :tx,
1576 user: user_id,
1577 org: _organization_id,
1578 course: course_id,
1579 instance: course_instance,
1580 :course_module,
1581 chapter: chapter_id,
1582 page: _page_id,
1583 exercise: exercise_id,
1584 slide: exercise_slide_id,
1585 task: exercise_task_id
1586 );
1587 insert_exercise_service_with_info(tx.as_mut()).await;
1588 course_instance_enrollments::insert_enrollment_and_set_as_current(
1589 tx.as_mut(),
1590 NewCourseInstanceEnrollment {
1591 course_id,
1592 course_instance_id: course_instance.id,
1593 user_id,
1594 },
1595 )
1596 .await
1597 .unwrap();
1598
1599 let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
1600 tx.as_mut(),
1601 user_id,
1602 exercise_id,
1603 CourseOrExamId::Course(course_id),
1604 )
1605 .await
1606 .unwrap();
1607 assert!(user_exercise_state.is_none());
1608
1609 let exercise = get_course_material_exercise(
1610 tx.as_mut(),
1611 Some(user_id),
1612 exercise_id,
1613 |_| unimplemented!(),
1614 &init_file_store(),
1615 &init_app_conf().expect("Application Configuration initialization failed"),
1616 )
1617 .await
1618 .unwrap();
1619 assert_eq!(
1620 exercise
1621 .current_exercise_slide
1622 .exercise_tasks
1623 .first()
1624 .unwrap()
1625 .id,
1626 exercise_task_id
1627 );
1628
1629 let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
1630 tx.as_mut(),
1631 user_id,
1632 exercise_id,
1633 CourseOrExamId::Course(course_id),
1634 )
1635 .await
1636 .unwrap();
1637 assert_eq!(
1638 user_exercise_state
1639 .unwrap()
1640 .selected_exercise_slide_id
1641 .unwrap(),
1642 exercise_slide_id
1643 );
1644 }
1645
1646 #[tokio::test]
1647 async fn course_material_exercise_inherits_chapter_deadline() {
1648 insert_data!(
1649 :tx,
1650 user: user_id,
1651 org: organization_id,
1652 course: course_id,
1653 instance: course_instance,
1654 :course_module,
1655 chapter: chapter_id,
1656 page: page_id,
1657 exercise: exercise_id,
1658 slide: exercise_slide_id,
1659 task: _exercise_task_id
1660 );
1661 insert_exercise_service_with_info(tx.as_mut()).await;
1662 course_instance_enrollments::insert_enrollment_and_set_as_current(
1663 tx.as_mut(),
1664 NewCourseInstanceEnrollment {
1665 course_id,
1666 course_instance_id: course_instance.id,
1667 user_id,
1668 },
1669 )
1670 .await
1671 .unwrap();
1672
1673 let chapter_deadline = Utc.with_ymd_and_hms(2125, 1, 1, 23, 59, 59).unwrap();
1674 let chapter = chapters::get_chapter(tx.as_mut(), chapter_id)
1675 .await
1676 .unwrap();
1677 chapters::update_chapter(
1678 tx.as_mut(),
1679 chapter_id,
1680 chapters::ChapterUpdate {
1681 name: chapter.name,
1682 color: chapter.color,
1683 front_page_id: chapter.front_page_id,
1684 deadline: Some(chapter_deadline),
1685 opens_at: chapter.opens_at,
1686 course_module_id: Some(chapter.course_module_id),
1687 },
1688 )
1689 .await
1690 .unwrap();
1691
1692 let exercise = get_course_material_exercise(
1693 tx.as_mut(),
1694 Some(user_id),
1695 exercise_id,
1696 |_| unimplemented!(),
1697 &init_file_store(),
1698 &init_app_conf().expect("Application Configuration initialization failed"),
1699 )
1700 .await
1701 .unwrap();
1702
1703 assert_eq!(exercise.exercise.deadline, Some(chapter_deadline));
1704 }
1705
1706 #[tokio::test]
1707 async fn resetting_exercise_unlocks_its_chapter_for_user() {
1708 insert_data!(
1709 :tx,
1710 user: user_id,
1711 org: _organization_id,
1712 course: course_id,
1713 instance: _course_instance,
1714 :course_module,
1715 chapter: chapter_id,
1716 page: _page_id,
1717 exercise: exercise_id,
1718 slide: _exercise_slide_id,
1719 task: _exercise_task_id
1720 );
1721 let app_config = init_app_conf().expect("Application Configuration initialization failed");
1722
1723 let existing_course = courses::get_course(tx.as_mut(), course_id).await.unwrap();
1724 courses::update_course(
1725 tx.as_mut(),
1726 &app_config,
1727 course_id,
1728 courses::CourseUpdate {
1729 name: existing_course.name,
1730 description: existing_course.description,
1731 is_draft: existing_course.is_draft,
1732 is_test_mode: existing_course.is_test_mode,
1733 can_add_chatbot: existing_course.can_add_chatbot,
1734 is_unlisted: existing_course.is_unlisted,
1735 is_joinable_by_code_only: existing_course.is_joinable_by_code_only,
1736 ask_marketing_consent: existing_course.ask_marketing_consent,
1737 flagged_answers_threshold: existing_course.flagged_answers_threshold.unwrap_or(1),
1738 flagged_answers_skip_manual_review_and_allow_retry: existing_course
1739 .flagged_answers_skip_manual_review_and_allow_retry,
1740 closed_at: existing_course.closed_at,
1741 closed_additional_message: existing_course.closed_additional_message,
1742 closed_course_successor_id: existing_course.closed_course_successor_id,
1743 chapter_locking_enabled: true,
1744 ai_policy: existing_course.ai_policy,
1745 course_material_ai_instructions: existing_course.course_material_ai_instructions,
1746 },
1747 )
1748 .await
1749 .unwrap();
1750
1751 user_chapter_locking_statuses::complete_and_lock_chapter(
1752 tx.as_mut(),
1753 user_id,
1754 chapter_id,
1755 course_id,
1756 )
1757 .await
1758 .unwrap();
1759
1760 reset_exercises_for_selected_users(
1761 tx.as_mut(),
1762 &[(user_id, vec![exercise_id])],
1763 Some(user_id),
1764 course_id,
1765 Some("test-reset".to_string()),
1766 )
1767 .await
1768 .unwrap();
1769
1770 let status = user_chapter_locking_statuses::get_or_init_status(
1771 tx.as_mut(),
1772 user_id,
1773 chapter_id,
1774 Some(course_id),
1775 Some(true),
1776 )
1777 .await
1778 .unwrap();
1779
1780 assert_eq!(status, Some(ChapterLockingStatus::Unlocked));
1781 }
1782}