Skip to main content

headless_lms_models/
exercise_task_submissions.rs

1use std::collections::{HashMap, HashSet};
2
3use chrono::Duration;
4use futures::{Stream, StreamExt, future::BoxFuture};
5use url::Url;
6use utoipa::ToSchema;
7
8use crate::{
9    CourseOrExamId,
10    exercise_service_info::{self, ExerciseServiceInfoApi},
11    exercise_services, exercise_slide_submissions, exercise_task_submission_files,
12    exercise_tasks::{CourseMaterialExerciseTask, ExerciseTask},
13    library::custom_view_exercises::{CustomViewExerciseTaskSubmission, CustomViewExerciseTasks},
14    library::grading::SubmittedAnswer,
15    peer_or_self_review_question_submissions::PeerOrSelfReviewQuestionSubmission,
16    peer_or_self_review_questions::PeerOrSelfReviewQuestion,
17    peer_or_self_review_submissions::PeerOrSelfReviewSubmission,
18    prelude::*,
19};
20
21/// Which of a submission's two answer representations is the answer: the opaque blob in
22/// `data_json`, or the rows in `exercise_task_submission_files`.
23#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
24#[sqlx(type_name = "answer_kind", rename_all = "snake_case")]
25#[serde(rename_all = "snake_case")]
26pub enum AnswerKind {
27    Json,
28    File,
29}
30
31/// The answer a submission carries, as either the raw JSON a plugin produced or the files a
32/// plugin's answer consists of.
33///
34/// Internal: outbound types spread [`AnswerFields`] instead, so nothing on the wire is tagged.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(tag = "kind", rename_all = "snake_case")]
37pub enum AnswerData {
38    Json {
39        data: serde_json::Value,
40    },
41    File {
42        files: Vec<AnswerFile>,
43        /// The plugin's own JSON about the files. `None` for a plugin whose answer is the files.
44        metadata: Option<serde_json::Value>,
45    },
46}
47
48impl AnswerData {
49    /// The JSON to hand an exercise service where the payload has one answer field and no place
50    /// for files, such as the CSV answers export: the answer itself for `Json`, the plugin's
51    /// metadata for `File`.
52    pub fn plugin_json(&self) -> Option<&serde_json::Value> {
53        match self {
54            AnswerData::Json { data } => Some(data),
55            AnswerData::File { metadata, .. } => metadata.as_ref(),
56        }
57    }
58}
59
60/// One host-stored file that an answer consists of.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
62pub struct AnswerFile {
63    pub id: Uuid,
64    /// The name the file was uploaded under. Not necessarily what a viewer should be shown -- a
65    /// plugin that anonymizes filenames keeps its display name in `AnswerData::File::metadata`.
66    pub name: String,
67    pub mime: String,
68    /// `None` for a file stored before the size was recorded. Omitted from the serialized form
69    /// rather than nulled: the exercise service protocol's `size_bytes` is optional, not nullable,
70    /// and its generated guard rejects a null.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub size_bytes: Option<i64>,
73    pub order_number: i32,
74    /// Capability download URL, minted at read time and carrying a claim that expires within the
75    /// hour. Never persisted, and a response containing one cannot be cached for another reader.
76    pub url: String,
77}
78
79/// A stored answer as the three fields every outbound type carries it in.
80///
81/// Spread these rather than serializing [`AnswerData`] itself: a consumer written before file
82/// answers existed still finds the answer in `data_json`, where it has always been.
83pub struct AnswerFields {
84    pub answer_kind: AnswerKind,
85    pub data_json: Option<serde_json::Value>,
86    pub data_files: Option<Vec<AnswerFile>>,
87}
88
89impl From<Option<AnswerData>> for AnswerFields {
90    fn from(answer: Option<AnswerData>) -> Self {
91        match answer {
92            Some(AnswerData::Json { data }) => Self {
93                answer_kind: AnswerKind::Json,
94                data_json: Some(data),
95                data_files: None,
96            },
97            Some(AnswerData::File { files, metadata }) => Self {
98                answer_kind: AnswerKind::File,
99                data_json: metadata,
100                data_files: (!files.is_empty()).then_some(files),
101            },
102            // A `json` row whose `data_json` is NULL; only rows predating
103            // `exercise_task_submissions_json_answer_has_data` can be like that.
104            None => Self {
105                answer_kind: AnswerKind::Json,
106                data_json: None,
107                data_files: None,
108            },
109        }
110    }
111}
112
113#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
114
115pub struct ExerciseTaskSubmission {
116    pub id: Uuid,
117    pub created_at: DateTime<Utc>,
118    pub updated_at: DateTime<Utc>,
119    pub deleted_at: Option<DateTime<Utc>>,
120    pub exercise_slide_submission_id: Uuid,
121    pub exercise_task_id: Uuid,
122    pub exercise_slide_id: Uuid,
123    pub answer_kind: AnswerKind,
124    /// The plugin's own JSON: the whole answer for a `json` answer, the plugin's metadata about the
125    /// files for a `file` one.
126    pub data_json: Option<serde_json::Value>,
127    /// The files the answer consists of, in grading order. Omitted when it has none.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub data_files: Option<Vec<AnswerFile>>,
130    pub exercise_task_grading_id: Option<Uuid>,
131    pub metadata: Option<serde_json::Value>,
132}
133
134/// A submission row exactly as stored, with its files still unresolved.
135struct SubmissionRow {
136    id: Uuid,
137    created_at: DateTime<Utc>,
138    updated_at: DateTime<Utc>,
139    deleted_at: Option<DateTime<Utc>>,
140    exercise_slide_submission_id: Uuid,
141    exercise_task_id: Uuid,
142    exercise_slide_id: Uuid,
143    data_json: Option<serde_json::Value>,
144    answer_kind: AnswerKind,
145    exercise_task_grading_id: Option<Uuid>,
146    metadata: Option<serde_json::Value>,
147}
148
149/// The stored form of one submission's answer, before the files of a `file` answer are resolved.
150pub struct StoredAnswer {
151    pub submission_id: Uuid,
152    pub answer_kind: AnswerKind,
153    pub data_json: Option<serde_json::Value>,
154}
155
156/// Resolves stored answers into [`AnswerData`] in one batch, minting a download URL per file.
157///
158/// Keyed by submission id. A submission is absent from the map only when its `json` answer has no
159/// `data_json` at all, which is the `None` its DTO carries. Only rows predating
160/// `exercise_task_submissions_json_answer_has_data` can be like that; the constraint is deliberately
161/// left unvalidated so they survive.
162pub async fn attach_answer_data(
163    conn: &mut PgConnection,
164    stored: &[StoredAnswer],
165    file_store: &dyn FileStore,
166    app_conf: &ApplicationConfiguration,
167) -> ModelResult<HashMap<Uuid, AnswerData>> {
168    let file_answer_ids: Vec<Uuid> = stored
169        .iter()
170        .filter(|answer| answer.answer_kind == AnswerKind::File)
171        .map(|answer| answer.submission_id)
172        .collect();
173    let mut files_by_submission: HashMap<Uuid, Vec<AnswerFile>> = HashMap::new();
174    if !file_answer_ids.is_empty() {
175        let files = exercise_task_submission_files::get_by_task_submission_ids(
176            &mut *conn,
177            &file_answer_ids,
178        )
179        .await?;
180        for file in files {
181            files_by_submission
182                .entry(file.exercise_task_submission_id)
183                .or_default()
184                .push(AnswerFile {
185                    id: file.file_upload_id,
186                    name: file.name,
187                    mime: file.mime,
188                    size_bytes: file.size_bytes,
189                    order_number: file.order_number,
190                    url: file_store.get_claimed_download_url(file.file_upload_id, app_conf)?,
191                });
192        }
193    }
194
195    let mut resolved = HashMap::with_capacity(stored.len());
196    for answer in stored {
197        match answer.answer_kind {
198            AnswerKind::Json => {
199                if let Some(data) = answer.data_json.clone() {
200                    resolved.insert(answer.submission_id, AnswerData::Json { data });
201                }
202            }
203            AnswerKind::File => {
204                resolved.insert(
205                    answer.submission_id,
206                    AnswerData::File {
207                        files: files_by_submission
208                            .remove(&answer.submission_id)
209                            .unwrap_or_default(),
210                        metadata: answer.data_json.clone(),
211                    },
212                );
213            }
214        }
215    }
216    Ok(resolved)
217}
218
219async fn resolve_rows(
220    conn: &mut PgConnection,
221    rows: Vec<SubmissionRow>,
222    file_store: &dyn FileStore,
223    app_conf: &ApplicationConfiguration,
224) -> ModelResult<Vec<ExerciseTaskSubmission>> {
225    let stored: Vec<StoredAnswer> = rows
226        .iter()
227        .map(|row| StoredAnswer {
228            submission_id: row.id,
229            answer_kind: row.answer_kind,
230            data_json: row.data_json.clone(),
231        })
232        .collect();
233    let mut answers = attach_answer_data(conn, &stored, file_store, app_conf).await?;
234    Ok(rows
235        .into_iter()
236        .map(|row| {
237            let answer = AnswerFields::from(answers.remove(&row.id));
238            ExerciseTaskSubmission {
239                id: row.id,
240                created_at: row.created_at,
241                updated_at: row.updated_at,
242                deleted_at: row.deleted_at,
243                exercise_slide_submission_id: row.exercise_slide_submission_id,
244                exercise_task_id: row.exercise_task_id,
245                exercise_slide_id: row.exercise_slide_id,
246                answer_kind: answer.answer_kind,
247                data_json: answer.data_json,
248                data_files: answer.data_files,
249                exercise_task_grading_id: row.exercise_task_grading_id,
250                metadata: row.metadata,
251            }
252        })
253        .collect())
254}
255
256async fn resolve_row(
257    conn: &mut PgConnection,
258    row: SubmissionRow,
259    file_store: &dyn FileStore,
260    app_conf: &ApplicationConfiguration,
261) -> ModelResult<ExerciseTaskSubmission> {
262    let mut resolved = resolve_rows(conn, vec![row], file_store, app_conf).await?;
263    resolved.pop().ok_or_else(|| {
264        model_err!(
265            Generic,
266            "Resolving a submission's answer dropped the submission".to_string()
267        )
268    })
269}
270
271/// One file of an answer, as aggregated by a query that resolves its own files.
272#[derive(Deserialize)]
273struct AggregatedAnswerFile {
274    id: Uuid,
275    name: String,
276    mime: String,
277    size_bytes: Option<i64>,
278    order_number: i32,
279}
280
281/// Builds the answer union from files the query aggregated itself, for streamed reads that cannot
282/// batch their rows.
283fn answer_from_aggregated_files(
284    answer_kind: AnswerKind,
285    data_json: Option<serde_json::Value>,
286    files: serde_json::Value,
287    file_store: &dyn FileStore,
288    app_conf: &ApplicationConfiguration,
289) -> ModelResult<Option<AnswerData>> {
290    match answer_kind {
291        AnswerKind::Json => Ok(data_json.map(|data| AnswerData::Json { data })),
292        AnswerKind::File => {
293            let aggregated: Vec<AggregatedAnswerFile> = serde_json::from_value(files)?;
294            Ok(Some(AnswerData::File {
295                files: aggregated
296                    .into_iter()
297                    .map(|file| {
298                        Ok(AnswerFile {
299                            id: file.id,
300                            name: file.name,
301                            mime: file.mime,
302                            size_bytes: file.size_bytes,
303                            order_number: file.order_number,
304                            url: file_store.get_claimed_download_url(file.id, app_conf)?,
305                        })
306                    })
307                    .collect::<ModelResult<_>>()?,
308                metadata: data_json,
309            }))
310        }
311    }
312}
313
314/// The columns a `SubmittedAnswer` writes, plus the files that must be recorded alongside them.
315struct AnswerColumns<'a> {
316    data_json: Option<&'a serde_json::Value>,
317    answer_kind: AnswerKind,
318    file_upload_ids: Option<&'a [Uuid]>,
319}
320
321/// Splits a submitted answer into the columns it is stored in. The ids are assumed to be uploads
322/// the submitter is allowed to name; this does not check that.
323fn answer_columns(answer: &SubmittedAnswer) -> ModelResult<AnswerColumns<'_>> {
324    match answer {
325        SubmittedAnswer::Json { data } => Ok(AnswerColumns {
326            data_json: Some(data),
327            answer_kind: AnswerKind::Json,
328            file_upload_ids: None,
329        }),
330        SubmittedAnswer::File {
331            file_upload_ids,
332            metadata,
333        } => {
334            if file_upload_ids.is_empty() {
335                return Err(model_err!(
336                    InvalidRequest,
337                    "A file answer must name at least one uploaded file.".to_string()
338                ));
339            }
340            Ok(AnswerColumns {
341                data_json: metadata.as_ref(),
342                answer_kind: AnswerKind::File,
343                file_upload_ids: Some(file_upload_ids),
344            })
345        }
346    }
347}
348
349#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
350
351pub struct PeerOrSelfReviewsReceived {
352    pub peer_or_self_review_questions: Vec<PeerOrSelfReviewQuestion>,
353    pub peer_or_self_review_question_submissions: Vec<PeerOrSelfReviewQuestionSubmission>,
354    pub peer_or_self_review_submissions: Vec<PeerOrSelfReviewSubmission>,
355}
356
357#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
358
359pub struct SubmissionData {
360    pub exercise_id: Uuid,
361    pub course_id: Uuid,
362    pub exercise_slide_submission_id: Uuid,
363    pub exercise_slide_id: Uuid,
364    pub exercise_task_id: Uuid,
365    pub user_id: Uuid,
366    pub course_instance_id: Uuid,
367    pub answer: SubmittedAnswer,
368    pub id: Uuid,
369}
370
371#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
372
373pub struct ExportedSubmission {
374    pub id: Uuid,
375    pub user_id: Uuid,
376    pub created_at: DateTime<Utc>,
377    pub exercise_id: Uuid,
378    pub exercise_task_id: Uuid,
379    pub score_given: Option<f32>,
380    pub answer: Option<AnswerData>,
381}
382
383#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
384
385pub struct ExportedCourseSubmission {
386    pub exercise_slide_submission_id: Uuid,
387    pub id: Uuid,
388    pub user_id: Uuid,
389    pub created_at: DateTime<Utc>,
390    pub course_id: Option<Uuid>,
391    pub exercise_id: Uuid,
392    pub exercise_task_id: Uuid,
393    pub score_given: Option<f32>,
394    pub answer: Option<AnswerData>,
395}
396
397/// One row for CSV export: a single attempt at an exercise task for a given exercise_slide_submission (chronological submission order).
398#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
399pub struct ExerciseTaskSubmissionCsvExportData {
400    pub exercise_slide_submission_id: Uuid,
401    pub exercise_task_submission_id: Uuid,
402    pub exercise_task_id: Uuid,
403    pub exercise_id: Uuid,
404    pub user_id: Uuid,
405    pub submitted_at: DateTime<Utc>,
406    pub answer: Option<AnswerData>,
407}
408
409pub async fn get_submission(
410    conn: &mut PgConnection,
411    submission_id: Uuid,
412    file_store: &dyn FileStore,
413    app_conf: &ApplicationConfiguration,
414) -> ModelResult<ExerciseTaskSubmission> {
415    let row = sqlx::query_as!(
416        SubmissionRow,
417        r#"
418SELECT *
419FROM exercise_task_submissions
420WHERE id = $1
421"#,
422        submission_id
423    )
424    .fetch_one(&mut *conn)
425    .await?;
426    resolve_row(conn, row, file_store, app_conf).await
427}
428
429// TODO: Merge with the other insert, but need to resolve different parameters.
430pub async fn insert_with_id(
431    conn: &mut PgConnection,
432    submission_data: &SubmissionData,
433) -> ModelResult<Uuid> {
434    insert(
435        conn,
436        PKeyPolicy::Fixed(submission_data.id),
437        submission_data.exercise_slide_submission_id,
438        submission_data.exercise_slide_id,
439        submission_data.exercise_task_id,
440        &submission_data.answer,
441    )
442    .await
443}
444
445/// Records a task submission and, for a file answer, the files that are the answer.
446///
447/// Both land in one transaction: a file answer whose file rows were rolled back would be an
448/// answer with no content at all.
449pub async fn insert(
450    conn: &mut PgConnection,
451    pkey_policy: PKeyPolicy<Uuid>,
452    exercise_slide_submission_id: Uuid,
453    exercise_slide_id: Uuid,
454    exercise_task_id: Uuid,
455    answer: &SubmittedAnswer,
456) -> ModelResult<Uuid> {
457    let columns = answer_columns(answer)?;
458    let mut tx = conn.begin().await?;
459    let res = sqlx::query!(
460        "
461INSERT INTO exercise_task_submissions (
462    id,
463    exercise_slide_submission_id,
464    exercise_slide_id,
465    exercise_task_id,
466    data_json,
467    answer_kind
468  )
469  VALUES ($1, $2, $3, $4, $5, $6)
470  RETURNING id
471        ",
472        pkey_policy.into_uuid(),
473        exercise_slide_submission_id,
474        exercise_slide_id,
475        exercise_task_id,
476        columns.data_json,
477        columns.answer_kind,
478    )
479    .fetch_one(&mut *tx)
480    .await?;
481    if let Some(file_upload_ids) = columns.file_upload_ids {
482        exercise_task_submission_files::insert_many(&mut tx, res.id, file_upload_ids).await?;
483    }
484    tx.commit().await?;
485    Ok(res.id)
486}
487
488pub async fn get_by_id(
489    conn: &mut PgConnection,
490    id: Uuid,
491    file_store: &dyn FileStore,
492    app_conf: &ApplicationConfiguration,
493) -> ModelResult<ExerciseTaskSubmission> {
494    let row = sqlx::query_as!(
495        SubmissionRow,
496        r#"
497SELECT *
498FROM exercise_task_submissions
499WHERE id = $1
500"#,
501        id
502    )
503    .fetch_one(&mut *conn)
504    .await?;
505    resolve_row(conn, row, file_store, app_conf).await
506}
507
508pub async fn get_by_exercise_slide_submission_id(
509    conn: &mut PgConnection,
510    exercise_slide_submission_id: Uuid,
511    file_store: &dyn FileStore,
512    app_conf: &ApplicationConfiguration,
513) -> ModelResult<Vec<ExerciseTaskSubmission>> {
514    let rows = sqlx::query_as!(
515        SubmissionRow,
516        r#"
517SELECT *
518FROM exercise_task_submissions
519WHERE exercise_slide_submission_id = $1
520        "#,
521        exercise_slide_submission_id
522    )
523    .fetch_all(&mut *conn)
524    .await?;
525    resolve_rows(conn, rows, file_store, app_conf).await
526}
527
528/// A CSV-export row before its answer's files are resolved.
529struct CsvExportRow {
530    exercise_slide_submission_id: Uuid,
531    exercise_task_submission_id: Uuid,
532    exercise_task_id: Uuid,
533    exercise_id: Uuid,
534    user_id: Uuid,
535    submitted_at: DateTime<Utc>,
536    data_json: Option<serde_json::Value>,
537    answer_kind: AnswerKind,
538}
539
540async fn resolve_csv_export_rows(
541    conn: &mut PgConnection,
542    rows: Vec<CsvExportRow>,
543    file_store: &dyn FileStore,
544    app_conf: &ApplicationConfiguration,
545) -> ModelResult<Vec<ExerciseTaskSubmissionCsvExportData>> {
546    let stored: Vec<StoredAnswer> = rows
547        .iter()
548        .map(|row| StoredAnswer {
549            submission_id: row.exercise_task_submission_id,
550            answer_kind: row.answer_kind,
551            data_json: row.data_json.clone(),
552        })
553        .collect();
554    let mut answers = attach_answer_data(conn, &stored, file_store, app_conf).await?;
555    Ok(rows
556        .into_iter()
557        .map(|row| ExerciseTaskSubmissionCsvExportData {
558            exercise_slide_submission_id: row.exercise_slide_submission_id,
559            exercise_task_submission_id: row.exercise_task_submission_id,
560            exercise_task_id: row.exercise_task_id,
561            exercise_id: row.exercise_id,
562            user_id: row.user_id,
563            submitted_at: row.submitted_at,
564            answer: answers.remove(&row.exercise_task_submission_id),
565        })
566        .collect())
567}
568
569/// Fetches CSV-exportable rows at attempt granularity in intended ordering.
570pub async fn get_csv_export_data_by_exercise_and_task(
571    conn: &mut PgConnection,
572    exercise_id: Uuid,
573    exercise_task_id: Uuid,
574    file_store: &dyn FileStore,
575    app_conf: &ApplicationConfiguration,
576) -> ModelResult<Vec<ExerciseTaskSubmissionCsvExportData>> {
577    let rows = sqlx::query_as!(
578        CsvExportRow,
579        r#"
580SELECT ets.exercise_slide_submission_id,
581  ets.id AS exercise_task_submission_id,
582  ets.exercise_task_id,
583  ess.exercise_id,
584  ess.user_id,
585  ets.created_at AS submitted_at,
586  ets.data_json,
587  ets.answer_kind
588FROM exercise_task_submissions ets
589  JOIN exercise_slide_submissions ess ON ets.exercise_slide_submission_id = ess.id
590WHERE ess.exercise_id = $1
591  AND ets.exercise_task_id = $2
592  AND ess.deleted_at IS NULL
593  AND ets.deleted_at IS NULL
594ORDER BY ets.created_at ASC
595        "#,
596        exercise_id,
597        exercise_task_id
598    )
599    .fetch_all(&mut *conn)
600    .await?;
601    resolve_csv_export_rows(conn, rows, file_store, app_conf).await
602}
603
604/// Fetches CSV-exportable rows for the latest submission per user only, ordered by submitted_at.
605pub async fn get_csv_export_data_by_exercise_and_task_latest_per_user(
606    conn: &mut PgConnection,
607    exercise_id: Uuid,
608    exercise_task_id: Uuid,
609    file_store: &dyn FileStore,
610    app_conf: &ApplicationConfiguration,
611) -> ModelResult<Vec<ExerciseTaskSubmissionCsvExportData>> {
612    let rows = sqlx::query_as!(
613        CsvExportRow,
614        r#"
615WITH latest AS (
616  SELECT DISTINCT ON (ess.user_id) ets.id AS exercise_task_submission_id
617  FROM exercise_task_submissions ets
618  JOIN exercise_slide_submissions ess ON ets.exercise_slide_submission_id = ess.id
619  WHERE ess.exercise_id = $1
620    AND ets.exercise_task_id = $2
621    AND ess.deleted_at IS NULL
622    AND ets.deleted_at IS NULL
623  ORDER BY ess.user_id, ets.created_at DESC
624)
625SELECT ets.exercise_slide_submission_id,
626  ets.id AS exercise_task_submission_id,
627  ets.exercise_task_id,
628  ess.exercise_id,
629  ess.user_id,
630  ets.created_at AS submitted_at,
631  ets.data_json,
632  ets.answer_kind
633FROM exercise_task_submissions ets
634JOIN exercise_slide_submissions ess ON ets.exercise_slide_submission_id = ess.id
635JOIN latest ON latest.exercise_task_submission_id = ets.id
636WHERE ess.exercise_id = $1
637  AND ets.exercise_task_id = $2
638  AND ess.deleted_at IS NULL
639  AND ets.deleted_at IS NULL
640ORDER BY ets.created_at ASC
641        "#,
642        exercise_id,
643        exercise_task_id
644    )
645    .fetch_all(&mut *conn)
646    .await?;
647    resolve_csv_export_rows(conn, rows, file_store, app_conf).await
648}
649
650pub async fn get_users_latest_exercise_task_submissions_for_exercise_slide(
651    conn: &mut PgConnection,
652    exercise_slide_id: Uuid,
653    user_id: Uuid,
654    file_store: &dyn FileStore,
655    app_conf: &ApplicationConfiguration,
656) -> ModelResult<Option<Vec<ExerciseTaskSubmission>>> {
657    let exercise_slide_submission =
658        exercise_slide_submissions::try_to_get_users_latest_exercise_slide_submission(
659            conn,
660            exercise_slide_id,
661            user_id,
662        )
663        .await?;
664    if let Some(exercise_slide_submission) = exercise_slide_submission {
665        let rows = sqlx::query_as!(
666            SubmissionRow,
667            r#"
668SELECT *
669FROM exercise_task_submissions
670WHERE exercise_slide_submission_id = $1
671  AND deleted_at IS NULL
672            "#,
673            exercise_slide_submission.id
674        )
675        .fetch_all(&mut *conn)
676        .await?;
677        Ok(Some(resolve_rows(conn, rows, file_store, app_conf).await?))
678    } else {
679        Ok(None)
680    }
681}
682
683pub async fn get_course_and_exam_id(
684    conn: &mut PgConnection,
685    id: Uuid,
686) -> ModelResult<CourseOrExamId> {
687    let res = sqlx::query!(
688        "
689SELECT ess.course_id,
690  ess.exam_id
691FROM exercise_task_submissions ets
692  JOIN exercise_slide_submissions ess ON ets.exercise_slide_submission_id = ess.id
693WHERE ets.id = $1
694  AND ets.deleted_at IS NULL
695  AND ess.deleted_at IS NULL
696        ",
697        id
698    )
699    .fetch_one(conn)
700    .await?;
701    CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
702}
703
704pub async fn get_peer_reviews_received(
705    conn: &mut PgConnection,
706    exercise_id: Uuid,
707    exercise_slide_submission_id: Uuid,
708    user_id: Uuid,
709) -> ModelResult<PeerOrSelfReviewsReceived> {
710    let exercise = crate::exercises::get_by_id(&mut *conn, exercise_id).await?;
711    let peer_or_self_review_config =
712        crate::peer_or_self_review_configs::get_by_exercise_or_course_id(
713            &mut *conn,
714            &exercise,
715            exercise.course_id.ok_or_else(|| {
716                ModelError::new(
717                    ModelErrorType::InvalidRequest,
718                    "Peer reviews work only on courses (and not, for example, on exams)"
719                        .to_string(),
720                    None,
721                )
722            })?,
723        )
724        .await?;
725    let peer_or_self_review_questions =
726        crate::peer_or_self_review_questions::get_by_peer_or_self_review_configs_id(
727            &mut *conn,
728            peer_or_self_review_config.id,
729        )
730        .await?;
731
732    let peer_or_self_review_question_ids = peer_or_self_review_questions
733        .iter()
734        .map(|x| x.id)
735        .collect::<Vec<_>>();
736
737    let peer_or_self_review_submissions =
738        crate::peer_or_self_review_submissions::get_received_peer_or_self_review_submissions_for_user_by_peer_or_self_review_config_id_and_exercise_slide_submission(
739            &mut *conn,
740            user_id,
741            exercise_slide_submission_id,
742            peer_or_self_review_config.id,
743        )
744        .await?;
745
746    let peer_or_self_review_question_submissions =
747        crate::peer_or_self_review_question_submissions::get_by_peer_reviews_question_ids(
748            &mut *conn,
749            &peer_or_self_review_question_ids,
750            user_id,
751            exercise_slide_submission_id,
752        )
753        .await?;
754
755    Ok(PeerOrSelfReviewsReceived {
756        peer_or_self_review_questions,
757        peer_or_self_review_question_submissions,
758        peer_or_self_review_submissions,
759    })
760}
761
762pub async fn set_grading_id(
763    conn: &mut PgConnection,
764    grading_id: Uuid,
765    submission_id: Uuid,
766) -> ModelResult<()> {
767    sqlx::query!(
768        "
769UPDATE exercise_task_submissions
770SET exercise_task_grading_id = $1
771WHERE id = $2
772",
773        grading_id,
774        submission_id
775    )
776    .execute(conn)
777    .await?;
778    Ok(())
779}
780
781/// A submission's grading pointer, for callers that route gradings and never read the answer.
782pub struct SubmissionGradingRef {
783    pub deleted_at: Option<DateTime<Utc>>,
784    pub exercise_task_grading_id: Option<Uuid>,
785}
786
787pub async fn get_grading_ref(
788    conn: &mut PgConnection,
789    id: Uuid,
790) -> ModelResult<SubmissionGradingRef> {
791    let res = sqlx::query_as!(
792        SubmissionGradingRef,
793        "
794SELECT deleted_at,
795  exercise_task_grading_id
796FROM exercise_task_submissions
797WHERE id = $1
798",
799        id
800    )
801    .fetch_one(conn)
802    .await?;
803    Ok(res)
804}
805
806pub fn stream_exam_submissions<'a>(
807    conn: &'a mut PgConnection,
808    exam_id: Uuid,
809    file_store: &'a dyn FileStore,
810    app_conf: &'a ApplicationConfiguration,
811) -> impl Stream<Item = ModelResult<ExportedSubmission>> + 'a {
812    sqlx::query!(
813        r#"
814SELECT exercise_task_submissions.id,
815  user_id,
816  exercise_task_submissions.created_at,
817  exercise_slide_submissions.exercise_id,
818  exercise_task_submissions.exercise_task_id,
819  exercise_task_gradings.score_given,
820  exercise_task_submissions.data_json,
821  exercise_task_submissions.answer_kind,
822  answer_files.files AS "files!: serde_json::Value"
823FROM exercise_task_submissions
824  JOIN exercise_slide_submissions ON exercise_task_submissions.exercise_slide_submission_id = exercise_slide_submissions.id
825  JOIN exercise_task_gradings on exercise_task_submissions.exercise_task_grading_id = exercise_task_gradings.id
826  JOIN exercises on exercise_slide_submissions.exercise_id = exercises.id
827  LEFT JOIN LATERAL (
828    SELECT COALESCE(
829        jsonb_agg(
830          jsonb_build_object(
831            'id', fu.id,
832            'name', fu.name,
833            'mime', fu.mime,
834            'size_bytes', fu.size_bytes,
835            'path', fu.path,
836            'order_number', etsf.order_number
837          )
838          ORDER BY etsf.order_number
839        ),
840        '[]'::jsonb
841      ) AS files
842    FROM exercise_task_submission_files etsf
843      JOIN file_uploads fu ON fu.id = etsf.file_upload_id
844    WHERE etsf.exercise_task_submission_id = exercise_task_submissions.id
845      AND etsf.deleted_at IS NULL
846      AND fu.deleted_at IS NULL
847  ) answer_files ON TRUE
848WHERE exercise_slide_submissions.exam_id = $1
849  AND exercise_task_submissions.deleted_at IS NULL
850  AND exercise_task_gradings.deleted_at IS NULL
851  AND exercises.deleted_at IS NULL;
852        "#,
853        exam_id
854    )
855    .fetch(conn)
856    .map(move |row| {
857        let row = row?;
858        Ok(ExportedSubmission {
859            id: row.id,
860            user_id: row.user_id,
861            created_at: row.created_at,
862            exercise_id: row.exercise_id,
863            exercise_task_id: row.exercise_task_id,
864            score_given: row.score_given,
865            answer: answer_from_aggregated_files(
866                row.answer_kind,
867                row.data_json,
868                row.files,
869                file_store,
870                app_conf,
871            )?,
872        })
873    })
874}
875
876pub fn stream_course_submissions<'a>(
877    conn: &'a mut PgConnection,
878    course_id: Uuid,
879    file_store: &'a dyn FileStore,
880    app_conf: &'a ApplicationConfiguration,
881) -> impl Stream<Item = ModelResult<ExportedCourseSubmission>> + 'a {
882    sqlx::query!(
883        r#"
884SELECT exercise_task_submissions.exercise_slide_submission_id,
885  exercise_task_submissions.id,
886  user_id,
887  exercise_task_submissions.created_at,
888  exercise_slide_submissions.course_id,
889  exercise_slide_submissions.exercise_id,
890  exercise_task_submissions.exercise_task_id,
891  exercise_task_gradings.score_given,
892  exercise_task_submissions.data_json,
893  exercise_task_submissions.answer_kind,
894  answer_files.files AS "files!: serde_json::Value"
895FROM exercise_task_submissions
896  JOIN exercise_slide_submissions ON exercise_task_submissions.exercise_slide_submission_id = exercise_slide_submissions.id
897  JOIN exercise_task_gradings ON exercise_task_submissions.exercise_task_grading_id = exercise_task_gradings.id
898  JOIN exercises ON exercise_slide_submissions.exercise_id = exercises.id
899  LEFT JOIN LATERAL (
900    SELECT COALESCE(
901        jsonb_agg(
902          jsonb_build_object(
903            'id', fu.id,
904            'name', fu.name,
905            'mime', fu.mime,
906            'size_bytes', fu.size_bytes,
907            'path', fu.path,
908            'order_number', etsf.order_number
909          )
910          ORDER BY etsf.order_number
911        ),
912        '[]'::jsonb
913      ) AS files
914    FROM exercise_task_submission_files etsf
915      JOIN file_uploads fu ON fu.id = etsf.file_upload_id
916    WHERE etsf.exercise_task_submission_id = exercise_task_submissions.id
917      AND etsf.deleted_at IS NULL
918      AND fu.deleted_at IS NULL
919  ) answer_files ON TRUE
920WHERE exercise_slide_submissions.course_id = $1
921  AND exercise_slide_submissions.deleted_at IS NULL
922  AND exercise_task_submissions.deleted_at IS NULL
923  AND exercise_task_gradings.deleted_at IS NULL
924  AND exercises.deleted_at IS NULL;
925        "#,
926        course_id
927    )
928    .fetch(conn)
929    .map(move |row| {
930        let row = row?;
931        Ok(ExportedCourseSubmission {
932            exercise_slide_submission_id: row.exercise_slide_submission_id,
933            id: row.id,
934            user_id: row.user_id,
935            created_at: row.created_at,
936            course_id: row.course_id,
937            exercise_id: row.exercise_id,
938            exercise_task_id: row.exercise_task_id,
939            score_given: row.score_given,
940            answer: answer_from_aggregated_files(
941                row.answer_kind,
942                row.data_json,
943                row.files,
944                file_store,
945                app_conf,
946            )?,
947        })
948    })
949}
950
951/// Used to get the necessary info for rendering a submission either when we're viewing a submission, or we're conducting a peer review.
952pub async fn get_exercise_task_submission_info_by_exercise_slide_submission_id(
953    conn: &mut PgConnection,
954    exercise_slide_submission_id: Uuid,
955    viewer_user_id: Uuid,
956    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
957    include_deleted_tasks: bool,
958    file_store: &dyn FileStore,
959    app_conf: &ApplicationConfiguration,
960) -> ModelResult<Vec<CourseMaterialExerciseTask>> {
961    let task_submisssions = crate::exercise_task_submissions::get_by_exercise_slide_submission_id(
962        &mut *conn,
963        exercise_slide_submission_id,
964        file_store,
965        app_conf,
966    )
967    .await?;
968    let exercise_task_gradings =
969        crate::exercise_task_gradings::get_all_gradings_by_exercise_slide_submission_id(
970            &mut *conn,
971            exercise_slide_submission_id,
972        )
973        .await?;
974
975    let exercise_tasks = if include_deleted_tasks {
976        crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_id_including_deleted::<
977            Vec<ExerciseTask>,
978        >(&mut *conn, &task_submisssions[0].exercise_slide_id)
979        .await?
980    } else {
981        crate::exercise_tasks::get_exercise_tasks_by_exercise_slide_id::<Vec<ExerciseTask>>(
982            &mut *conn,
983            &task_submisssions[0].exercise_slide_id,
984        )
985        .await?
986    };
987
988    let mut res = Vec::with_capacity(task_submisssions.len());
989
990    let unique_exercise_service_slugs = exercise_tasks
991        .iter()
992        .cloned()
993        .map(|et| et.exercise_type)
994        .collect::<HashSet<_>>()
995        .into_iter()
996        .collect::<Vec<_>>();
997    let exercise_service_slug_to_service_and_info =
998        exercise_service_info::get_selected_exercise_services_by_type(
999            &mut *conn,
1000            &unique_exercise_service_slugs,
1001            fetch_service_info,
1002        )
1003        .await?;
1004
1005    for ts in task_submisssions {
1006        let grading = exercise_task_gradings
1007            .iter()
1008            .find(|g| Some(g.id) == ts.exercise_task_grading_id)
1009            .ok_or_else(|| {
1010                ModelError::new(
1011                    ModelErrorType::NotFound,
1012                    "Grading not found".to_string(),
1013                    None,
1014                )
1015            })?;
1016        let task = exercise_tasks
1017            .iter()
1018            .find(|t| t.id == ts.exercise_task_id)
1019            .ok_or_else(|| {
1020                ModelError::new(
1021                    ModelErrorType::NotFound,
1022                    "Exercise task not found".to_string(),
1023                    None,
1024                )
1025            })?;
1026        let (exercise_service, service_info) = exercise_service_slug_to_service_and_info
1027            .get(&task.exercise_type)
1028            .ok_or_else(|| {
1029                ModelError::new(
1030                    ModelErrorType::InvalidRequest,
1031                    "Exercise service not found".to_string(),
1032                    None,
1033                )
1034            })?;
1035        let mut exercise_iframe_url =
1036            exercise_services::get_exercise_service_externally_preferred_baseurl(exercise_service)?;
1037        exercise_iframe_url.set_path(&service_info.user_interface_iframe_path);
1038        let course_material_exercise_task = CourseMaterialExerciseTask {
1039            id: task.id,
1040            exercise_service_slug: task.exercise_type.clone(),
1041            exercise_slide_id: task.exercise_slide_id,
1042            exercise_iframe_url: Some(exercise_iframe_url.to_string()),
1043            pseudonumous_user_id: Some(Uuid::new_v5(
1044                &service_info.exercise_service_id,
1045                viewer_user_id.as_bytes(),
1046            )),
1047            assignment: task.assignment.clone(),
1048            public_spec: task.public_spec.clone(),
1049            model_solution_spec: task.model_solution_spec.clone(),
1050            previous_submission: Some(ts),
1051            previous_submission_grading: Some(grading.clone()),
1052            order_number: task.order_number,
1053            deleted_at: task.deleted_at,
1054        };
1055        res.push(course_material_exercise_task);
1056    }
1057    Ok(res)
1058}
1059
1060pub async fn get_user_custom_view_exercise_tasks_by_module_and_exercise_type(
1061    conn: &mut PgConnection,
1062    exercise_type: &str,
1063    course_module_id: Uuid,
1064    user_id: Uuid,
1065    course_id: Uuid,
1066    file_store: &dyn FileStore,
1067    app_conf: &ApplicationConfiguration,
1068) -> ModelResult<CustomViewExerciseTasks> {
1069    let task_submissions =
1070        crate::exercise_task_submissions::get_user_latest_exercise_task_submissions_by_course_module_and_exercise_type(
1071            &mut *conn,
1072            user_id,
1073            exercise_type,
1074            course_module_id,
1075            course_id,
1076            file_store,
1077            app_conf,
1078        )
1079        .await?;
1080    let task_gradings =
1081        crate::exercise_task_gradings::get_user_exercise_task_gradings_by_module_and_exercise_type(
1082            &mut *conn,
1083            user_id,
1084            exercise_type,
1085            course_module_id,
1086            course_id,
1087        )
1088        .await?;
1089
1090    let exercise_tasks = crate::exercise_tasks::get_all_exercise_tasks_by_module_and_exercise_type(
1091        &mut *conn,
1092        exercise_type,
1093        course_module_id,
1094    )
1095    .await?;
1096    let res: CustomViewExerciseTasks = CustomViewExerciseTasks {
1097        exercise_tasks,
1098        task_submissions,
1099        task_gradings,
1100    };
1101    Ok(res)
1102}
1103
1104/// get all submissions for user and course module and exercise type
1105pub async fn get_user_latest_exercise_task_submissions_by_course_module_and_exercise_type(
1106    conn: &mut PgConnection,
1107    user_id: Uuid,
1108    exercise_type: &str,
1109    module_id: Uuid,
1110    course_id: Uuid,
1111    file_store: &dyn FileStore,
1112    app_conf: &ApplicationConfiguration,
1113) -> ModelResult<Vec<CustomViewExerciseTaskSubmission>> {
1114    let rows = sqlx::query!(
1115        r#"
1116        SELECT DISTINCT ON (g.exercise_task_id)
1117        g.id,
1118        g.created_at,
1119        g.exercise_slide_submission_id,
1120        g.exercise_slide_id,
1121        g.exercise_task_id,
1122        g.exercise_task_grading_id,
1123        g.data_json,
1124        g.answer_kind
1125      FROM exercise_task_submissions g
1126        JOIN exercise_tasks et ON et.id = g.exercise_task_id
1127        JOIN exercise_slide_submissions ess ON ess.id = g.exercise_slide_submission_id
1128        JOIN exercises e ON e.id = ess.exercise_id
1129        JOIN chapters c ON c.id = e.chapter_id
1130      WHERE ess.user_id = $1
1131      AND ess.course_id = $2
1132      AND et.exercise_type = $3
1133      AND c.course_module_id = $4
1134      AND g.deleted_at IS NULL
1135      AND et.deleted_at IS NULL
1136      AND ess.deleted_at IS NULL
1137      AND e.deleted_at IS NULL
1138      AND c.deleted_at IS NULL
1139      ORDER BY g.exercise_task_id, g.created_at DESC
1140      "#,
1141        user_id,
1142        course_id,
1143        exercise_type,
1144        module_id
1145    )
1146    .fetch_all(&mut *conn)
1147    .await?;
1148    let stored: Vec<StoredAnswer> = rows
1149        .iter()
1150        .map(|row| StoredAnswer {
1151            submission_id: row.id,
1152            answer_kind: row.answer_kind,
1153            data_json: row.data_json.clone(),
1154        })
1155        .collect();
1156    let mut answers = attach_answer_data(conn, &stored, file_store, app_conf).await?;
1157    Ok(rows
1158        .into_iter()
1159        .map(|row| {
1160            let answer = AnswerFields::from(answers.remove(&row.id));
1161            CustomViewExerciseTaskSubmission {
1162                id: row.id,
1163                created_at: row.created_at,
1164                exercise_slide_submission_id: row.exercise_slide_submission_id,
1165                exercise_slide_id: row.exercise_slide_id,
1166                exercise_task_id: row.exercise_task_id,
1167                exercise_task_grading_id: row.exercise_task_grading_id,
1168                answer_kind: answer.answer_kind,
1169                data_json: answer.data_json,
1170                data_files: answer.data_files,
1171            }
1172        })
1173        .collect())
1174}
1175
1176pub async fn get_ids_by_exercise_id(
1177    conn: &mut PgConnection,
1178    exercise_id: Uuid,
1179) -> ModelResult<Vec<Uuid>> {
1180    let res = sqlx::query!(
1181        "
1182SELECT id
1183FROM exercise_task_submissions
1184WHERE exercise_slide_submission_id IN (
1185    SELECT id
1186    FROM exercise_slide_submissions
1187    WHERE exercise_id = $1
1188)
1189AND deleted_at IS NULL
1190",
1191        &exercise_id
1192    )
1193    .fetch_all(conn)
1194    .await?;
1195    Ok(res.iter().map(|x| x.id).collect())
1196}
1197
1198/// Similar to get_ids_by_exercise_id but returns the record with the highest created_at for a user_id
1199pub async fn get_latest_submission_ids_by_exercise_id(
1200    conn: &mut PgConnection,
1201    exercise_id: Uuid,
1202) -> ModelResult<Vec<Uuid>> {
1203    let res = sqlx::query!(
1204        "
1205SELECT id
1206FROM exercise_task_submissions
1207WHERE exercise_slide_submission_id IN (SELECT id
1208    FROM (SELECT DISTINCT ON (user_id, exercise_id) *
1209        FROM exercise_slide_submissions
1210        WHERE exercise_id = $1
1211        AND deleted_at IS NULL
1212        ORDER BY user_id, exercise_id, created_at DESC) a )
1213    AND deleted_at IS NULL
1214",
1215        &exercise_id
1216    )
1217    .fetch_all(conn)
1218    .await?;
1219    Ok(res.iter().map(|x| x.id).collect())
1220}
1221
1222/// Which representation holds a submission's answer.
1223///
1224/// Cheaper than [`get_by_id`] when only the discriminant matters, and unlike the resolved
1225/// [`AnswerData`] it reports the stored column rather than what the rows resolve to.
1226pub async fn get_answer_kind(conn: &mut PgConnection, id: Uuid) -> ModelResult<AnswerKind> {
1227    let kind = sqlx::query_scalar!(
1228        "SELECT answer_kind FROM exercise_task_submissions WHERE id = $1",
1229        id
1230    )
1231    .fetch_one(conn)
1232    .await?;
1233    Ok(kind)
1234}
1235
1236/// Moves a submission's creation time by `offset`, which may be negative.
1237///
1238/// Every row a transaction inserts shares one Postgres `now()`, so submission order is otherwise
1239/// unobservable within it.
1240pub async fn shift_created_at(
1241    conn: &mut PgConnection,
1242    id: Uuid,
1243    offset: Duration,
1244) -> ModelResult<()> {
1245    sqlx::query!(
1246        "
1247UPDATE exercise_task_submissions
1248SET created_at = now() + $2::interval
1249WHERE id = $1
1250",
1251        id,
1252        offset as Duration
1253    )
1254    .execute(conn)
1255    .await?;
1256    Ok(())
1257}
1258
1259/// Soft-deletes one task submission, leaving its slide submission alone.
1260pub async fn delete_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
1261    sqlx::query!(
1262        "UPDATE exercise_task_submissions SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL",
1263        id
1264    )
1265    .execute(conn)
1266    .await?;
1267    Ok(())
1268}
1269
1270#[cfg(test)]
1271mod test {
1272    use super::*;
1273    use crate::exercise_slide_submissions::{
1274        NewExerciseSlideSubmission, insert_exercise_slide_submission,
1275    };
1276    use crate::exercise_task_gradings::UserPointsUpdateStrategy;
1277    use crate::test_helper::*;
1278    use headless_lms_base::jwt::DOWNLOAD_CLAIM_PARAM;
1279
1280    /// The exercise service protocol declares `size_bytes` optional but not nullable, so a null
1281    /// would make a plugin's generated guard reject the whole iframe state, not just the size.
1282    #[test]
1283    fn unknown_file_size_serializes_as_an_absent_key() {
1284        let file = AnswerFile {
1285            id: Uuid::new_v4(),
1286            name: "a.tar.zst".to_string(),
1287            mime: "application/octet-stream".to_string(),
1288            size_bytes: None,
1289            order_number: 0,
1290            url: "http://project-331.local/api/v0/files/uploads/a.tar.zst".to_string(),
1291        };
1292
1293        let json = serde_json::to_value(&file).unwrap();
1294
1295        assert!(json.get("size_bytes").is_none());
1296    }
1297
1298    async fn insert_slide_submission(
1299        tx: &mut PgConnection,
1300        course_id: Uuid,
1301        user_id: Uuid,
1302        exercise_id: Uuid,
1303        exercise_slide_id: Uuid,
1304    ) -> Uuid {
1305        insert_exercise_slide_submission(
1306            tx,
1307            NewExerciseSlideSubmission {
1308                exercise_slide_id,
1309                course_id: Some(course_id),
1310                exam_id: None,
1311                user_id,
1312                exercise_id,
1313                user_points_update_strategy:
1314                    UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
1315            },
1316        )
1317        .await
1318        .unwrap()
1319        .id
1320    }
1321
1322    async fn insert_file(tx: &mut PgConnection, name: &str, size_bytes: Option<i64>) -> Uuid {
1323        crate::file_uploads::insert(
1324            tx,
1325            name,
1326            &format!("uploads/{name}"),
1327            "application/octet-stream",
1328            None,
1329            size_bytes,
1330        )
1331        .await
1332        .unwrap()
1333    }
1334
1335    #[tokio::test]
1336    async fn file_answer_records_its_files_in_order() {
1337        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
1338        let slide_submission_id =
1339            insert_slide_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
1340        let first = insert_file(tx.as_mut(), "a.tar.zst", Some(11)).await;
1341        let second = insert_file(tx.as_mut(), "b.tar.zst", None).await;
1342
1343        let submission_id = insert(
1344            tx.as_mut(),
1345            PKeyPolicy::Generate,
1346            slide_submission_id,
1347            slide_id,
1348            task_id,
1349            &SubmittedAnswer::File {
1350                file_upload_ids: vec![first, second],
1351                metadata: Some(serde_json::json!({ "plugin": "owned" })),
1352            },
1353        )
1354        .await
1355        .unwrap();
1356
1357        let app_conf = init_app_conf().expect("app conf");
1358        let submission = get_by_id(tx.as_mut(), submission_id, &init_file_store(), &app_conf)
1359            .await
1360            .unwrap();
1361        assert_eq!(submission.answer_kind, AnswerKind::File);
1362        assert_eq!(
1363            submission.data_json,
1364            Some(serde_json::json!({ "plugin": "owned" }))
1365        );
1366        let files = submission.data_files.expect("a file answer names files");
1367        assert_eq!(
1368            files
1369                .iter()
1370                .map(|file| (file.id, file.order_number, file.name.as_str()))
1371                .collect::<Vec<_>>(),
1372            vec![(first, 0, "a.tar.zst"), (second, 1, "b.tar.zst")]
1373        );
1374        assert_eq!(files[0].size_bytes, Some(11));
1375        assert_eq!(files[1].size_bytes, None);
1376        // A claim for the file, never its storage path.
1377        assert!(files[0].url.starts_with(&format!(
1378            "{}/api/v0/files/claimed/{first}?{DOWNLOAD_CLAIM_PARAM}=",
1379            app_conf.base_url
1380        )));
1381        tx.rollback().await;
1382    }
1383
1384    /// A file answer with no files would be an answer with no content, and no database constraint
1385    /// can catch it because the files live in another table.
1386    #[tokio::test]
1387    async fn file_answer_without_files_is_rejected() {
1388        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
1389        let slide_submission_id =
1390            insert_slide_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
1391
1392        insert(
1393            tx.as_mut(),
1394            PKeyPolicy::Generate,
1395            slide_submission_id,
1396            slide_id,
1397            task_id,
1398            &SubmittedAnswer::File {
1399                file_upload_ids: Vec::new(),
1400                metadata: None,
1401            },
1402        )
1403        .await
1404        .expect_err("a file answer naming no files must be rejected");
1405        tx.rollback().await;
1406    }
1407
1408    #[tokio::test]
1409    async fn json_answer_reads_back_as_json() {
1410        insert_data!(:tx, user:user_id, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
1411        let slide_submission_id =
1412            insert_slide_submission(tx.as_mut(), course_id, user_id, exercise_id, slide_id).await;
1413
1414        let submission_id = insert(
1415            tx.as_mut(),
1416            PKeyPolicy::Generate,
1417            slide_submission_id,
1418            slide_id,
1419            task_id,
1420            &SubmittedAnswer::Json {
1421                data: serde_json::json!({ "opaque": "plugin owned" }),
1422            },
1423        )
1424        .await
1425        .unwrap();
1426
1427        let app_conf = init_app_conf().expect("app conf");
1428        let submission = get_by_id(tx.as_mut(), submission_id, &init_file_store(), &app_conf)
1429            .await
1430            .unwrap();
1431        assert_eq!(submission.answer_kind, AnswerKind::Json);
1432        assert_eq!(
1433            submission.data_json,
1434            Some(serde_json::json!({ "opaque": "plugin owned" }))
1435        );
1436        assert_eq!(submission.data_files, None);
1437        tx.rollback().await;
1438    }
1439}