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
470pub async fn get_exercise_ids_with_any_submissions(
476 conn: &mut PgConnection,
477 page_id: Uuid,
478 exercise_ids: &[Uuid],
479) -> ModelResult<Vec<Uuid>> {
480 let exercise_ids = sqlx::query_scalar!(
483 r#"
484 SELECT DISTINCT ess.exercise_id
485 FROM exercise_slide_submissions ess
486 JOIN exercises e ON e.id = ess.exercise_id
487 WHERE ess.exercise_id = ANY($1)
488 AND e.page_id = $2
489 AND e.deleted_at IS NULL
490 AND ess.deleted_at IS NULL
491 "#,
492 exercise_ids,
493 page_id,
494 )
495 .fetch_all(conn)
496 .await?;
497
498 Ok(exercise_ids)
499}
500
501pub async fn get_users_submissions_for_exercise(
502 conn: &mut PgConnection,
503 user_id: Uuid,
504 exercise_id: Uuid,
505) -> ModelResult<Vec<ExerciseSlideSubmission>> {
506 let submissions = sqlx::query_as!(
507 ExerciseSlideSubmission,
508 r#"
509SELECT *
510FROM exercise_slide_submissions
511WHERE user_id = $1
512 AND exercise_id = $2
513 AND deleted_at IS NULL
514ORDER BY created_at DESC, id DESC
515 "#,
516 user_id,
517 exercise_id,
518 )
519 .fetch_all(conn)
520 .await?;
521 Ok(submissions)
522}
523
524#[derive(Debug, Clone, PartialEq)]
529pub struct UserExerciseSlideSubmissionWithGrading {
530 pub id: Uuid,
531 pub exercise_id: Uuid,
532 pub created_at: DateTime<Utc>,
533 pub score_given: Option<f32>,
534 pub grading_progress: Option<GradingProgress>,
535}
536
537pub async fn get_users_submissions_for_exercise_with_gradings(
542 conn: &mut PgConnection,
543 user_id: Uuid,
544 exercise_id: Uuid,
545) -> ModelResult<Vec<UserExerciseSlideSubmissionWithGrading>> {
546 let submissions = sqlx::query_as!(
547 UserExerciseSlideSubmissionWithGrading,
548 r#"
549SELECT DISTINCT ON (ess.created_at, ess.id)
550 ess.id,
551 ess.exercise_id,
552 ess.created_at,
553 etg.score_given,
554 etg.grading_progress AS "grading_progress?: GradingProgress"
555FROM exercise_slide_submissions AS ess
556 LEFT JOIN exercise_task_submissions AS ets ON ets.exercise_slide_submission_id = ess.id
557 AND ets.deleted_at IS NULL
558 LEFT JOIN exercise_task_gradings AS etg ON etg.id = ets.exercise_task_grading_id
559 AND etg.deleted_at IS NULL
560WHERE ess.user_id = $1
561 AND ess.exercise_id = $2
562 AND ess.deleted_at IS NULL
563ORDER BY ess.created_at DESC, ess.id DESC, ets.created_at
564 "#,
565 user_id,
566 exercise_id,
567 )
568 .fetch_all(conn)
569 .await?;
570 Ok(submissions)
571}
572
573pub async fn get_users_latest_exercise_slide_submission(
574 conn: &mut PgConnection,
575 exercise_slide_id: Uuid,
576 user_id: Uuid,
577) -> ModelResult<ExerciseSlideSubmission> {
578 let res = sqlx::query_as!(
579 ExerciseSlideSubmission,
580 r#"
581SELECT *
582FROM exercise_slide_submissions
583WHERE exercise_slide_id = $1
584 AND user_id = $2
585 AND deleted_at IS NULL
586ORDER BY created_at DESC
587LIMIT 1
588 "#,
589 exercise_slide_id,
590 user_id
591 )
592 .fetch_one(conn)
593 .await?;
594 Ok(res)
595}
596
597pub async fn try_to_get_users_latest_exercise_slide_submission(
598 conn: &mut PgConnection,
599 exercise_slide_id: Uuid,
600 user_id: Uuid,
601) -> ModelResult<Option<ExerciseSlideSubmission>> {
602 get_users_latest_exercise_slide_submission(conn, exercise_slide_id, user_id)
603 .await
604 .optional()
605}
606
607pub async fn get_course_and_exam_id(
608 conn: &mut PgConnection,
609 id: Uuid,
610) -> ModelResult<CourseOrExamId> {
611 let res = sqlx::query!(
612 "
613SELECT *
614FROM exercise_slide_submissions
615WHERE id = $1
616 AND deleted_at IS NULL
617 ",
618 id
619 )
620 .fetch_one(conn)
621 .await?;
622 CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
623}
624
625pub async fn exercise_slide_submission_count(
626 conn: &mut PgConnection,
627 exercise_id: Uuid,
628) -> ModelResult<u32> {
629 let count = sqlx::query!(
630 "
631SELECT COUNT(*) as count
632FROM exercise_slide_submissions
633WHERE exercise_id = $1
634AND deleted_at IS NULL
635",
636 exercise_id,
637 )
638 .fetch_one(conn)
639 .await?;
640 Ok(count.count.unwrap_or(0).try_into()?)
641}
642
643pub async fn exercise_slide_submissions(
644 conn: &mut PgConnection,
645 exercise_id: Uuid,
646 pagination: Pagination,
647) -> ModelResult<Vec<ExerciseSlideSubmission>> {
648 let submissions = sqlx::query_as!(
649 ExerciseSlideSubmission,
650 r#"
651SELECT *
652FROM exercise_slide_submissions
653WHERE exercise_id = $1
654 AND deleted_at IS NULL
655ORDER BY created_at DESC
656LIMIT $2 OFFSET $3
657 "#,
658 exercise_id,
659 pagination.limit(),
660 pagination.offset(),
661 )
662 .fetch_all(conn)
663 .await?;
664 Ok(submissions)
665}
666
667pub async fn exercise_slide_submission_count_with_exam_id(
668 conn: &mut PgConnection,
669 exam_id: Uuid,
670) -> ModelResult<u32> {
671 let count = sqlx::query!(
672 "
673SELECT COUNT(*) as count
674FROM exercise_slide_submissions
675WHERE exam_id = $1
676AND deleted_at IS NULL
677",
678 exam_id,
679 )
680 .fetch_one(conn)
681 .await?;
682 Ok(count.count.unwrap_or(0).try_into()?)
683}
684
685pub async fn exercise_slide_submission_count_with_exercise_id(
686 conn: &mut PgConnection,
687 exercise_id: Uuid,
688) -> ModelResult<u32> {
689 let count = sqlx::query!(
690 "
691SELECT COUNT(*) as count
692FROM exercise_slide_submissions
693WHERE exercise_id = $1
694AND deleted_at IS NULL
695",
696 exercise_id,
697 )
698 .fetch_one(conn)
699 .await?;
700 Ok(count.count.unwrap_or(0).try_into()?)
701}
702
703pub async fn get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
704 conn: &mut PgConnection,
705 exercise_id: Uuid,
706 pagination: Pagination,
707) -> ModelResult<Vec<ExerciseSlideSubmissionAndUserExerciseState>> {
708 let submissions = sqlx::query_as!(
709 ExerciseSlideSubmission,
710 r#"
711 SELECT DISTINCT ON (user_id)
712 id,
713 created_at,
714 updated_at,
715 deleted_at,
716 exercise_slide_id,
717 course_id,
718 exam_id,
719 exercise_id,
720 user_id,
721 user_points_update_strategy,
722 flag_count
723FROM exercise_slide_submissions
724WHERE exercise_id = $1
725 AND deleted_at IS NULL
726ORDER BY user_id, created_at DESC
727LIMIT $2 OFFSET $3
728 "#,
729 exercise_id,
730 pagination.limit(),
731 pagination.offset(),
732 )
733 .fetch_all(&mut *conn)
734 .await?;
735
736 let user_ids = submissions
737 .iter()
738 .map(|sub| sub.user_id)
739 .collect::<Vec<_>>();
740
741 let exercise = exercises::get_by_id(conn, exercise_id).await?;
742 let exam_id = exercise.exam_id;
743 let course_id = exercise.course_id;
744
745 let user_exercise_states_list =
746 user_exercise_states::get_or_create_user_exercise_state_for_users(
747 conn,
748 &user_ids,
749 exercise_id,
750 course_id,
751 exam_id,
752 )
753 .await?;
754
755 let mut user_exercise_state_id_list: Vec<Uuid> = Vec::new();
756
757 for (_key, value) in user_exercise_states_list.clone().into_iter() {
758 user_exercise_state_id_list.push(value.id);
759 }
760
761 let exercise = exercises::get_by_id(conn, exercise_id).await?;
762 let exam_id = exercise
763 .exam_id
764 .ok_or_else(|| ModelError::new(ModelErrorType::Generic, "No exam id found", None))?;
765
766 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?;
767
768 let user_exam_enrollments_list =
769 exams::get_exam_enrollments_for_users(conn, exam_id, &user_ids).await?;
770
771 let mut list: Vec<ExerciseSlideSubmissionAndUserExerciseState> = Vec::new();
772 for sub in submissions {
773 let user_exercise_state = user_exercise_states_list
774 .get(&sub.user_id)
775 .ok_or_else(|| ModelError::new(ModelErrorType::Generic, "No user found", None))?;
776
777 let teacher_grading_decision = teacher_grading_decisions_list.get(&user_exercise_state.id);
778 let user_exam_enrollment =
779 user_exam_enrollments_list
780 .get(&sub.user_id)
781 .ok_or_else(|| {
782 ModelError::new(
783 ModelErrorType::Generic,
784 "No users exam_enrollment found",
785 None,
786 )
787 })?;
788
789 if user_exam_enrollment.ended_at.is_some() {
791 let data = ExerciseSlideSubmissionAndUserExerciseState {
792 exercise: exercise.clone(),
793 exercise_slide_submission: sub,
794 user_exercise_state: user_exercise_state.clone(),
795 teacher_grading_decision: teacher_grading_decision.cloned(),
796 user_exam_enrollment: user_exam_enrollment.clone(),
797 };
798 list.push(data);
799 }
800 }
801
802 Ok(list)
803}
804
805pub async fn get_course_daily_slide_submission_counts(
806 conn: &mut PgConnection,
807 course: &Course,
808) -> ModelResult<Vec<ExerciseSlideSubmissionCount>> {
809 let res = sqlx::query_as!(
810 ExerciseSlideSubmissionCount,
811 r#"
812SELECT DATE(created_at) date, count(*)::integer
813FROM exercise_slide_submissions
814WHERE course_id = $1
815AND deleted_at IS NULL
816GROUP BY date
817ORDER BY date;
818 "#,
819 course.id
820 )
821 .fetch_all(conn)
822 .await?;
823 Ok(res)
824}
825
826pub async fn get_course_daily_user_counts_with_submissions(
827 conn: &mut PgConnection,
828 course: &Course,
829) -> ModelResult<Vec<ExerciseSlideSubmissionCount>> {
830 let res = sqlx::query_as!(
831 ExerciseSlideSubmissionCount,
832 r#"
833SELECT DATE(created_at) date, count(DISTINCT user_id)::integer
834FROM exercise_slide_submissions
835WHERE course_id = $1
836AND deleted_at IS NULL
837GROUP BY date
838ORDER BY date;
839 "#,
840 course.id
841 )
842 .fetch_all(conn)
843 .await?;
844 Ok(res)
845}
846
847pub async fn answer_requiring_attention_count(
848 conn: &mut PgConnection,
849 exercise_id: Uuid,
850) -> ModelResult<u32> {
851 let count = sqlx::query!(
852 r#"
853 SELECT
854 COUNT(DISTINCT us_state.user_id) as count
855 FROM user_exercise_states AS us_state
856 JOIN exercise_task_submissions AS t_submission
857 ON us_state.selected_exercise_slide_id =
858 t_submission.exercise_slide_id
859 JOIN exercise_slide_submissions AS s_submission
860 ON t_submission.exercise_slide_submission_id =
861 s_submission.id
862 WHERE us_state.selected_exercise_slide_id =
863 t_submission.exercise_slide_id
864 AND us_state.user_id = s_submission.user_id
865 AND us_state.exercise_id = $1
866 AND us_state.reviewing_stage = 'waiting_for_manual_grading'
867 AND us_state.deleted_at IS NULL
868 AND s_submission.deleted_at IS NULL
869 AND t_submission.deleted_at IS NULL"#,
870 exercise_id,
871 )
872 .fetch_one(conn)
873 .await?;
874 Ok(count.count.unwrap_or(0).try_into()?)
875}
876
877pub async fn get_count_of_answers_requiring_attention_in_exercise_by_course_id(
878 conn: &mut PgConnection,
879 course_id: Uuid,
880) -> ModelResult<Vec<ExerciseAnswersInCourseRequiringAttentionCount>> {
881 let count_list = sqlx::query_as!(
882 ExerciseAnswersInCourseRequiringAttentionCount,
883 r#"
884SELECT exercises.id,
885 (
886 SELECT COUNT(DISTINCT us_state.user_id)::integer AS COUNT
887 FROM exercises AS exercises2
888 LEFT JOIN user_exercise_states AS us_state ON us_state.exercise_id = exercises2.id
889 LEFT JOIN exercise_slide_submissions AS s_submission ON us_state.selected_exercise_slide_id = s_submission.exercise_slide_id
890 LEFT JOIN exercise_task_submissions AS t_submission ON s_submission.id = t_submission.exercise_slide_submission_id
891 WHERE us_state.selected_exercise_slide_id = t_submission.exercise_slide_id
892 AND us_state.user_id = s_submission.user_id
893 AND us_state.reviewing_stage = 'waiting_for_manual_grading'
894 AND us_state.deleted_at IS NULL
895 AND s_submission.deleted_at IS NULL
896 AND t_submission.deleted_at IS NULL
897 AND exercises2.course_id = $1
898 AND exercises.id = exercises2.id
899 GROUP BY exercises2.id
900 ),
901 exercises.order_number,
902 exercises.name,
903 exercises.page_id,
904 exercises.chapter_id
905FROM exercises
906WHERE exercises.course_id = $1
907 AND exercises.deleted_at IS NULL
908GROUP BY exercises.id;
909"#,
910 course_id,
911 )
912 .fetch_all(conn)
913 .await?;
914 Ok(count_list)
915}
916
917pub async fn exercise_slide_submissions_for_answers_requiring_attention(
918 conn: &mut PgConnection,
919 exercise_id: Uuid,
920 pagination: Pagination,
921) -> ModelResult<Vec<ExerciseSlideSubmission>> {
922 let submissions = sqlx::query_as!(
923 ExerciseSlideSubmission,
924 r#"
925SELECT *
926FROM exercise_slide_submissions
927WHERE exercise_id = $1
928 AND deleted_at IS NULL
929LIMIT $2 OFFSET $3
930 "#,
931 exercise_id,
932 pagination.limit(),
933 pagination.offset(),
934 )
935 .fetch_all(conn)
936 .await?;
937 Ok(submissions)
938}
939
940pub async fn get_all_answers_requiring_attention(
941 conn: &mut PgConnection,
942 exercise_id: Uuid,
943 pagination: Pagination,
944 file_store: &dyn FileStore,
945 app_conf: &ApplicationConfiguration,
946) -> ModelResult<Vec<AnswerRequiringAttention>> {
947 let rows = sqlx::query!(
948 r#"
949 SELECT DISTINCT ON (us_state.user_id)
950 us_state.id,
951 us_state.user_id,
952 us_state.exercise_id,
953 us_state.score_given,
954 us_state.grading_progress AS "grading_progress: GradingProgress",
955 t_submission.id AS task_submission_id,
956 t_submission.data_json,
957 t_submission.answer_kind,
958 s_submission.created_at,
959 s_submission.updated_at,
960 s_submission.deleted_at,
961 s_submission.id AS submission_id
962 FROM user_exercise_states AS us_state
963 JOIN exercise_task_submissions AS t_submission
964 ON us_state.selected_exercise_slide_id =
965 t_submission.exercise_slide_id
966 JOIN exercise_slide_submissions AS s_submission
967 ON t_submission.exercise_slide_submission_id =
968 s_submission.id
969 WHERE us_state.selected_exercise_slide_id =
970 t_submission.exercise_slide_id
971 AND us_state.user_id = s_submission.user_id
972 AND us_state.exercise_id = $1
973 AND us_state.reviewing_stage = 'waiting_for_manual_grading'
974 AND us_state.deleted_at IS NULL
975 AND s_submission.deleted_at IS NULL
976 AND t_submission.deleted_at IS NULL
977 ORDER BY us_state.user_id, s_submission.created_at DESC
978 LIMIT $2 OFFSET $3;"#,
979 exercise_id,
980 pagination.limit(),
981 pagination.offset(),
982 )
983 .fetch_all(&mut *conn)
984 .await?;
985 let stored: Vec<StoredAnswer> = rows
986 .iter()
987 .map(|row| StoredAnswer {
988 submission_id: row.task_submission_id,
989 answer_kind: row.answer_kind,
990 data_json: row.data_json.clone(),
991 })
992 .collect();
993 let mut answers = attach_answer_data(conn, &stored, file_store, app_conf).await?;
994 Ok(rows
995 .into_iter()
996 .map(|row| AnswerRequiringAttention {
997 id: row.id,
998 user_id: row.user_id,
999 created_at: row.created_at,
1000 updated_at: row.updated_at,
1001 deleted_at: row.deleted_at,
1002 answer: answers.remove(&row.task_submission_id),
1003 grading_progress: row.grading_progress,
1004 score_given: row.score_given,
1005 submission_id: row.submission_id,
1006 exercise_id: row.exercise_id,
1007 })
1008 .collect())
1009}
1010
1011pub async fn get_course_exercise_slide_submission_counts_by_weekday_and_hour(
1012 conn: &mut PgConnection,
1013 course: &Course,
1014) -> ModelResult<Vec<ExerciseSlideSubmissionCountByWeekAndHour>> {
1015 let res = sqlx::query_as!(
1016 ExerciseSlideSubmissionCountByWeekAndHour,
1017 r#"
1018SELECT date_part('isodow', created_at)::integer isodow,
1019 date_part('hour', created_at)::integer "hour",
1020 count(*)::integer
1021FROM exercise_slide_submissions
1022WHERE course_id = $1
1023AND deleted_at IS NULL
1024GROUP BY isodow,
1025 "hour"
1026ORDER BY isodow,
1027 hour;
1028 "#,
1029 course.id
1030 )
1031 .fetch_all(conn)
1032 .await?;
1033 Ok(res)
1034}
1035
1036pub async fn get_course_exercise_slide_submission_counts_by_exercise(
1037 conn: &mut PgConnection,
1038 course: &Course,
1039) -> ModelResult<Vec<ExerciseSlideSubmissionCountByExercise>> {
1040 let res = sqlx::query_as!(
1041 ExerciseSlideSubmissionCountByExercise,
1042 r#"
1043SELECT counts.*, exercises.name exercise_name
1044 FROM (
1045 SELECT exercise_id, count(*)::integer count
1046 FROM exercise_slide_submissions
1047 WHERE course_id = $1
1048 AND deleted_at IS NULL
1049 GROUP BY exercise_id
1050 ) counts
1051 JOIN exercises ON (counts.exercise_id = exercises.id);
1052 "#,
1053 course.id
1054 )
1055 .fetch_all(conn)
1056 .await?;
1057 Ok(res)
1058}
1059
1060pub async fn get_exercise_slide_submission_counts_for_exercise_user(
1061 conn: &mut PgConnection,
1062 exercise_id: Uuid,
1063 course_id_or_exam_id: CourseOrExamId,
1064 user_id: Uuid,
1065) -> ModelResult<HashMap<Uuid, i64>> {
1066 let ci_id_or_e_id = match course_id_or_exam_id {
1067 CourseOrExamId::Course(id) => id,
1068 CourseOrExamId::Exam(id) => id,
1069 };
1070 let res = sqlx::query!(
1071 r#"
1072SELECT exercise_slide_id,
1073 COUNT(*) as count
1074FROM exercise_slide_submissions
1075WHERE exercise_id = $1
1076 AND (course_id = $2 OR exam_id = $2)
1077 AND user_id = $3
1078 AND deleted_at IS NULL
1079GROUP BY exercise_slide_id;
1080 "#,
1081 exercise_id,
1082 ci_id_or_e_id,
1083 user_id
1084 )
1085 .fetch_all(conn)
1086 .await?
1087 .iter()
1088 .map(|row| (row.exercise_slide_id, row.count.unwrap_or(0)))
1089 .collect::<HashMap<Uuid, i64>>();
1090
1091 Ok(res)
1092}
1093
1094pub async fn get_exercise_slide_submission_info(
1095 conn: &mut PgConnection,
1096 exercise_slide_submission_id: Uuid,
1097 user_id: Uuid,
1098 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
1099 include_deleted_tasks: bool,
1100 file_store: &dyn FileStore,
1101 app_conf: &ApplicationConfiguration,
1102) -> ModelResult<ExerciseSlideSubmissionInfo> {
1103 let exercise_slide_submission = get_by_id(&mut *conn, exercise_slide_submission_id).await?;
1104 let exercise =
1105 crate::exercises::get_by_id(&mut *conn, exercise_slide_submission.exercise_id).await?;
1106 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?;
1107 let user_exercise_state = crate::user_exercise_states::get_user_exercise_state_if_exists(
1108 &mut *conn,
1109 user_id,
1110 exercise_slide_submission.exercise_id,
1111 CourseOrExamId::from_course_and_exam_ids(
1112 exercise_slide_submission.course_id,
1113 exercise_slide_submission.exam_id,
1114 )?,
1115 )
1116 .await?;
1117
1118 Ok(ExerciseSlideSubmissionInfo {
1119 exercise,
1120 tasks,
1121 exercise_slide_submission,
1122 user_exercise_state,
1123 })
1124}
1125
1126pub async fn delete_exercise_submissions_with_exam_id_and_user_id(
1127 conn: &mut PgConnection,
1128 exam_id: Uuid,
1129 user_id: Uuid,
1130) -> ModelResult<()> {
1131 sqlx::query!(
1132 "
1133UPDATE exercise_slide_submissions
1134SET deleted_at = now()
1135WHERE exam_id = $1 AND user_id = $2
1136AND deleted_at IS NULL
1137 ",
1138 exam_id,
1139 user_id,
1140 )
1141 .execute(&mut *conn)
1142 .await?;
1143 Ok(())
1144}
1145
1146pub async fn exercise_slide_submission_count_with_exercise_and_user_ids(
1148 conn: &mut PgConnection,
1149 exercise_id: Uuid,
1150 user_id: Uuid,
1151) -> ModelResult<u32> {
1152 let count = sqlx::query_scalar!(
1153 r#"
1154SELECT count(*) AS "count!"
1155FROM exercise_slide_submissions
1156WHERE exercise_id = $1
1157 AND user_id = $2
1158 AND deleted_at IS NULL
1159"#,
1160 exercise_id,
1161 user_id
1162 )
1163 .fetch_one(conn)
1164 .await?;
1165 Ok(count.try_into()?)
1166}
1167
1168pub async fn delete_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
1170 sqlx::query!(
1171 "UPDATE exercise_slide_submissions SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL",
1172 id
1173 )
1174 .execute(conn)
1175 .await?;
1176 Ok(())
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182 use crate::exercise_tasks::CourseMaterialExerciseTask;
1183 use crate::exercises::ActivityProgress;
1184 use crate::user_exercise_states::ReviewingStage;
1185
1186 fn dummy_exercise() -> Exercise {
1187 Exercise {
1188 id: Uuid::new_v4(),
1189 created_at: Utc::now(),
1190 updated_at: Utc::now(),
1191 name: "Test exercise".to_string(),
1192 course_id: Some(Uuid::new_v4()),
1193 exam_id: None,
1194 page_id: Uuid::new_v4(),
1195 chapter_id: None,
1196 deadline: None,
1197 deleted_at: None,
1198 score_maximum: 1,
1199 order_number: 0,
1200 copied_from: None,
1201 max_tries_per_slide: None,
1202 limit_number_of_tries: false,
1203 needs_peer_review: false,
1204 needs_self_review: false,
1205 use_course_default_peer_or_self_review_config: false,
1206 exercise_language_group_id: None,
1207 teacher_reviews_answer_after_locking: false,
1208 }
1209 }
1210
1211 fn dummy_slide_submission(user_id: Uuid) -> ExerciseSlideSubmission {
1212 ExerciseSlideSubmission {
1213 id: Uuid::new_v4(),
1214 created_at: Utc::now(),
1215 updated_at: Utc::now(),
1216 deleted_at: None,
1217 exercise_slide_id: Uuid::new_v4(),
1218 course_id: Some(Uuid::new_v4()),
1219 exam_id: None,
1220 exercise_id: Uuid::new_v4(),
1221 user_id,
1222 user_points_update_strategy:
1223 UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
1224 flag_count: None,
1225 }
1226 }
1227
1228 fn dummy_user_exercise_state(user_id: Uuid) -> UserExerciseState {
1229 UserExerciseState {
1230 id: Uuid::new_v4(),
1231 user_id,
1232 exercise_id: Uuid::new_v4(),
1233 course_id: Some(Uuid::new_v4()),
1234 exam_id: None,
1235 created_at: Utc::now(),
1236 updated_at: Utc::now(),
1237 deleted_at: None,
1238 score_given: Some(1.0),
1239 grading_progress: GradingProgress::FullyGraded,
1240 activity_progress: ActivityProgress::Completed,
1241 reviewing_stage: ReviewingStage::ReviewedAndLocked,
1242 selected_exercise_slide_id: Some(Uuid::new_v4()),
1243 }
1244 }
1245
1246 fn identifiers_in(info: &ExerciseSlideSubmissionInfo) -> Vec<String> {
1249 let mut found = Vec::new();
1250 if info.exercise_slide_submission.user_id != Uuid::nil() {
1251 found.push("exercise_slide_submission.user_id".to_string());
1252 }
1253 if info.user_exercise_state.is_some() {
1254 found.push("user_exercise_state".to_string());
1255 }
1256 for (index, task) in info.tasks.iter().enumerate() {
1257 if task.pseudonumous_user_id.is_some() {
1258 found.push(format!("tasks[{index}].pseudonumous_user_id"));
1259 }
1260 }
1261 found
1262 }
1263
1264 fn dummy_task_with_model_solution() -> CourseMaterialExerciseTask {
1265 CourseMaterialExerciseTask {
1266 id: Uuid::new_v4(),
1267 exercise_service_slug: "tmc".to_string(),
1268 exercise_slide_id: Uuid::new_v4(),
1269 exercise_iframe_url: None,
1270 pseudonumous_user_id: Some(Uuid::new_v4()),
1271 assignment: serde_json::json!([]),
1272 public_spec: Some(serde_json::json!({ "public": true })),
1273 model_solution_spec: Some(serde_json::json!({ "solution": "the answer" })),
1274 previous_submission: None,
1275 previous_submission_grading: None,
1276 order_number: 0,
1277 deleted_at: None,
1278 }
1279 }
1280
1281 #[test]
1283 fn strip_for_shared_view_removes_model_solution_and_user_id() {
1284 let user_id = Uuid::new_v4();
1285 let mut info = ExerciseSlideSubmissionInfo {
1286 tasks: vec![
1287 dummy_task_with_model_solution(),
1288 dummy_task_with_model_solution(),
1289 ],
1290 exercise: dummy_exercise(),
1291 exercise_slide_submission: dummy_slide_submission(user_id),
1292 user_exercise_state: Some(dummy_user_exercise_state(user_id)),
1293 };
1294
1295 assert!(
1296 info.tasks.iter().all(|t| t.model_solution_spec.is_some()),
1297 "test fixture should start with model solutions present"
1298 );
1299 assert_eq!(info.exercise_slide_submission.user_id, user_id);
1300
1301 info.strip_for_shared_view();
1302
1303 assert!(
1304 info.tasks.iter().all(|t| t.model_solution_spec.is_none()),
1305 "model_solution_spec must be absent from every task in the shared view"
1306 );
1307 assert_eq!(
1308 info.exercise_slide_submission.user_id,
1309 Uuid::nil(),
1310 "submitter user_id must not be exposed in the shared view"
1311 );
1312 }
1313
1314 #[test]
1318 fn strip_for_shared_view_leaves_no_user_identifier() {
1319 let user_id = Uuid::new_v4();
1320 let mut info = ExerciseSlideSubmissionInfo {
1321 tasks: vec![
1322 dummy_task_with_model_solution(),
1323 dummy_task_with_model_solution(),
1324 ],
1325 exercise: dummy_exercise(),
1326 exercise_slide_submission: dummy_slide_submission(user_id),
1327 user_exercise_state: Some(dummy_user_exercise_state(user_id)),
1328 };
1329
1330 assert_eq!(
1331 identifiers_in(&info),
1332 vec![
1333 "exercise_slide_submission.user_id",
1334 "user_exercise_state",
1335 "tasks[0].pseudonumous_user_id",
1336 "tasks[1].pseudonumous_user_id",
1337 ],
1338 "the fixture must start with every identifier present, or this test proves nothing"
1339 );
1340
1341 info.strip_for_shared_view();
1342
1343 assert_eq!(
1344 identifiers_in(&info),
1345 Vec::<String>::new(),
1346 "no user identifier may survive the strip"
1347 );
1348 }
1349
1350 #[test]
1351 fn strip_for_shared_view_keeps_public_spec() {
1352 let mut info = ExerciseSlideSubmissionInfo {
1353 tasks: vec![dummy_task_with_model_solution()],
1354 exercise: dummy_exercise(),
1355 exercise_slide_submission: dummy_slide_submission(Uuid::new_v4()),
1356 user_exercise_state: Some(dummy_user_exercise_state(Uuid::new_v4())),
1357 };
1358
1359 info.strip_for_shared_view();
1360
1361 assert!(
1362 info.tasks[0].public_spec.is_some(),
1363 "public_spec should remain available in the shared view"
1364 );
1365 }
1366}