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