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_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    /// Redacts fields that must never reach a holder of a share token, who has no teacher or
121    /// course role. Unlike the role-gated paths, the shared view always hides:
122    ///
123    /// - every task's model solution spec, regardless of grading state — otherwise
124    ///   "submit → share → open my own link" is the cheapest path to a solution. (The
125    ///   course-material path reveals it once the student has full points or is out of tries.)
126    /// - the submitter's `user_id`, zeroed rather than removed so the wire shape stays the
127    ///   same for other consumers of this type.
128    /// - `user_exercise_state`, dropped whole rather than field by field: every one of its
129    ///   members is the submitter's own progress (`user_id`, `score_given`,
130    ///   `activity_progress`, `reviewing_stage`), a share-link holder needs none of it, and
131    ///   nulling members individually would re-leak the moment one is added.
132    /// - each task's `pseudonumous_user_id`, a stable per-service pseudonym that otherwise
133    ///   links all of one user's shares to each other.
134    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
278/// Returns a map of exercise_slide_submission id -> user_id for the given submission ids.
279pub 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
300/// Attempts to find a single random `ExerciseSlideSubmission` that is not related to the provided user.
301///
302/// 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.
303pub 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    // shuffle the res vec
341    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/// A single submission's time, its exercise, and the module that exercise sits in (via its chapter).
370/// Used to plot a user's submission activity within a course.
371#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
372pub struct UserCourseSubmissionTime {
373    pub created_at: DateTime<Utc>,
374    pub exercise_id: Uuid,
375    /// Module the exercise's chapter belongs to; `None` for exercises not placed in a chapter.
376    pub course_module_id: Option<Uuid>,
377}
378
379/// All of a user's submission times in a course, each tagged with its exercise and module, ordered
380/// chronologically. Excludes submissions to soft-deleted exercises/chapters so this per-course timeline
381/// agrees with the cross-course density view. Capped to a safe maximum, fetching one past the cap (5001)
382/// so the caller can tell a genuine overflow from an exact-cap result.
383pub 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
435/// Of the given exercises, the ones the user has an existing answer to.
436///
437/// This is the predicate for "there is something to review": it matches the joins
438/// get_all_answers_requiring_attention uses, so an exercise missing from the result has nothing a
439/// teacher could grade. Prefer this over
440/// user_exercise_states::get_returned_exercise_ids_for_user_and_course when deciding whether an
441/// answer exists — that one keys off activity_progress, which stays InProgress for a submitted
442/// peer review exercise and so under-reports answers.
443///
444/// Kept in sync with the repair in migration 20260806122511.
445pub 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/// Of the given exercises, the ones with at least one existing submission from any user.
471///
472/// Used to warn a teacher before saving a page edit that would drop one of these exercises: the
473/// exercise gets soft-deleted by `exercises::delete_exercises_by_page_id`, which orphans the
474/// submissions behind the `deleted_at` filter instead of removing them (course-improvements-issues#146).
475pub 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    // Joins back to exercises.page_id so a caller can't probe submission existence for
481    // exercise ids outside the page they were authorized to edit.
482    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/// One of the user's submissions to an exercise, with the grading of its task submission.
525///
526/// The exercise-services client API only serves single-task (editor) slides, so "the slide's
527/// grading" is unambiguous; `DISTINCT ON` keeps that true for any slide that has more.
528#[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
537/// The user's submissions to an exercise, newest first, each joined to its grading.
538///
539/// One query rather than two per submission: a student who retries an exercise 200 times used to
540/// cost 401 round trips for one listing.
541pub 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        //Add submissions to the list only if the students exam time has ended
790        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
1146/// How many live slide submissions a user has made to an exercise.
1147pub 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
1168/// Soft-deletes one slide submission, leaving its task submissions alone.
1169pub 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    /// Every field of the shared view that could name or fingerprint the submitter. A new
1247    /// identity-bearing field must be added here as well as to `strip_for_shared_view`.
1248    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    /// The strip is unconditional, i.e. independent of grading state.
1282    #[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    /// A share link is forwardable, so nothing reachable through it may name or fingerprint the
1315    /// submitter — not their user id, not their progress row, not the per-service pseudonym that
1316    /// would link their shares together.
1317    #[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}