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#[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#[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 metadata: Option<serde_json::Value>,
46 },
47}
48
49impl AnswerData {
50 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
63pub struct AnswerFile {
64 pub id: Uuid,
65 pub name: String,
68 pub mime: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
73 pub size_bytes: Option<i64>,
74 pub order_number: i32,
75 pub url: String,
77}
78
79pub 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 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 pub data_json: Option<serde_json::Value>,
127 #[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
134struct 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
149pub struct StoredAnswer {
151 pub submission_id: Uuid,
152 pub answer_kind: AnswerKind,
153 pub data_json: Option<serde_json::Value>,
154}
155
156pub 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#[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
282fn 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
313struct AnswerColumns<'a> {
315 data_json: Option<&'a serde_json::Value>,
316 answer_kind: AnswerKind,
317 file_upload_ids: Option<&'a [Uuid]>,
318}
319
320fn 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#[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
428pub 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
444pub 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
527struct 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
568pub 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
603pub 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
780pub 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
950pub 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
1103pub 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
1197pub 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
1221pub 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
1235pub 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
1258pub 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 #[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 #[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}