Skip to main content

headless_lms_models/
exercise_slide_submissions.rs

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_tasks::CourseMaterialExerciseTask,
16    exercises::{self, Exercise, GradingProgress},
17    prelude::*,
18    teacher_grading_decisions::{self, TeacherGradingDecision},
19    user_exercise_states::{self, UserExerciseState},
20};
21
22#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
23
24pub struct AnswerRequiringAttention {
25    pub id: Uuid,
26    pub user_id: Uuid,
27    pub created_at: DateTime<Utc>,
28    pub updated_at: DateTime<Utc>,
29    pub deleted_at: Option<DateTime<Utc>>,
30    pub data_json: Option<serde_json::Value>,
31    pub grading_progress: GradingProgress,
32    pub score_given: Option<f32>,
33    pub submission_id: Uuid,
34    pub exercise_id: Uuid,
35}
36#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
37
38pub struct NewExerciseSlideSubmission {
39    pub exercise_slide_id: Uuid,
40    pub course_id: Option<Uuid>,
41    pub exam_id: Option<Uuid>,
42    pub user_id: Uuid,
43    pub exercise_id: Uuid,
44    pub user_points_update_strategy: UserPointsUpdateStrategy,
45}
46
47#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
48
49pub struct ExerciseSlideSubmission {
50    pub id: Uuid,
51    pub created_at: DateTime<Utc>,
52    pub updated_at: DateTime<Utc>,
53    pub deleted_at: Option<DateTime<Utc>>,
54    pub exercise_slide_id: Uuid,
55    pub course_id: Option<Uuid>,
56    pub exam_id: Option<Uuid>,
57    pub exercise_id: Uuid,
58    pub user_id: Uuid,
59    pub user_points_update_strategy: UserPointsUpdateStrategy,
60    pub flag_count: Option<i32>,
61}
62
63impl ExerciseSlideSubmission {
64    pub fn get_course_id(&self) -> ModelResult<Uuid> {
65        self.course_id.ok_or_else(|| {
66            ModelError::new(
67                ModelErrorType::Generic,
68                "Submission is not related to a course.".to_string(),
69                None,
70            )
71        })
72    }
73}
74
75#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
76
77pub struct ExerciseAnswersInCourseRequiringAttentionCount {
78    pub id: Uuid,
79    pub name: String,
80    pub page_id: Uuid,
81    pub chapter_id: Option<Uuid>,
82    pub order_number: i32,
83    pub count: Option<i32>,
84}
85
86#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
87
88pub struct ExerciseSlideSubmissionCount {
89    pub date: Option<NaiveDate>,
90    pub count: Option<i32>,
91}
92
93#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
94
95pub struct ExerciseSlideSubmissionCountByExercise {
96    pub exercise_id: Uuid,
97    pub count: Option<i32>,
98    pub exercise_name: String,
99}
100
101#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
102
103pub struct ExerciseSlideSubmissionCountByWeekAndHour {
104    pub isodow: Option<i32>,
105    pub hour: Option<i32>,
106    pub count: Option<i32>,
107}
108
109#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
110
111pub struct ExerciseSlideSubmissionInfo {
112    pub tasks: Vec<CourseMaterialExerciseTask>,
113    pub exercise: Exercise,
114    pub exercise_slide_submission: ExerciseSlideSubmission,
115    pub user_exercise_state: Option<UserExerciseState>,
116}
117
118#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
119
120pub struct ExerciseSlideSubmissionAndUserExerciseState {
121    pub exercise: Exercise,
122    pub exercise_slide_submission: ExerciseSlideSubmission,
123    pub user_exercise_state: UserExerciseState,
124    pub teacher_grading_decision: Option<TeacherGradingDecision>,
125    pub user_exam_enrollment: ExamEnrollment,
126}
127
128#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
129
130pub struct ExerciseSlideSubmissionAndUserExerciseStateList {
131    pub data: Vec<ExerciseSlideSubmissionAndUserExerciseState>,
132    pub total_pages: u32,
133}
134
135pub async fn insert_exercise_slide_submission(
136    conn: &mut PgConnection,
137    exercise_slide_submission: NewExerciseSlideSubmission,
138) -> ModelResult<ExerciseSlideSubmission> {
139    let res = sqlx::query_as!(
140        ExerciseSlideSubmission,
141        r#"
142INSERT INTO exercise_slide_submissions (
143    exercise_slide_id,
144    course_id,
145    exam_id,
146    exercise_id,
147    user_id,
148    user_points_update_strategy
149  )
150VALUES ($1, $2, $3, $4, $5, $6)
151RETURNING *
152        "#,
153        exercise_slide_submission.exercise_slide_id,
154        exercise_slide_submission.course_id,
155        exercise_slide_submission.exam_id,
156        exercise_slide_submission.exercise_id,
157        exercise_slide_submission.user_id,
158        exercise_slide_submission.user_points_update_strategy as UserPointsUpdateStrategy,
159    )
160    .fetch_one(conn)
161    .await?;
162    Ok(res)
163}
164
165pub async fn insert_exercise_slide_submission_with_id(
166    conn: &mut PgConnection,
167    id: Uuid,
168    exercise_slide_submission: &NewExerciseSlideSubmission,
169) -> ModelResult<ExerciseSlideSubmission> {
170    let res = sqlx::query_as!(
171        ExerciseSlideSubmission,
172        r#"
173INSERT INTO exercise_slide_submissions (
174    id,
175    exercise_slide_id,
176    course_id,
177    exam_id,
178    exercise_id,
179    user_id,
180    user_points_update_strategy
181  )
182VALUES ($1, $2, $3, $4, $5, $6, $7)
183RETURNING *
184        "#,
185        id,
186        exercise_slide_submission.exercise_slide_id,
187        exercise_slide_submission.course_id,
188        exercise_slide_submission.exam_id,
189        exercise_slide_submission.exercise_id,
190        exercise_slide_submission.user_id,
191        exercise_slide_submission.user_points_update_strategy as UserPointsUpdateStrategy,
192    )
193    .fetch_one(conn)
194    .await?;
195    Ok(res)
196}
197
198pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ExerciseSlideSubmission> {
199    let exercise_slide_submission = sqlx::query_as!(
200        ExerciseSlideSubmission,
201        r#"
202SELECT *
203FROM exercise_slide_submissions
204WHERE id = $1
205  AND deleted_at IS NULL;
206        "#,
207        id
208    )
209    .fetch_one(conn)
210    .await?;
211    Ok(exercise_slide_submission)
212}
213
214pub async fn get_by_course_id_and_user_ids_and_exercise_ids(
215    conn: &mut PgConnection,
216    course_id: Uuid,
217    user_ids: &[Uuid],
218    exercise_ids: &[Uuid],
219) -> ModelResult<Vec<ExerciseSlideSubmission>> {
220    let submissions = sqlx::query_as!(
221        ExerciseSlideSubmission,
222        r#"
223SELECT ess.id,
224  ess.created_at,
225  ess.updated_at,
226  ess.deleted_at,
227  ess.exercise_slide_id,
228  ess.course_id,
229  ess.exam_id,
230  ess.exercise_id,
231  ess.user_id,
232  ess.user_points_update_strategy,
233  ess.flag_count
234FROM exercise_slide_submissions ess
235  JOIN exercises e ON e.id = ess.exercise_id
236WHERE ess.course_id = $1
237  AND e.course_id = $1
238  AND ess.user_id = ANY($2)
239  AND ess.exercise_id = ANY($3)
240  AND ess.deleted_at IS NULL
241  AND e.deleted_at IS NULL
242        "#,
243        course_id,
244        user_ids,
245        exercise_ids
246    )
247    .fetch_all(conn)
248    .await?;
249    Ok(submissions)
250}
251
252/// Returns a map of exercise_slide_submission id -> user_id for the given submission ids.
253pub async fn get_user_ids_by_submission_ids(
254    conn: &mut PgConnection,
255    submission_ids: &[Uuid],
256) -> ModelResult<HashMap<Uuid, Uuid>> {
257    if submission_ids.is_empty() {
258        return Ok(HashMap::new());
259    }
260    let rows = sqlx::query!(
261        r#"
262SELECT *
263FROM exercise_slide_submissions
264WHERE id = ANY($1)
265  AND deleted_at IS NULL
266        "#,
267        submission_ids
268    )
269    .fetch_all(conn)
270    .await?;
271    Ok(rows.into_iter().map(|r| (r.id, r.user_id)).collect())
272}
273
274/// Attempts to find a single random `ExerciseSlideSubmission` that is not related to the provided user.
275///
276/// This function is mostly provided for very specific peer review purposes. Used only as a last resort if no other candidates are found to be peer reviewed.
277pub async fn try_to_get_random_filtered_by_user_and_submissions(
278    conn: &mut PgConnection,
279    exercise_id: Uuid,
280    excluded_user_id: Uuid,
281    excluded_ids: &[Uuid],
282) -> ModelResult<Option<ExerciseSlideSubmission>> {
283    let mut res = sqlx::query_as!(
284        ExerciseSlideSubmission,
285        r#"
286SELECT DISTINCT ON (user_id)
287  ess.id,
288  ess.created_at,
289  ess.updated_at,
290  ess.deleted_at,
291  ess.exercise_slide_id,
292  ess.course_id,
293  ess.exam_id,
294  ess.exercise_id,
295  ess.user_id,
296  ess.user_points_update_strategy,
297  ess.flag_count
298FROM exercise_slide_submissions AS ess
299JOIN courses AS c
300  ON ess.course_id = c.id
301WHERE ess.exercise_id = $1
302  AND ess.id <> ALL($2)
303  AND ess.user_id <> $3
304  AND ess.deleted_at IS NULL
305  AND ess.flag_count < c.flagged_answers_threshold
306ORDER BY ess.user_id, ess.created_at DESC
307        "#,
308        exercise_id,
309        excluded_ids,
310        excluded_user_id,
311    )
312    .fetch_all(conn)
313    .await?;
314    // shuffle the res vec
315    let mut rng = rand::rng();
316    res.shuffle(&mut rng);
317    Ok(res.into_iter().next())
318}
319
320pub async fn get_by_exercise_id(
321    conn: &mut PgConnection,
322    exercise_id: Uuid,
323    pagination: Pagination,
324) -> ModelResult<Vec<ExerciseSlideSubmission>> {
325    let submissions = sqlx::query_as!(
326        ExerciseSlideSubmission,
327        r#"
328SELECT *
329FROM exercise_slide_submissions
330WHERE exercise_id = $1
331  AND deleted_at IS NULL
332LIMIT $2 OFFSET $3;
333        "#,
334        exercise_id,
335        pagination.limit(),
336        pagination.offset(),
337    )
338    .fetch_all(conn)
339    .await?;
340    Ok(submissions)
341}
342
343/// A single submission's time, its exercise, and the module that exercise sits in (via its chapter).
344/// Used to plot a user's submission activity within a course.
345#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
346pub struct UserCourseSubmissionTime {
347    pub created_at: DateTime<Utc>,
348    pub exercise_id: Uuid,
349    /// Module the exercise's chapter belongs to; `None` for exercises not placed in a chapter.
350    pub course_module_id: Option<Uuid>,
351}
352
353/// All of a user's submission times in a course, each tagged with its exercise and module, ordered
354/// chronologically. Excludes submissions to soft-deleted exercises/chapters so this per-course timeline
355/// agrees with the cross-course density view. Capped to a safe maximum, fetching one past the cap (5001)
356/// so the caller can tell a genuine overflow from an exact-cap result.
357pub async fn get_user_course_submission_times(
358    conn: &mut PgConnection,
359    user_id: Uuid,
360    course_id: Uuid,
361) -> ModelResult<Vec<UserCourseSubmissionTime>> {
362    let submissions = sqlx::query_as!(
363        UserCourseSubmissionTime,
364        r#"
365SELECT ess.created_at,
366  ess.exercise_id,
367  c.course_module_id AS "course_module_id?"
368FROM exercise_slide_submissions ess
369  JOIN exercises e ON e.id = ess.exercise_id AND e.deleted_at IS NULL
370  LEFT JOIN chapters c ON c.id = e.chapter_id AND c.deleted_at IS NULL
371WHERE ess.user_id = $1
372  AND ess.course_id = $2
373  AND ess.deleted_at IS NULL
374ORDER BY ess.created_at
375LIMIT 5001
376        "#,
377        user_id,
378        course_id,
379    )
380    .fetch_all(conn)
381    .await?;
382    Ok(submissions)
383}
384
385pub async fn get_users_all_submissions_for_course_or_exam(
386    conn: &mut PgConnection,
387    user_id: Uuid,
388    course_or_exam_id: CourseOrExamId,
389) -> ModelResult<Vec<ExerciseSlideSubmission>> {
390    let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
391    let submissions = sqlx::query_as!(
392        ExerciseSlideSubmission,
393        r#"
394SELECT *
395FROM exercise_slide_submissions
396WHERE user_id = $1
397  AND (course_id = $2 OR exam_id = $3)
398  AND deleted_at IS NULL
399        "#,
400        user_id,
401        course_id,
402        exam_id,
403    )
404    .fetch_all(conn)
405    .await?;
406    Ok(submissions)
407}
408
409/// Of the given exercises, the ones the user has an existing answer to.
410///
411/// This is the predicate for "there is something to review": it matches the joins
412/// get_all_answers_requiring_attention uses, so an exercise missing from the result has nothing a
413/// teacher could grade. Prefer this over
414/// user_exercise_states::get_returned_exercise_ids_for_user_and_course when deciding whether an
415/// answer exists — that one keys off activity_progress, which stays InProgress for a submitted
416/// peer review exercise and so under-reports answers.
417///
418/// Kept in sync with the repair in migration 20260806122511.
419pub async fn get_exercise_ids_with_submissions_for_user(
420    conn: &mut PgConnection,
421    exercise_ids: &[Uuid],
422    user_id: Uuid,
423    course_id: Uuid,
424) -> ModelResult<Vec<Uuid>> {
425    let exercise_ids = sqlx::query_scalar!(
426        r#"
427        SELECT DISTINCT exercise_id
428        FROM exercise_slide_submissions
429        WHERE exercise_id = ANY($1)
430          AND user_id = $2
431          AND course_id = $3
432          AND deleted_at IS NULL
433        "#,
434        exercise_ids,
435        user_id,
436        course_id,
437    )
438    .fetch_all(conn)
439    .await?;
440
441    Ok(exercise_ids)
442}
443
444pub async fn get_users_submissions_for_exercise(
445    conn: &mut PgConnection,
446    user_id: Uuid,
447    exercise_id: Uuid,
448) -> ModelResult<Vec<ExerciseSlideSubmission>> {
449    let submissions = sqlx::query_as!(
450        ExerciseSlideSubmission,
451        r#"
452SELECT *
453FROM exercise_slide_submissions
454WHERE user_id = $1
455  AND exercise_id = $2
456  AND deleted_at IS NULL
457ORDER BY created_at DESC
458        "#,
459        user_id,
460        exercise_id,
461    )
462    .fetch_all(conn)
463    .await?;
464    Ok(submissions)
465}
466
467pub async fn get_users_latest_exercise_slide_submission(
468    conn: &mut PgConnection,
469    exercise_slide_id: Uuid,
470    user_id: Uuid,
471) -> ModelResult<ExerciseSlideSubmission> {
472    let res = sqlx::query_as!(
473        ExerciseSlideSubmission,
474        r#"
475SELECT *
476FROM exercise_slide_submissions
477WHERE exercise_slide_id = $1
478  AND user_id = $2
479  AND deleted_at IS NULL
480ORDER BY created_at DESC
481LIMIT 1
482        "#,
483        exercise_slide_id,
484        user_id
485    )
486    .fetch_one(conn)
487    .await?;
488    Ok(res)
489}
490
491pub async fn try_to_get_users_latest_exercise_slide_submission(
492    conn: &mut PgConnection,
493    exercise_slide_id: Uuid,
494    user_id: Uuid,
495) -> ModelResult<Option<ExerciseSlideSubmission>> {
496    get_users_latest_exercise_slide_submission(conn, exercise_slide_id, user_id)
497        .await
498        .optional()
499}
500
501pub async fn get_course_and_exam_id(
502    conn: &mut PgConnection,
503    id: Uuid,
504) -> ModelResult<CourseOrExamId> {
505    let res = sqlx::query!(
506        "
507SELECT *
508FROM exercise_slide_submissions
509WHERE id = $1
510  AND deleted_at IS NULL
511        ",
512        id
513    )
514    .fetch_one(conn)
515    .await?;
516    CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
517}
518
519pub async fn exercise_slide_submission_count(
520    conn: &mut PgConnection,
521    exercise_id: Uuid,
522) -> ModelResult<u32> {
523    let count = sqlx::query!(
524        "
525SELECT COUNT(*) as count
526FROM exercise_slide_submissions
527WHERE exercise_id = $1
528AND deleted_at IS NULL
529",
530        exercise_id,
531    )
532    .fetch_one(conn)
533    .await?;
534    Ok(count.count.unwrap_or(0).try_into()?)
535}
536
537pub async fn exercise_slide_submissions(
538    conn: &mut PgConnection,
539    exercise_id: Uuid,
540    pagination: Pagination,
541) -> ModelResult<Vec<ExerciseSlideSubmission>> {
542    let submissions = sqlx::query_as!(
543        ExerciseSlideSubmission,
544        r#"
545SELECT *
546FROM exercise_slide_submissions
547WHERE exercise_id = $1
548  AND deleted_at IS NULL
549ORDER BY created_at DESC
550LIMIT $2 OFFSET $3
551        "#,
552        exercise_id,
553        pagination.limit(),
554        pagination.offset(),
555    )
556    .fetch_all(conn)
557    .await?;
558    Ok(submissions)
559}
560
561pub async fn exercise_slide_submission_count_with_exam_id(
562    conn: &mut PgConnection,
563    exam_id: Uuid,
564) -> ModelResult<u32> {
565    let count = sqlx::query!(
566        "
567SELECT COUNT(*) as count
568FROM exercise_slide_submissions
569WHERE exam_id = $1
570AND deleted_at IS NULL
571",
572        exam_id,
573    )
574    .fetch_one(conn)
575    .await?;
576    Ok(count.count.unwrap_or(0).try_into()?)
577}
578
579pub async fn exercise_slide_submission_count_with_exercise_id(
580    conn: &mut PgConnection,
581    exercise_id: Uuid,
582) -> ModelResult<u32> {
583    let count = sqlx::query!(
584        "
585SELECT COUNT(*) as count
586FROM exercise_slide_submissions
587WHERE exercise_id = $1
588AND deleted_at IS NULL
589",
590        exercise_id,
591    )
592    .fetch_one(conn)
593    .await?;
594    Ok(count.count.unwrap_or(0).try_into()?)
595}
596
597pub async fn get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
598    conn: &mut PgConnection,
599    exercise_id: Uuid,
600    pagination: Pagination,
601) -> ModelResult<Vec<ExerciseSlideSubmissionAndUserExerciseState>> {
602    let submissions = sqlx::query_as!(
603        ExerciseSlideSubmission,
604        r#"
605    SELECT DISTINCT ON (user_id)
606        id,
607        created_at,
608        updated_at,
609        deleted_at,
610        exercise_slide_id,
611        course_id,
612        exam_id,
613        exercise_id,
614        user_id,
615        user_points_update_strategy,
616  flag_count
617FROM exercise_slide_submissions
618WHERE exercise_id = $1
619      AND deleted_at IS NULL
620ORDER BY user_id, created_at DESC
621LIMIT $2 OFFSET $3
622        "#,
623        exercise_id,
624        pagination.limit(),
625        pagination.offset(),
626    )
627    .fetch_all(&mut *conn)
628    .await?;
629
630    let user_ids = submissions
631        .iter()
632        .map(|sub| sub.user_id)
633        .collect::<Vec<_>>();
634
635    let exercise = exercises::get_by_id(conn, exercise_id).await?;
636    let exam_id = exercise.exam_id;
637    let course_id = exercise.course_id;
638
639    let user_exercise_states_list =
640        user_exercise_states::get_or_create_user_exercise_state_for_users(
641            conn,
642            &user_ids,
643            exercise_id,
644            course_id,
645            exam_id,
646        )
647        .await?;
648
649    let mut user_exercise_state_id_list: Vec<Uuid> = Vec::new();
650
651    for (_key, value) in user_exercise_states_list.clone().into_iter() {
652        user_exercise_state_id_list.push(value.id);
653    }
654
655    let exercise = exercises::get_by_id(conn, exercise_id).await?;
656    let exam_id = exercise
657        .exam_id
658        .ok_or_else(|| ModelError::new(ModelErrorType::Generic, "No exam id found", None))?;
659
660    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?;
661
662    let user_exam_enrollments_list =
663        exams::get_exam_enrollments_for_users(conn, exam_id, &user_ids).await?;
664
665    let mut list: Vec<ExerciseSlideSubmissionAndUserExerciseState> = Vec::new();
666    for sub in submissions {
667        let user_exercise_state = user_exercise_states_list
668            .get(&sub.user_id)
669            .ok_or_else(|| ModelError::new(ModelErrorType::Generic, "No user found", None))?;
670
671        let teacher_grading_decision = teacher_grading_decisions_list.get(&user_exercise_state.id);
672        let user_exam_enrollment =
673            user_exam_enrollments_list
674                .get(&sub.user_id)
675                .ok_or_else(|| {
676                    ModelError::new(
677                        ModelErrorType::Generic,
678                        "No users exam_enrollment found",
679                        None,
680                    )
681                })?;
682
683        //Add submissions to the list only if the students exam time has ended
684        if user_exam_enrollment.ended_at.is_some() {
685            let data = ExerciseSlideSubmissionAndUserExerciseState {
686                exercise: exercise.clone(),
687                exercise_slide_submission: sub,
688                user_exercise_state: user_exercise_state.clone(),
689                teacher_grading_decision: teacher_grading_decision.cloned(),
690                user_exam_enrollment: user_exam_enrollment.clone(),
691            };
692            list.push(data);
693        }
694    }
695
696    Ok(list)
697}
698
699pub async fn get_course_daily_slide_submission_counts(
700    conn: &mut PgConnection,
701    course: &Course,
702) -> ModelResult<Vec<ExerciseSlideSubmissionCount>> {
703    let res = sqlx::query_as!(
704        ExerciseSlideSubmissionCount,
705        r#"
706SELECT DATE(created_at) date, count(*)::integer
707FROM exercise_slide_submissions
708WHERE course_id = $1
709AND deleted_at IS NULL
710GROUP BY date
711ORDER BY date;
712          "#,
713        course.id
714    )
715    .fetch_all(conn)
716    .await?;
717    Ok(res)
718}
719
720pub async fn get_course_daily_user_counts_with_submissions(
721    conn: &mut PgConnection,
722    course: &Course,
723) -> ModelResult<Vec<ExerciseSlideSubmissionCount>> {
724    let res = sqlx::query_as!(
725        ExerciseSlideSubmissionCount,
726        r#"
727SELECT DATE(created_at) date, count(DISTINCT user_id)::integer
728FROM exercise_slide_submissions
729WHERE course_id = $1
730AND deleted_at IS NULL
731GROUP BY date
732ORDER BY date;
733          "#,
734        course.id
735    )
736    .fetch_all(conn)
737    .await?;
738    Ok(res)
739}
740
741pub async fn answer_requiring_attention_count(
742    conn: &mut PgConnection,
743    exercise_id: Uuid,
744) -> ModelResult<u32> {
745    let count = sqlx::query!(
746        r#"
747        SELECT
748        COUNT(DISTINCT us_state.user_id) as count
749    FROM user_exercise_states AS us_state
750    JOIN exercise_task_submissions AS t_submission
751        ON us_state.selected_exercise_slide_id =
752            t_submission.exercise_slide_id
753    JOIN exercise_slide_submissions AS s_submission
754            ON t_submission.exercise_slide_submission_id =
755                s_submission.id
756    WHERE us_state.selected_exercise_slide_id =
757            t_submission.exercise_slide_id
758    AND us_state.user_id = s_submission.user_id
759    AND us_state.exercise_id = $1
760    AND us_state.reviewing_stage = 'waiting_for_manual_grading'
761    AND us_state.deleted_at IS NULL
762    AND s_submission.deleted_at IS NULL
763    AND t_submission.deleted_at IS NULL"#,
764        exercise_id,
765    )
766    .fetch_one(conn)
767    .await?;
768    Ok(count.count.unwrap_or(0).try_into()?)
769}
770
771pub async fn get_count_of_answers_requiring_attention_in_exercise_by_course_id(
772    conn: &mut PgConnection,
773    course_id: Uuid,
774) -> ModelResult<Vec<ExerciseAnswersInCourseRequiringAttentionCount>> {
775    let count_list = sqlx::query_as!(
776        ExerciseAnswersInCourseRequiringAttentionCount,
777        r#"
778SELECT exercises.id,
779  (
780    SELECT COUNT(DISTINCT us_state.user_id)::integer AS COUNT
781    FROM exercises AS exercises2
782      LEFT JOIN user_exercise_states AS us_state ON us_state.exercise_id = exercises2.id
783      LEFT JOIN exercise_slide_submissions AS s_submission ON us_state.selected_exercise_slide_id = s_submission.exercise_slide_id
784      LEFT JOIN exercise_task_submissions AS t_submission ON s_submission.id = t_submission.exercise_slide_submission_id
785    WHERE us_state.selected_exercise_slide_id = t_submission.exercise_slide_id
786      AND us_state.user_id = s_submission.user_id
787      AND us_state.reviewing_stage = 'waiting_for_manual_grading'
788      AND us_state.deleted_at IS NULL
789      AND s_submission.deleted_at IS NULL
790      AND t_submission.deleted_at IS NULL
791      AND exercises2.course_id = $1
792      AND exercises.id = exercises2.id
793    GROUP BY exercises2.id
794  ),
795  exercises.order_number,
796  exercises.name,
797  exercises.page_id,
798  exercises.chapter_id
799FROM exercises
800WHERE exercises.course_id = $1
801  AND exercises.deleted_at IS NULL
802GROUP BY exercises.id;
803"#,
804        course_id,
805    )
806    .fetch_all(conn)
807    .await?;
808    Ok(count_list)
809}
810
811pub async fn exercise_slide_submissions_for_answers_requiring_attention(
812    conn: &mut PgConnection,
813    exercise_id: Uuid,
814    pagination: Pagination,
815) -> ModelResult<Vec<ExerciseSlideSubmission>> {
816    let submissions = sqlx::query_as!(
817        ExerciseSlideSubmission,
818        r#"
819SELECT *
820FROM exercise_slide_submissions
821WHERE exercise_id = $1
822  AND deleted_at IS NULL
823LIMIT $2 OFFSET $3
824        "#,
825        exercise_id,
826        pagination.limit(),
827        pagination.offset(),
828    )
829    .fetch_all(conn)
830    .await?;
831    Ok(submissions)
832}
833
834pub async fn get_all_answers_requiring_attention(
835    conn: &mut PgConnection,
836    exercise_id: Uuid,
837    pagination: Pagination,
838) -> ModelResult<Vec<AnswerRequiringAttention>> {
839    let submissions = sqlx::query_as!(
840        AnswerRequiringAttention,
841        r#"
842        SELECT DISTINCT ON (us_state.user_id)
843        us_state.id,
844        us_state.user_id,
845        us_state.exercise_id,
846        us_state.score_given,
847        us_state.grading_progress,
848        t_submission.data_json,
849        s_submission.created_at,
850        s_submission.updated_at,
851        s_submission.deleted_at,
852        s_submission.id AS submission_id
853    FROM user_exercise_states AS us_state
854    JOIN exercise_task_submissions AS t_submission
855        ON us_state.selected_exercise_slide_id =
856            t_submission.exercise_slide_id
857    JOIN exercise_slide_submissions AS s_submission
858            ON t_submission.exercise_slide_submission_id =
859                s_submission.id
860    WHERE us_state.selected_exercise_slide_id =
861            t_submission.exercise_slide_id
862    AND us_state.user_id = s_submission.user_id
863    AND us_state.exercise_id = $1
864    AND us_state.reviewing_stage = 'waiting_for_manual_grading'
865    AND us_state.deleted_at IS NULL
866    AND s_submission.deleted_at IS NULL
867    AND t_submission.deleted_at IS NULL
868    ORDER BY us_state.user_id, s_submission.created_at DESC
869    LIMIT $2 OFFSET $3;"#,
870        exercise_id,
871        pagination.limit(),
872        pagination.offset(),
873    )
874    .fetch_all(conn)
875    .await?;
876    Ok(submissions)
877}
878
879pub async fn get_course_exercise_slide_submission_counts_by_weekday_and_hour(
880    conn: &mut PgConnection,
881    course: &Course,
882) -> ModelResult<Vec<ExerciseSlideSubmissionCountByWeekAndHour>> {
883    let res = sqlx::query_as!(
884        ExerciseSlideSubmissionCountByWeekAndHour,
885        r#"
886SELECT date_part('isodow', created_at)::integer isodow,
887  date_part('hour', created_at)::integer "hour",
888  count(*)::integer
889FROM exercise_slide_submissions
890WHERE course_id = $1
891AND deleted_at IS NULL
892GROUP BY isodow,
893  "hour"
894ORDER BY isodow,
895  hour;
896          "#,
897        course.id
898    )
899    .fetch_all(conn)
900    .await?;
901    Ok(res)
902}
903
904pub async fn get_course_exercise_slide_submission_counts_by_exercise(
905    conn: &mut PgConnection,
906    course: &Course,
907) -> ModelResult<Vec<ExerciseSlideSubmissionCountByExercise>> {
908    let res = sqlx::query_as!(
909        ExerciseSlideSubmissionCountByExercise,
910        r#"
911SELECT counts.*, exercises.name exercise_name
912    FROM (
913        SELECT exercise_id, count(*)::integer count
914        FROM exercise_slide_submissions
915        WHERE course_id = $1
916        AND deleted_at IS NULL
917        GROUP BY exercise_id
918    ) counts
919    JOIN exercises ON (counts.exercise_id = exercises.id);
920          "#,
921        course.id
922    )
923    .fetch_all(conn)
924    .await?;
925    Ok(res)
926}
927
928pub async fn get_exercise_slide_submission_counts_for_exercise_user(
929    conn: &mut PgConnection,
930    exercise_id: Uuid,
931    course_id_or_exam_id: CourseOrExamId,
932    user_id: Uuid,
933) -> ModelResult<HashMap<Uuid, i64>> {
934    let ci_id_or_e_id = match course_id_or_exam_id {
935        CourseOrExamId::Course(id) => id,
936        CourseOrExamId::Exam(id) => id,
937    };
938    let res = sqlx::query!(
939        r#"
940SELECT exercise_slide_id,
941  COUNT(*) as count
942FROM exercise_slide_submissions
943WHERE exercise_id = $1
944  AND (course_id = $2 OR exam_id = $2)
945  AND user_id = $3
946  AND deleted_at IS NULL
947GROUP BY exercise_slide_id;
948    "#,
949        exercise_id,
950        ci_id_or_e_id,
951        user_id
952    )
953    .fetch_all(conn)
954    .await?
955    .iter()
956    .map(|row| (row.exercise_slide_id, row.count.unwrap_or(0)))
957    .collect::<HashMap<Uuid, i64>>();
958
959    Ok(res)
960}
961
962pub async fn get_exercise_slide_submission_info(
963    conn: &mut PgConnection,
964    exercise_slide_submission_id: Uuid,
965    user_id: Uuid,
966    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
967    include_deleted_tasks: bool,
968) -> ModelResult<ExerciseSlideSubmissionInfo> {
969    let exercise_slide_submission = get_by_id(&mut *conn, exercise_slide_submission_id).await?;
970    let exercise =
971        crate::exercises::get_by_id(&mut *conn, exercise_slide_submission.exercise_id).await?;
972    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).await?;
973    let user_exercise_state = crate::user_exercise_states::get_user_exercise_state_if_exists(
974        &mut *conn,
975        user_id,
976        exercise_slide_submission.exercise_id,
977        CourseOrExamId::from_course_and_exam_ids(
978            exercise_slide_submission.course_id,
979            exercise_slide_submission.exam_id,
980        )?,
981    )
982    .await?;
983
984    Ok(ExerciseSlideSubmissionInfo {
985        exercise,
986        tasks,
987        exercise_slide_submission,
988        user_exercise_state,
989    })
990}
991
992pub async fn delete_exercise_submissions_with_exam_id_and_user_id(
993    conn: &mut PgConnection,
994    exam_id: Uuid,
995    user_id: Uuid,
996) -> ModelResult<()> {
997    sqlx::query!(
998        "
999UPDATE exercise_slide_submissions
1000SET deleted_at = now()
1001WHERE exam_id = $1 AND user_id = $2
1002AND deleted_at IS NULL
1003    ",
1004        exam_id,
1005        user_id,
1006    )
1007    .execute(&mut *conn)
1008    .await?;
1009    Ok(())
1010}