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