1use std::collections::HashMap;
2
3use chrono::NaiveDate;
4use futures::future::BoxFuture;
5use rand::prelude::SliceRandom;
6use url::Url;
7use utoipa::ToSchema;
8
9use crate::{
10 CourseOrExamId,
11 courses::Course,
12 exams::{self, ExamEnrollment},
13 exercise_service_info::ExerciseServiceInfoApi,
14 exercise_task_gradings::UserPointsUpdateStrategy,
15 exercise_task_submissions::{AnswerData, StoredAnswer, attach_answer_data},
16 exercise_tasks::CourseMaterialExerciseTask,
17 exercises::{self, Exercise, GradingProgress},
18 prelude::*,
19 teacher_grading_decisions::{self, TeacherGradingDecision},
20 user_exercise_states::{self, UserExerciseState},
21};
22
23#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
24
25pub struct AnswerRequiringAttention {
26 pub id: Uuid,
27 pub user_id: Uuid,
28 pub created_at: DateTime<Utc>,
29 pub updated_at: DateTime<Utc>,
30 pub deleted_at: Option<DateTime<Utc>>,
31 pub answer: Option<AnswerData>,
32 pub grading_progress: GradingProgress,
33 pub score_given: Option<f32>,
34 pub submission_id: Uuid,
35 pub exercise_id: Uuid,
36}
37#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
38
39pub struct NewExerciseSlideSubmission {
40 pub exercise_slide_id: Uuid,
41 pub course_id: Option<Uuid>,
42 pub exam_id: Option<Uuid>,
43 pub user_id: Uuid,
44 pub exercise_id: Uuid,
45 pub user_points_update_strategy: UserPointsUpdateStrategy,
46}
47
48#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
49
50pub struct ExerciseSlideSubmission {
51 pub id: Uuid,
52 pub created_at: DateTime<Utc>,
53 pub updated_at: DateTime<Utc>,
54 pub deleted_at: Option<DateTime<Utc>>,
55 pub exercise_slide_id: Uuid,
56 pub course_id: Option<Uuid>,
57 pub exam_id: Option<Uuid>,
58 pub exercise_id: Uuid,
59 pub user_id: Uuid,
60 pub user_points_update_strategy: UserPointsUpdateStrategy,
61 pub flag_count: Option<i32>,
62}
63
64impl ExerciseSlideSubmission {
65 pub fn get_course_id(&self) -> ModelResult<Uuid> {
66 self.course_id.ok_or_else(|| {
67 ModelError::new(
68 ModelErrorType::Generic,
69 "Submission is not related to a course.".to_string(),
70 None,
71 )
72 })
73 }
74}
75
76#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
77
78pub struct ExerciseAnswersInCourseRequiringAttentionCount {
79 pub id: Uuid,
80 pub name: String,
81 pub page_id: Uuid,
82 pub chapter_id: Option<Uuid>,
83 pub order_number: i32,
84 pub count: Option<i32>,
85}
86
87#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
88
89pub struct ExerciseSlideSubmissionCount {
90 pub date: Option<NaiveDate>,
91 pub count: Option<i32>,
92}
93
94#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
95
96pub struct ExerciseSlideSubmissionCountByExercise {
97 pub exercise_id: Uuid,
98 pub count: Option<i32>,
99 pub exercise_name: String,
100}
101
102#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
103
104pub struct ExerciseSlideSubmissionCountByWeekAndHour {
105 pub isodow: Option<i32>,
106 pub hour: Option<i32>,
107 pub count: Option<i32>,
108}
109
110#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
111
112pub struct ExerciseSlideSubmissionInfo {
113 pub tasks: Vec<CourseMaterialExerciseTask>,
114 pub exercise: Exercise,
115 pub exercise_slide_submission: ExerciseSlideSubmission,
116 pub user_exercise_state: Option<UserExerciseState>,
117}
118
119impl ExerciseSlideSubmissionInfo {
120 pub fn strip_for_shared_view(&mut self) {
135 for task in &mut self.tasks {
136 task.model_solution_spec = None;
137 task.pseudonumous_user_id = None;
138 }
139 self.exercise_slide_submission.user_id = Uuid::nil();
140 self.user_exercise_state = None;
141 }
142}
143
144#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
145
146pub struct ExerciseSlideSubmissionAndUserExerciseState {
147 pub exercise: Exercise,
148 pub exercise_slide_submission: ExerciseSlideSubmission,
149 pub user_exercise_state: UserExerciseState,
150 pub teacher_grading_decision: Option<TeacherGradingDecision>,
151 pub user_exam_enrollment: ExamEnrollment,
152}
153
154#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
155
156pub struct ExerciseSlideSubmissionAndUserExerciseStateList {
157 pub data: Vec<ExerciseSlideSubmissionAndUserExerciseState>,
158 pub total_pages: u32,
159}
160
161pub async fn insert_exercise_slide_submission(
162 conn: &mut PgConnection,
163 exercise_slide_submission: NewExerciseSlideSubmission,
164) -> ModelResult<ExerciseSlideSubmission> {
165 let res = sqlx::query_as!(
166 ExerciseSlideSubmission,
167 r#"
168INSERT INTO exercise_slide_submissions (
169 exercise_slide_id,
170 course_id,
171 exam_id,
172 exercise_id,
173 user_id,
174 user_points_update_strategy
175 )
176VALUES ($1, $2, $3, $4, $5, $6)
177RETURNING *
178 "#,
179 exercise_slide_submission.exercise_slide_id,
180 exercise_slide_submission.course_id,
181 exercise_slide_submission.exam_id,
182 exercise_slide_submission.exercise_id,
183 exercise_slide_submission.user_id,
184 exercise_slide_submission.user_points_update_strategy as UserPointsUpdateStrategy,
185 )
186 .fetch_one(conn)
187 .await?;
188 Ok(res)
189}
190
191pub async fn insert_exercise_slide_submission_with_id(
192 conn: &mut PgConnection,
193 id: Uuid,
194 exercise_slide_submission: &NewExerciseSlideSubmission,
195) -> ModelResult<ExerciseSlideSubmission> {
196 let res = sqlx::query_as!(
197 ExerciseSlideSubmission,
198 r#"
199INSERT INTO exercise_slide_submissions (
200 id,
201 exercise_slide_id,
202 course_id,
203 exam_id,
204 exercise_id,
205 user_id,
206 user_points_update_strategy
207 )
208VALUES ($1, $2, $3, $4, $5, $6, $7)
209RETURNING *
210 "#,
211 id,
212 exercise_slide_submission.exercise_slide_id,
213 exercise_slide_submission.course_id,
214 exercise_slide_submission.exam_id,
215 exercise_slide_submission.exercise_id,
216 exercise_slide_submission.user_id,
217 exercise_slide_submission.user_points_update_strategy as UserPointsUpdateStrategy,
218 )
219 .fetch_one(conn)
220 .await?;
221 Ok(res)
222}
223
224pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ExerciseSlideSubmission> {
225 let exercise_slide_submission = sqlx::query_as!(
226 ExerciseSlideSubmission,
227 r#"
228SELECT *
229FROM exercise_slide_submissions
230WHERE id = $1
231 AND deleted_at IS NULL;
232 "#,
233 id
234 )
235 .fetch_one(conn)
236 .await?;
237 Ok(exercise_slide_submission)
238}
239
240pub async fn get_by_course_id_and_user_ids_and_exercise_ids(
241 conn: &mut PgConnection,
242 course_id: Uuid,
243 user_ids: &[Uuid],
244 exercise_ids: &[Uuid],
245) -> ModelResult<Vec<ExerciseSlideSubmission>> {
246 let submissions = sqlx::query_as!(
247 ExerciseSlideSubmission,
248 r#"
249SELECT ess.id,
250 ess.created_at,
251 ess.updated_at,
252 ess.deleted_at,
253 ess.exercise_slide_id,
254 ess.course_id,
255 ess.exam_id,
256 ess.exercise_id,
257 ess.user_id,
258 ess.user_points_update_strategy,
259 ess.flag_count
260FROM exercise_slide_submissions ess
261 JOIN exercises e ON e.id = ess.exercise_id
262WHERE ess.course_id = $1
263 AND e.course_id = $1
264 AND ess.user_id = ANY($2)
265 AND ess.exercise_id = ANY($3)
266 AND ess.deleted_at IS NULL
267 AND e.deleted_at IS NULL
268 "#,
269 course_id,
270 user_ids,
271 exercise_ids
272 )
273 .fetch_all(conn)
274 .await?;
275 Ok(submissions)
276}
277
278pub async fn get_user_ids_by_submission_ids(
280 conn: &mut PgConnection,
281 submission_ids: &[Uuid],
282) -> ModelResult<HashMap<Uuid, Uuid>> {
283 if submission_ids.is_empty() {
284 return Ok(HashMap::new());
285 }
286 let rows = sqlx::query!(
287 r#"
288SELECT *
289FROM exercise_slide_submissions
290WHERE id = ANY($1)
291 AND deleted_at IS NULL
292 "#,
293 submission_ids
294 )
295 .fetch_all(conn)
296 .await?;
297 Ok(rows.into_iter().map(|r| (r.id, r.user_id)).collect())
298}
299
300pub async fn try_to_get_random_filtered_by_user_and_submissions(
304 conn: &mut PgConnection,
305 exercise_id: Uuid,
306 excluded_user_id: Uuid,
307 excluded_ids: &[Uuid],
308) -> ModelResult<Option<ExerciseSlideSubmission>> {
309 let mut res = sqlx::query_as!(
310 ExerciseSlideSubmission,
311 r#"
312SELECT DISTINCT ON (user_id)
313 ess.id,
314 ess.created_at,
315 ess.updated_at,
316 ess.deleted_at,
317 ess.exercise_slide_id,
318 ess.course_id,
319 ess.exam_id,
320 ess.exercise_id,
321 ess.user_id,
322 ess.user_points_update_strategy,
323 ess.flag_count
324FROM exercise_slide_submissions AS ess
325JOIN courses AS c
326 ON ess.course_id = c.id
327WHERE ess.exercise_id = $1
328 AND ess.id <> ALL($2)
329 AND ess.user_id <> $3
330 AND ess.deleted_at IS NULL
331 AND ess.flag_count < c.flagged_answers_threshold
332ORDER BY ess.user_id, ess.created_at DESC
333 "#,
334 exercise_id,
335 excluded_ids,
336 excluded_user_id,
337 )
338 .fetch_all(conn)
339 .await?;
340 let mut rng = rand::rng();
342 res.shuffle(&mut rng);
343 Ok(res.into_iter().next())
344}
345
346pub async fn get_by_exercise_id(
347 conn: &mut PgConnection,
348 exercise_id: Uuid,
349 pagination: Pagination,
350) -> ModelResult<Vec<ExerciseSlideSubmission>> {
351 let submissions = sqlx::query_as!(
352 ExerciseSlideSubmission,
353 r#"
354SELECT *
355FROM exercise_slide_submissions
356WHERE exercise_id = $1
357 AND deleted_at IS NULL
358LIMIT $2 OFFSET $3;
359 "#,
360 exercise_id,
361 pagination.limit(),
362 pagination.offset(),
363 )
364 .fetch_all(conn)
365 .await?;
366 Ok(submissions)
367}
368
369#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
372pub struct UserCourseSubmissionTime {
373 pub created_at: DateTime<Utc>,
374 pub exercise_id: Uuid,
375 pub course_module_id: Option<Uuid>,
377}
378
379pub async fn get_user_course_submission_times(
384 conn: &mut PgConnection,
385 user_id: Uuid,
386 course_id: Uuid,
387) -> ModelResult<Vec<UserCourseSubmissionTime>> {
388 let submissions = sqlx::query_as!(
389 UserCourseSubmissionTime,
390 r#"
391SELECT ess.created_at,
392 ess.exercise_id,
393 c.course_module_id AS "course_module_id?"
394FROM exercise_slide_submissions ess
395 JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
396 LEFT JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
397WHERE ess.user_id = $1
398 AND ess.course_id = $2
399 AND ess.deleted_at IS NULL
400ORDER BY ess.created_at
401LIMIT 5001
402 "#,
403 user_id,
404 course_id,
405 )
406 .fetch_all(conn)
407 .await?;
408 Ok(submissions)
409}
410
411pub async fn get_users_all_submissions_for_course_or_exam(
412 conn: &mut PgConnection,
413 user_id: Uuid,
414 course_or_exam_id: CourseOrExamId,
415) -> ModelResult<Vec<ExerciseSlideSubmission>> {
416 let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
417 let submissions = sqlx::query_as!(
418 ExerciseSlideSubmission,
419 r#"
420SELECT *
421FROM exercise_slide_submissions
422WHERE user_id = $1
423 AND (course_id = $2 OR exam_id = $3)
424 AND deleted_at IS NULL
425 "#,
426 user_id,
427 course_id,
428 exam_id,
429 )
430 .fetch_all(conn)
431 .await?;
432 Ok(submissions)
433}
434
435pub async fn get_exercise_ids_with_submissions_for_user(
446 conn: &mut PgConnection,
447 exercise_ids: &[Uuid],
448 user_id: Uuid,
449 course_id: Uuid,
450) -> ModelResult<Vec<Uuid>> {
451 let exercise_ids = sqlx::query_scalar!(
452 r#"
453 SELECT DISTINCT exercise_id
454 FROM exercise_slide_submissions
455 WHERE exercise_id = ANY($1)
456 AND user_id = $2
457 AND course_id = $3
458 AND deleted_at IS NULL
459 "#,
460 exercise_ids,
461 user_id,
462 course_id,
463 )
464 .fetch_all(conn)
465 .await?;
466
467 Ok(exercise_ids)
468}
469
470#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct ExerciseWithSubmissions {
473 pub exercise_id: Uuid,
474 pub page_id: Uuid,
475}
476
477pub async fn get_exercises_with_submissions_by_course_id(
479 conn: &mut PgConnection,
480 course_id: Uuid,
481) -> ModelResult<Vec<ExerciseWithSubmissions>> {
482 let res = sqlx::query_as!(
483 ExerciseWithSubmissions,
484 r#"
485SELECT e.id AS exercise_id,
486 e.page_id
487FROM exercises e
488WHERE e.course_id = $1
489 AND e.deleted_at IS NULL
490 AND EXISTS (
491 SELECT 1
492 FROM exercise_slide_submissions ess
493 WHERE ess.exercise_id = e.id
494 AND ess.deleted_at IS NULL
495 )
496 "#,
497 course_id,
498 )
499 .fetch_all(conn)
500 .await?;
501 Ok(res)
502}
503
504pub async fn get_exercise_ids_with_submissions_by_course_id(
506 conn: &mut PgConnection,
507 course_id: Uuid,
508 exercise_ids: &[Uuid],
509) -> ModelResult<Vec<Uuid>> {
510 let res = sqlx::query_scalar!(
511 r#"
512SELECT e.id
513FROM exercises e
514WHERE e.course_id = $1
515 AND e.id = ANY($2)
516 AND e.deleted_at IS NULL
517 AND EXISTS (
518 SELECT 1
519 FROM exercise_slide_submissions ess
520 WHERE ess.exercise_id = e.id
521 AND ess.deleted_at IS NULL
522 )
523 "#,
524 course_id,
525 exercise_ids,
526 )
527 .fetch_all(conn)
528 .await?;
529 Ok(res)
530}
531
532pub async fn get_exercise_ids_with_any_submissions(
538 conn: &mut PgConnection,
539 page_id: Uuid,
540 exercise_ids: &[Uuid],
541) -> ModelResult<Vec<Uuid>> {
542 let exercise_ids = sqlx::query_scalar!(
545 r#"
546 SELECT DISTINCT ess.exercise_id
547 FROM exercise_slide_submissions ess
548 JOIN exercises e ON e.id = ess.exercise_id
549 WHERE ess.exercise_id = ANY($1)
550 AND e.page_id = $2
551 AND e.deleted_at IS NULL
552 AND ess.deleted_at IS NULL
553 "#,
554 exercise_ids,
555 page_id,
556 )
557 .fetch_all(conn)
558 .await?;
559
560 Ok(exercise_ids)
561}
562
563pub async fn get_users_submissions_for_exercise(
564 conn: &mut PgConnection,
565 user_id: Uuid,
566 exercise_id: Uuid,
567) -> ModelResult<Vec<ExerciseSlideSubmission>> {
568 let submissions = sqlx::query_as!(
569 ExerciseSlideSubmission,
570 r#"
571SELECT *
572FROM exercise_slide_submissions
573WHERE user_id = $1
574 AND exercise_id = $2
575 AND deleted_at IS NULL
576ORDER BY created_at DESC, id DESC
577 "#,
578 user_id,
579 exercise_id,
580 )
581 .fetch_all(conn)
582 .await?;
583 Ok(submissions)
584}
585
586#[derive(Debug, Clone, PartialEq)]
591pub struct UserExerciseSlideSubmissionWithGrading {
592 pub id: Uuid,
593 pub exercise_id: Uuid,
594 pub created_at: DateTime<Utc>,
595 pub score_given: Option<f32>,
596 pub grading_progress: Option<GradingProgress>,
597}
598
599pub async fn get_users_submissions_for_exercise_with_gradings(
604 conn: &mut PgConnection,
605 user_id: Uuid,
606 exercise_id: Uuid,
607) -> ModelResult<Vec<UserExerciseSlideSubmissionWithGrading>> {
608 let submissions = sqlx::query_as!(
609 UserExerciseSlideSubmissionWithGrading,
610 r#"
611SELECT DISTINCT ON (ess.created_at, ess.id)
612 ess.id,
613 ess.exercise_id,
614 ess.created_at,
615 etg.score_given,
616 etg.grading_progress AS "grading_progress?: GradingProgress"
617FROM exercise_slide_submissions AS ess
618 LEFT JOIN exercise_task_submissions AS ets ON ets.exercise_slide_submission_id = ess.id
619 AND ets.deleted_at IS NULL
620 LEFT JOIN exercise_task_gradings AS etg ON etg.id = ets.exercise_task_grading_id
621 AND etg.deleted_at IS NULL
622WHERE ess.user_id = $1
623 AND ess.exercise_id = $2
624 AND ess.deleted_at IS NULL
625ORDER BY ess.created_at DESC, ess.id DESC, ets.created_at
626 "#,
627 user_id,
628 exercise_id,
629 )
630 .fetch_all(conn)
631 .await?;
632 Ok(submissions)
633}
634
635pub async fn get_users_latest_exercise_slide_submission(
636 conn: &mut PgConnection,
637 exercise_slide_id: Uuid,
638 user_id: Uuid,
639) -> ModelResult<ExerciseSlideSubmission> {
640 let res = sqlx::query_as!(
641 ExerciseSlideSubmission,
642 r#"
643SELECT *
644FROM exercise_slide_submissions
645WHERE exercise_slide_id = $1
646 AND user_id = $2
647 AND deleted_at IS NULL
648ORDER BY created_at DESC
649LIMIT 1
650 "#,
651 exercise_slide_id,
652 user_id
653 )
654 .fetch_one(conn)
655 .await?;
656 Ok(res)
657}
658
659pub async fn try_to_get_users_latest_exercise_slide_submission(
660 conn: &mut PgConnection,
661 exercise_slide_id: Uuid,
662 user_id: Uuid,
663) -> ModelResult<Option<ExerciseSlideSubmission>> {
664 get_users_latest_exercise_slide_submission(conn, exercise_slide_id, user_id)
665 .await
666 .optional()
667}
668
669pub async fn get_course_and_exam_id(
670 conn: &mut PgConnection,
671 id: Uuid,
672) -> ModelResult<CourseOrExamId> {
673 let res = sqlx::query!(
674 "
675SELECT *
676FROM exercise_slide_submissions
677WHERE id = $1
678 AND deleted_at IS NULL
679 ",
680 id
681 )
682 .fetch_one(conn)
683 .await?;
684 CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
685}
686
687pub async fn exercise_slide_submission_count(
688 conn: &mut PgConnection,
689 exercise_id: Uuid,
690) -> ModelResult<u32> {
691 let count = sqlx::query!(
692 "
693SELECT COUNT(*) as count
694FROM exercise_slide_submissions
695WHERE exercise_id = $1
696AND deleted_at IS NULL
697",
698 exercise_id,
699 )
700 .fetch_one(conn)
701 .await?;
702 Ok(count.count.unwrap_or(0).try_into()?)
703}
704
705pub async fn exercise_slide_submissions(
706 conn: &mut PgConnection,
707 exercise_id: Uuid,
708 pagination: Pagination,
709) -> ModelResult<Vec<ExerciseSlideSubmission>> {
710 let submissions = sqlx::query_as!(
711 ExerciseSlideSubmission,
712 r#"
713SELECT *
714FROM exercise_slide_submissions
715WHERE exercise_id = $1
716 AND deleted_at IS NULL
717ORDER BY created_at DESC
718LIMIT $2 OFFSET $3
719 "#,
720 exercise_id,
721 pagination.limit(),
722 pagination.offset(),
723 )
724 .fetch_all(conn)
725 .await?;
726 Ok(submissions)
727}
728
729pub async fn exercise_slide_submission_count_with_exam_id(
730 conn: &mut PgConnection,
731 exam_id: Uuid,
732) -> ModelResult<u32> {
733 let count = sqlx::query!(
734 "
735SELECT COUNT(*) as count
736FROM exercise_slide_submissions
737WHERE exam_id = $1
738AND deleted_at IS NULL
739",
740 exam_id,
741 )
742 .fetch_one(conn)
743 .await?;
744 Ok(count.count.unwrap_or(0).try_into()?)
745}
746
747pub async fn exercise_slide_submission_count_with_exercise_id(
748 conn: &mut PgConnection,
749 exercise_id: Uuid,
750) -> ModelResult<u32> {
751 let count = sqlx::query!(
752 "
753SELECT COUNT(*) as count
754FROM exercise_slide_submissions
755WHERE exercise_id = $1
756AND deleted_at IS NULL
757",
758 exercise_id,
759 )
760 .fetch_one(conn)
761 .await?;
762 Ok(count.count.unwrap_or(0).try_into()?)
763}
764
765pub async fn get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
766 conn: &mut PgConnection,
767 exercise_id: Uuid,
768 pagination: Pagination,
769) -> ModelResult<Vec<ExerciseSlideSubmissionAndUserExerciseState>> {
770 let submissions = sqlx::query_as!(
771 ExerciseSlideSubmission,
772 r#"
773 SELECT DISTINCT ON (user_id)
774 id,
775 created_at,
776 updated_at,
777 deleted_at,
778 exercise_slide_id,
779 course_id,
780 exam_id,
781 exercise_id,
782 user_id,
783 user_points_update_strategy,
784 flag_count
785FROM exercise_slide_submissions
786WHERE exercise_id = $1
787 AND deleted_at IS NULL
788ORDER BY user_id, created_at DESC
789LIMIT $2 OFFSET $3
790 "#,
791 exercise_id,
792 pagination.limit(),
793 pagination.offset(),
794 )
795 .fetch_all(&mut *conn)
796 .await?;
797
798 let user_ids = submissions
799 .iter()
800 .map(|sub| sub.user_id)
801 .collect::<Vec<_>>();
802
803 let exercise = exercises::get_by_id(conn, exercise_id).await?;
804 let exam_id = exercise.exam_id;
805 let course_id = exercise.course_id;
806
807 let user_exercise_states_list =
808 user_exercise_states::get_or_create_user_exercise_state_for_users(
809 conn,
810 &user_ids,
811 exercise_id,
812 course_id,
813 exam_id,
814 )
815 .await?;
816
817 let mut user_exercise_state_id_list: Vec<Uuid> = Vec::new();
818
819 for (_key, value) in user_exercise_states_list.clone().into_iter() {
820 user_exercise_state_id_list.push(value.id);
821 }
822
823 let exercise = exercises::get_by_id(conn, exercise_id).await?;
824 let exam_id = exercise
825 .exam_id
826 .ok_or_else(|| ModelError::new(ModelErrorType::Generic, "No exam id found", None))?;
827
828 let teacher_grading_decisions_list = teacher_grading_decisions::try_to_get_latest_grading_decision_by_user_exercise_state_id_for_users(conn, &user_exercise_state_id_list).await?;
829
830 let user_exam_enrollments_list =
831 exams::get_exam_enrollments_for_users(conn, exam_id, &user_ids).await?;
832
833 let mut list: Vec<ExerciseSlideSubmissionAndUserExerciseState> = Vec::new();
834 for sub in submissions {
835 let user_exercise_state = user_exercise_states_list
836 .get(&sub.user_id)
837 .ok_or_else(|| ModelError::new(ModelErrorType::Generic, "No user found", None))?;
838
839 let teacher_grading_decision = teacher_grading_decisions_list.get(&user_exercise_state.id);
840 let user_exam_enrollment =
841 user_exam_enrollments_list
842 .get(&sub.user_id)
843 .ok_or_else(|| {
844 ModelError::new(
845 ModelErrorType::Generic,
846 "No users exam_enrollment found",
847 None,
848 )
849 })?;
850
851 if user_exam_enrollment.ended_at.is_some() {
853 let data = ExerciseSlideSubmissionAndUserExerciseState {
854 exercise: exercise.clone(),
855 exercise_slide_submission: sub,
856 user_exercise_state: user_exercise_state.clone(),
857 teacher_grading_decision: teacher_grading_decision.cloned(),
858 user_exam_enrollment: user_exam_enrollment.clone(),
859 };
860 list.push(data);
861 }
862 }
863
864 Ok(list)
865}
866
867pub async fn get_course_daily_slide_submission_counts(
868 conn: &mut PgConnection,
869 course: &Course,
870) -> ModelResult<Vec<ExerciseSlideSubmissionCount>> {
871 let res = sqlx::query_as!(
872 ExerciseSlideSubmissionCount,
873 r#"
874SELECT DATE(created_at) date, count(*)::integer
875FROM exercise_slide_submissions
876WHERE course_id = $1
877AND deleted_at IS NULL
878GROUP BY date
879ORDER BY date;
880 "#,
881 course.id
882 )
883 .fetch_all(conn)
884 .await?;
885 Ok(res)
886}
887
888pub async fn get_course_daily_user_counts_with_submissions(
889 conn: &mut PgConnection,
890 course: &Course,
891) -> ModelResult<Vec<ExerciseSlideSubmissionCount>> {
892 let res = sqlx::query_as!(
893 ExerciseSlideSubmissionCount,
894 r#"
895SELECT DATE(created_at) date, count(DISTINCT user_id)::integer
896FROM exercise_slide_submissions
897WHERE course_id = $1
898AND deleted_at IS NULL
899GROUP BY date
900ORDER BY date;
901 "#,
902 course.id
903 )
904 .fetch_all(conn)
905 .await?;
906 Ok(res)
907}
908
909pub async fn answer_requiring_attention_count(
910 conn: &mut PgConnection,
911 exercise_id: Uuid,
912) -> ModelResult<u32> {
913 let count = sqlx::query!(
914 r#"
915 SELECT
916 COUNT(DISTINCT us_state.user_id) as count
917 FROM user_exercise_states AS us_state
918 JOIN exercise_task_submissions AS t_submission
919 ON us_state.selected_exercise_slide_id =
920 t_submission.exercise_slide_id
921 JOIN exercise_slide_submissions AS s_submission
922 ON t_submission.exercise_slide_submission_id =
923 s_submission.id
924 WHERE us_state.selected_exercise_slide_id =
925 t_submission.exercise_slide_id
926 AND us_state.user_id = s_submission.user_id
927 AND us_state.exercise_id = $1
928 AND us_state.reviewing_stage = 'waiting_for_manual_grading'
929 AND us_state.deleted_at IS NULL
930 AND s_submission.deleted_at IS NULL
931 AND t_submission.deleted_at IS NULL"#,
932 exercise_id,
933 )
934 .fetch_one(conn)
935 .await?;
936 Ok(count.count.unwrap_or(0).try_into()?)
937}
938
939pub async fn get_count_of_answers_requiring_attention_in_exercise_by_course_id(
940 conn: &mut PgConnection,
941 course_id: Uuid,
942) -> ModelResult<Vec<ExerciseAnswersInCourseRequiringAttentionCount>> {
943 let count_list = sqlx::query_as!(
944 ExerciseAnswersInCourseRequiringAttentionCount,
945 r#"
946SELECT exercises.id,
947 (
948 SELECT COUNT(DISTINCT us_state.user_id)::integer AS COUNT
949 FROM exercises AS exercises2
950 LEFT JOIN user_exercise_states AS us_state ON us_state.exercise_id = exercises2.id
951 LEFT JOIN exercise_slide_submissions AS s_submission ON us_state.selected_exercise_slide_id = s_submission.exercise_slide_id
952 LEFT JOIN exercise_task_submissions AS t_submission ON s_submission.id = t_submission.exercise_slide_submission_id
953 WHERE us_state.selected_exercise_slide_id = t_submission.exercise_slide_id
954 AND us_state.user_id = s_submission.user_id
955 AND us_state.reviewing_stage = 'waiting_for_manual_grading'
956 AND us_state.deleted_at IS NULL
957 AND s_submission.deleted_at IS NULL
958 AND t_submission.deleted_at IS NULL
959 AND exercises2.course_id = $1
960 AND exercises.id = exercises2.id
961 GROUP BY exercises2.id
962 ),
963 exercises.order_number,
964 exercises.name,
965 exercises.page_id,
966 exercises.chapter_id
967FROM exercises
968WHERE exercises.course_id = $1
969 AND exercises.deleted_at IS NULL
970GROUP BY exercises.id;
971"#,
972 course_id,
973 )
974 .fetch_all(conn)
975 .await?;
976 Ok(count_list)
977}
978
979pub async fn exercise_slide_submissions_for_answers_requiring_attention(
980 conn: &mut PgConnection,
981 exercise_id: Uuid,
982 pagination: Pagination,
983) -> ModelResult<Vec<ExerciseSlideSubmission>> {
984 let submissions = sqlx::query_as!(
985 ExerciseSlideSubmission,
986 r#"
987SELECT *
988FROM exercise_slide_submissions
989WHERE exercise_id = $1
990 AND deleted_at IS NULL
991LIMIT $2 OFFSET $3
992 "#,
993 exercise_id,
994 pagination.limit(),
995 pagination.offset(),
996 )
997 .fetch_all(conn)
998 .await?;
999 Ok(submissions)
1000}
1001
1002pub async fn get_all_answers_requiring_attention(
1003 conn: &mut PgConnection,
1004 exercise_id: Uuid,
1005 pagination: Pagination,
1006 file_store: &dyn FileStore,
1007 app_conf: &ApplicationConfiguration,
1008) -> ModelResult<Vec<AnswerRequiringAttention>> {
1009 let rows = sqlx::query!(
1010 r#"
1011 SELECT DISTINCT ON (us_state.user_id)
1012 us_state.id,
1013 us_state.user_id,
1014 us_state.exercise_id,
1015 us_state.score_given,
1016 us_state.grading_progress AS "grading_progress: GradingProgress",
1017 t_submission.id AS task_submission_id,
1018 t_submission.data_json,
1019 t_submission.answer_kind,
1020 s_submission.created_at,
1021 s_submission.updated_at,
1022 s_submission.deleted_at,
1023 s_submission.id AS submission_id
1024 FROM user_exercise_states AS us_state
1025 JOIN exercise_task_submissions AS t_submission
1026 ON us_state.selected_exercise_slide_id =
1027 t_submission.exercise_slide_id
1028 JOIN exercise_slide_submissions AS s_submission
1029 ON t_submission.exercise_slide_submission_id =
1030 s_submission.id
1031 WHERE us_state.selected_exercise_slide_id =
1032 t_submission.exercise_slide_id
1033 AND us_state.user_id = s_submission.user_id
1034 AND us_state.exercise_id = $1
1035 AND us_state.reviewing_stage = 'waiting_for_manual_grading'
1036 AND us_state.deleted_at IS NULL
1037 AND s_submission.deleted_at IS NULL
1038 AND t_submission.deleted_at IS NULL
1039 ORDER BY us_state.user_id, s_submission.created_at DESC
1040 LIMIT $2 OFFSET $3;"#,
1041 exercise_id,
1042 pagination.limit(),
1043 pagination.offset(),
1044 )
1045 .fetch_all(&mut *conn)
1046 .await?;
1047 let stored: Vec<StoredAnswer> = rows
1048 .iter()
1049 .map(|row| StoredAnswer {
1050 submission_id: row.task_submission_id,
1051 answer_kind: row.answer_kind,
1052 data_json: row.data_json.clone(),
1053 })
1054 .collect();
1055 let mut answers = attach_answer_data(conn, &stored, file_store, app_conf).await?;
1056 Ok(rows
1057 .into_iter()
1058 .map(|row| AnswerRequiringAttention {
1059 id: row.id,
1060 user_id: row.user_id,
1061 created_at: row.created_at,
1062 updated_at: row.updated_at,
1063 deleted_at: row.deleted_at,
1064 answer: answers.remove(&row.task_submission_id),
1065 grading_progress: row.grading_progress,
1066 score_given: row.score_given,
1067 submission_id: row.submission_id,
1068 exercise_id: row.exercise_id,
1069 })
1070 .collect())
1071}
1072
1073pub async fn get_course_exercise_slide_submission_counts_by_weekday_and_hour(
1074 conn: &mut PgConnection,
1075 course: &Course,
1076) -> ModelResult<Vec<ExerciseSlideSubmissionCountByWeekAndHour>> {
1077 let res = sqlx::query_as!(
1078 ExerciseSlideSubmissionCountByWeekAndHour,
1079 r#"
1080SELECT date_part('isodow', created_at)::integer isodow,
1081 date_part('hour', created_at)::integer "hour",
1082 count(*)::integer
1083FROM exercise_slide_submissions
1084WHERE course_id = $1
1085AND deleted_at IS NULL
1086GROUP BY isodow,
1087 "hour"
1088ORDER BY isodow,
1089 hour;
1090 "#,
1091 course.id
1092 )
1093 .fetch_all(conn)
1094 .await?;
1095 Ok(res)
1096}
1097
1098pub async fn get_course_exercise_slide_submission_counts_by_exercise(
1099 conn: &mut PgConnection,
1100 course: &Course,
1101) -> ModelResult<Vec<ExerciseSlideSubmissionCountByExercise>> {
1102 let res = sqlx::query_as!(
1103 ExerciseSlideSubmissionCountByExercise,
1104 r#"
1105SELECT counts.*, exercises.name exercise_name
1106 FROM (
1107 SELECT exercise_id, count(*)::integer count
1108 FROM exercise_slide_submissions
1109 WHERE course_id = $1
1110 AND deleted_at IS NULL
1111 GROUP BY exercise_id
1112 ) counts
1113 JOIN exercises ON (counts.exercise_id = exercises.id);
1114 "#,
1115 course.id
1116 )
1117 .fetch_all(conn)
1118 .await?;
1119 Ok(res)
1120}
1121
1122pub async fn get_exercise_slide_submission_counts_for_exercise_user(
1123 conn: &mut PgConnection,
1124 exercise_id: Uuid,
1125 course_id_or_exam_id: CourseOrExamId,
1126 user_id: Uuid,
1127) -> ModelResult<HashMap<Uuid, i64>> {
1128 let ci_id_or_e_id = match course_id_or_exam_id {
1129 CourseOrExamId::Course(id) => id,
1130 CourseOrExamId::Exam(id) => id,
1131 };
1132 let res = sqlx::query!(
1133 r#"
1134SELECT exercise_slide_id,
1135 COUNT(*) as count
1136FROM exercise_slide_submissions
1137WHERE exercise_id = $1
1138 AND (course_id = $2 OR exam_id = $2)
1139 AND user_id = $3
1140 AND deleted_at IS NULL
1141GROUP BY exercise_slide_id;
1142 "#,
1143 exercise_id,
1144 ci_id_or_e_id,
1145 user_id
1146 )
1147 .fetch_all(conn)
1148 .await?
1149 .iter()
1150 .map(|row| (row.exercise_slide_id, row.count.unwrap_or(0)))
1151 .collect::<HashMap<Uuid, i64>>();
1152
1153 Ok(res)
1154}
1155
1156pub async fn get_exercise_slide_submission_info(
1157 conn: &mut PgConnection,
1158 exercise_slide_submission_id: Uuid,
1159 user_id: Uuid,
1160 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1161 include_deleted_tasks: bool,
1162 file_store: &dyn FileStore,
1163 app_conf: &ApplicationConfiguration,
1164) -> ModelResult<ExerciseSlideSubmissionInfo> {
1165 let exercise_slide_submission = get_by_id(&mut *conn, exercise_slide_submission_id).await?;
1166 let exercise =
1167 crate::exercises::get_by_id(&mut *conn, exercise_slide_submission.exercise_id).await?;
1168 let tasks = crate::exercise_task_submissions::get_exercise_task_submission_info_by_exercise_slide_submission_id(&mut *conn, exercise_slide_submission_id, user_id, fetch_service_info, include_deleted_tasks, file_store, app_conf).await?;
1169 let user_exercise_state = crate::user_exercise_states::get_user_exercise_state_if_exists(
1170 &mut *conn,
1171 user_id,
1172 exercise_slide_submission.exercise_id,
1173 CourseOrExamId::from_course_and_exam_ids(
1174 exercise_slide_submission.course_id,
1175 exercise_slide_submission.exam_id,
1176 )?,
1177 )
1178 .await?;
1179
1180 Ok(ExerciseSlideSubmissionInfo {
1181 exercise,
1182 tasks,
1183 exercise_slide_submission,
1184 user_exercise_state,
1185 })
1186}
1187
1188pub async fn delete_exercise_submissions_with_exam_id_and_user_id(
1189 conn: &mut PgConnection,
1190 exam_id: Uuid,
1191 user_id: Uuid,
1192) -> ModelResult<()> {
1193 sqlx::query!(
1194 "
1195UPDATE exercise_slide_submissions
1196SET deleted_at = now()
1197WHERE exam_id = $1 AND user_id = $2
1198AND deleted_at IS NULL
1199 ",
1200 exam_id,
1201 user_id,
1202 )
1203 .execute(&mut *conn)
1204 .await?;
1205 Ok(())
1206}
1207
1208pub async fn exercise_slide_submission_count_with_exercise_and_user_ids(
1210 conn: &mut PgConnection,
1211 exercise_id: Uuid,
1212 user_id: Uuid,
1213) -> ModelResult<u32> {
1214 let count = sqlx::query_scalar!(
1215 r#"
1216SELECT count(*) AS "count!"
1217FROM exercise_slide_submissions
1218WHERE exercise_id = $1
1219 AND user_id = $2
1220 AND deleted_at IS NULL
1221"#,
1222 exercise_id,
1223 user_id
1224 )
1225 .fetch_one(conn)
1226 .await?;
1227 Ok(count.try_into()?)
1228}
1229
1230pub async fn delete_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
1232 sqlx::query!(
1233 "UPDATE exercise_slide_submissions SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL",
1234 id
1235 )
1236 .execute(conn)
1237 .await?;
1238 Ok(())
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use super::*;
1244 use crate::exercise_tasks::CourseMaterialExerciseTask;
1245 use crate::exercises::ActivityProgress;
1246 use crate::user_exercise_states::ReviewingStage;
1247
1248 fn dummy_exercise() -> Exercise {
1249 Exercise {
1250 id: Uuid::new_v4(),
1251 created_at: Utc::now(),
1252 updated_at: Utc::now(),
1253 name: "Test exercise".to_string(),
1254 course_id: Some(Uuid::new_v4()),
1255 exam_id: None,
1256 page_id: Uuid::new_v4(),
1257 chapter_id: None,
1258 deadline: None,
1259 deleted_at: None,
1260 score_maximum: 1,
1261 order_number: 0,
1262 copied_from: None,
1263 max_tries_per_slide: None,
1264 limit_number_of_tries: false,
1265 needs_peer_review: false,
1266 needs_self_review: false,
1267 use_course_default_peer_or_self_review_config: false,
1268 exercise_language_group_id: None,
1269 teacher_reviews_answer_after_locking: false,
1270 }
1271 }
1272
1273 fn dummy_slide_submission(user_id: Uuid) -> ExerciseSlideSubmission {
1274 ExerciseSlideSubmission {
1275 id: Uuid::new_v4(),
1276 created_at: Utc::now(),
1277 updated_at: Utc::now(),
1278 deleted_at: None,
1279 exercise_slide_id: Uuid::new_v4(),
1280 course_id: Some(Uuid::new_v4()),
1281 exam_id: None,
1282 exercise_id: Uuid::new_v4(),
1283 user_id,
1284 user_points_update_strategy:
1285 UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
1286 flag_count: None,
1287 }
1288 }
1289
1290 fn dummy_user_exercise_state(user_id: Uuid) -> UserExerciseState {
1291 UserExerciseState {
1292 id: Uuid::new_v4(),
1293 user_id,
1294 exercise_id: Uuid::new_v4(),
1295 course_id: Some(Uuid::new_v4()),
1296 exam_id: None,
1297 created_at: Utc::now(),
1298 updated_at: Utc::now(),
1299 deleted_at: None,
1300 score_given: Some(1.0),
1301 grading_progress: GradingProgress::FullyGraded,
1302 activity_progress: ActivityProgress::Completed,
1303 reviewing_stage: ReviewingStage::ReviewedAndLocked,
1304 selected_exercise_slide_id: Some(Uuid::new_v4()),
1305 }
1306 }
1307
1308 fn identifiers_in(info: &ExerciseSlideSubmissionInfo) -> Vec<String> {
1311 let mut found = Vec::new();
1312 if info.exercise_slide_submission.user_id != Uuid::nil() {
1313 found.push("exercise_slide_submission.user_id".to_string());
1314 }
1315 if info.user_exercise_state.is_some() {
1316 found.push("user_exercise_state".to_string());
1317 }
1318 for (index, task) in info.tasks.iter().enumerate() {
1319 if task.pseudonumous_user_id.is_some() {
1320 found.push(format!("tasks[{index}].pseudonumous_user_id"));
1321 }
1322 }
1323 found
1324 }
1325
1326 fn dummy_task_with_model_solution() -> CourseMaterialExerciseTask {
1327 CourseMaterialExerciseTask {
1328 id: Uuid::new_v4(),
1329 exercise_service_slug: "tmc".to_string(),
1330 exercise_slide_id: Uuid::new_v4(),
1331 exercise_iframe_url: None,
1332 pseudonumous_user_id: Some(Uuid::new_v4()),
1333 assignment: serde_json::json!([]),
1334 public_spec: Some(serde_json::json!({ "public": true })),
1335 model_solution_spec: Some(serde_json::json!({ "solution": "the answer" })),
1336 previous_submission: None,
1337 previous_submission_grading: None,
1338 order_number: 0,
1339 deleted_at: None,
1340 }
1341 }
1342
1343 #[test]
1345 fn strip_for_shared_view_removes_model_solution_and_user_id() {
1346 let user_id = Uuid::new_v4();
1347 let mut info = ExerciseSlideSubmissionInfo {
1348 tasks: vec![
1349 dummy_task_with_model_solution(),
1350 dummy_task_with_model_solution(),
1351 ],
1352 exercise: dummy_exercise(),
1353 exercise_slide_submission: dummy_slide_submission(user_id),
1354 user_exercise_state: Some(dummy_user_exercise_state(user_id)),
1355 };
1356
1357 assert!(
1358 info.tasks.iter().all(|t| t.model_solution_spec.is_some()),
1359 "test fixture should start with model solutions present"
1360 );
1361 assert_eq!(info.exercise_slide_submission.user_id, user_id);
1362
1363 info.strip_for_shared_view();
1364
1365 assert!(
1366 info.tasks.iter().all(|t| t.model_solution_spec.is_none()),
1367 "model_solution_spec must be absent from every task in the shared view"
1368 );
1369 assert_eq!(
1370 info.exercise_slide_submission.user_id,
1371 Uuid::nil(),
1372 "submitter user_id must not be exposed in the shared view"
1373 );
1374 }
1375
1376 #[test]
1380 fn strip_for_shared_view_leaves_no_user_identifier() {
1381 let user_id = Uuid::new_v4();
1382 let mut info = ExerciseSlideSubmissionInfo {
1383 tasks: vec![
1384 dummy_task_with_model_solution(),
1385 dummy_task_with_model_solution(),
1386 ],
1387 exercise: dummy_exercise(),
1388 exercise_slide_submission: dummy_slide_submission(user_id),
1389 user_exercise_state: Some(dummy_user_exercise_state(user_id)),
1390 };
1391
1392 assert_eq!(
1393 identifiers_in(&info),
1394 vec![
1395 "exercise_slide_submission.user_id",
1396 "user_exercise_state",
1397 "tasks[0].pseudonumous_user_id",
1398 "tasks[1].pseudonumous_user_id",
1399 ],
1400 "the fixture must start with every identifier present, or this test proves nothing"
1401 );
1402
1403 info.strip_for_shared_view();
1404
1405 assert_eq!(
1406 identifiers_in(&info),
1407 Vec::<String>::new(),
1408 "no user identifier may survive the strip"
1409 );
1410 }
1411
1412 #[test]
1413 fn strip_for_shared_view_keeps_public_spec() {
1414 let mut info = ExerciseSlideSubmissionInfo {
1415 tasks: vec![dummy_task_with_model_solution()],
1416 exercise: dummy_exercise(),
1417 exercise_slide_submission: dummy_slide_submission(Uuid::new_v4()),
1418 user_exercise_state: Some(dummy_user_exercise_state(Uuid::new_v4())),
1419 };
1420
1421 info.strip_for_shared_view();
1422
1423 assert!(
1424 info.tasks[0].public_spec.is_some(),
1425 "public_spec should remain available in the shared view"
1426 );
1427 }
1428}