Skip to main content

headless_lms_models/
exercise_task_submissions.rs

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