1use crate::prelude::*;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SubmissionFile {
12 pub exercise_task_submission_id: Uuid,
13 pub file_upload_id: Uuid,
14 pub name: String,
15 pub path: String,
16 pub mime: String,
17 pub size_bytes: Option<i64>,
19 pub order_number: i32,
20}
21
22pub async fn insert_many(
24 conn: &mut PgConnection,
25 exercise_task_submission_id: Uuid,
26 file_upload_ids: &[Uuid],
27) -> ModelResult<()> {
28 sqlx::query!(
29 "
30INSERT INTO exercise_task_submission_files (
31 exercise_task_submission_id,
32 file_upload_id,
33 order_number
34 )
35SELECT $1,
36 file_upload_id,
37 (ordinality - 1)::integer
38FROM UNNEST($2::uuid []) WITH ORDINALITY AS t(file_upload_id, ordinality)
39",
40 exercise_task_submission_id,
41 file_upload_ids
42 )
43 .execute(conn)
44 .await?;
45 Ok(())
46}
47
48pub async fn get_by_task_submission_ids(
52 conn: &mut PgConnection,
53 exercise_task_submission_ids: &[Uuid],
54) -> ModelResult<Vec<SubmissionFile>> {
55 let res = sqlx::query_as!(
56 SubmissionFile,
57 "
58SELECT etsf.exercise_task_submission_id,
59 etsf.file_upload_id,
60 fu.name,
61 fu.path,
62 fu.mime,
63 fu.size_bytes,
64 etsf.order_number
65FROM exercise_task_submission_files AS etsf
66 JOIN file_uploads AS fu ON fu.id = etsf.file_upload_id
67WHERE etsf.exercise_task_submission_id = ANY($1)
68 AND etsf.deleted_at IS NULL
69 AND fu.deleted_at IS NULL
70ORDER BY etsf.exercise_task_submission_id,
71 etsf.order_number
72",
73 exercise_task_submission_ids
74 )
75 .fetch_all(conn)
76 .await?;
77 Ok(res)
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ExerciseAnswerFile {
87 pub user_id: Uuid,
88 pub exercise_task_submission_id: Uuid,
89 pub created_at: DateTime<Utc>,
90 pub path: String,
91 pub mime: String,
92 pub order_number: i32,
93}
94
95pub async fn get_answer_files_by_exercise_id(
101 conn: &mut PgConnection,
102 exercise_id: Uuid,
103) -> ModelResult<Vec<ExerciseAnswerFile>> {
104 let res = sqlx::query_as!(
105 ExerciseAnswerFile,
106 "
107SELECT ess.user_id,
108 ets.id AS exercise_task_submission_id,
109 ets.created_at,
110 fu.path,
111 fu.mime,
112 etsf.order_number
113FROM exercise_task_submissions AS ets
114 JOIN exercise_slide_submissions AS ess ON ets.exercise_slide_submission_id = ess.id
115 JOIN exercise_task_submission_files AS etsf ON etsf.exercise_task_submission_id = ets.id
116 JOIN file_uploads AS fu ON fu.id = etsf.file_upload_id
117WHERE ess.exercise_id = $1
118 AND ets.answer_kind = 'file'
119 AND ets.deleted_at IS NULL
120 AND ess.deleted_at IS NULL
121 AND etsf.deleted_at IS NULL
122 AND fu.deleted_at IS NULL
123ORDER BY ess.user_id,
124 ets.created_at,
125 etsf.order_number
126",
127 exercise_id
128 )
129 .fetch_all(conn)
130 .await?;
131 Ok(res)
132}
133
134pub async fn exercise_has_answer_files(
139 conn: &mut PgConnection,
140 exercise_id: Uuid,
141) -> ModelResult<bool> {
142 let res = sqlx::query!(
143 r#"
144SELECT EXISTS (
145 SELECT 1
146 FROM exercise_task_submissions AS ets
147 JOIN exercise_slide_submissions AS ess ON ets.exercise_slide_submission_id = ess.id
148 JOIN exercise_task_submission_files AS etsf ON etsf.exercise_task_submission_id = ets.id
149 JOIN file_uploads AS fu ON fu.id = etsf.file_upload_id
150 WHERE ess.exercise_id = $1
151 AND ets.answer_kind = 'file'
152 AND ets.deleted_at IS NULL
153 AND ess.deleted_at IS NULL
154 AND etsf.deleted_at IS NULL
155 AND fu.deleted_at IS NULL
156 ) AS "exists!"
157"#,
158 exercise_id
159 )
160 .fetch_one(conn)
161 .await?;
162 Ok(res.exists)
163}
164
165pub async fn get_positions_by_task_submission_id(
170 conn: &mut PgConnection,
171 task_submission_id: Uuid,
172) -> ModelResult<Vec<(Uuid, i32)>> {
173 let rows = sqlx::query!(
174 "
175SELECT file_upload_id,
176 order_number
177FROM exercise_task_submission_files
178WHERE exercise_task_submission_id = $1
179 AND deleted_at IS NULL
180ORDER BY order_number
181",
182 task_submission_id
183 )
184 .fetch_all(conn)
185 .await?;
186 Ok(rows
187 .into_iter()
188 .map(|row| (row.file_upload_id, row.order_number))
189 .collect())
190}
191
192pub async fn delete_by_task_submission_and_file_upload_ids(
194 conn: &mut PgConnection,
195 task_submission_id: Uuid,
196 file_upload_id: Uuid,
197) -> ModelResult<()> {
198 sqlx::query!(
199 "
200UPDATE exercise_task_submission_files
201SET deleted_at = now()
202WHERE exercise_task_submission_id = $1
203 AND file_upload_id = $2
204 AND deleted_at IS NULL
205",
206 task_submission_id,
207 file_upload_id
208 )
209 .execute(conn)
210 .await?;
211 Ok(())
212}
213
214#[cfg(test)]
215mod test {
216 use super::*;
217 use crate::exercise_slide_submissions::{
218 NewExerciseSlideSubmission, insert_exercise_slide_submission,
219 };
220 use crate::exercise_task_gradings::UserPointsUpdateStrategy;
221 use crate::library::grading::SubmittedAnswer;
222 use crate::test_helper::*;
223
224 async fn insert_task_submission(
225 tx: &mut PgConnection,
226 course_id: Uuid,
227 user_id: Uuid,
228 exercise_id: Uuid,
229 exercise_slide_id: Uuid,
230 exercise_task_id: Uuid,
231 ) -> Uuid {
232 let slide_submission = insert_exercise_slide_submission(
233 tx,
234 NewExerciseSlideSubmission {
235 exercise_slide_id,
236 course_id: Some(course_id),
237 exam_id: None,
238 user_id,
239 exercise_id,
240 user_points_update_strategy:
241 UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
242 },
243 )
244 .await
245 .unwrap();
246 crate::exercise_task_submissions::insert(
247 tx,
248 PKeyPolicy::Generate,
249 slide_submission.id,
250 exercise_slide_id,
251 exercise_task_id,
252 &SubmittedAnswer::Json {
253 data: serde_json::json!({ "opaque": "plugin owned" }),
254 },
255 )
256 .await
257 .unwrap()
258 }
259
260 async fn insert_file(tx: &mut PgConnection, name: &str) -> Uuid {
261 crate::file_uploads::insert(
262 tx,
263 name,
264 &format!("tmc/{name}"),
265 "application/octet-stream",
266 None,
267 None,
268 )
269 .await
270 .unwrap()
271 }
272
273 async fn insert_file_answer(
274 tx: &mut PgConnection,
275 course_id: Uuid,
276 user_id: Uuid,
277 exercise_id: Uuid,
278 exercise_slide_id: Uuid,
279 exercise_task_id: Uuid,
280 file_upload_ids: &[Uuid],
281 ) -> (Uuid, Uuid) {
282 let slide_submission = insert_exercise_slide_submission(
283 tx,
284 NewExerciseSlideSubmission {
285 exercise_slide_id,
286 course_id: Some(course_id),
287 exam_id: None,
288 user_id,
289 exercise_id,
290 user_points_update_strategy:
291 UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
292 },
293 )
294 .await
295 .unwrap();
296 let submission_id = crate::exercise_task_submissions::insert(
297 tx,
298 PKeyPolicy::Generate,
299 slide_submission.id,
300 exercise_slide_id,
301 exercise_task_id,
302 &SubmittedAnswer::File {
303 file_upload_ids: file_upload_ids.to_vec(),
304 metadata: None,
305 },
306 )
307 .await
308 .unwrap();
309 (slide_submission.id, submission_id)
310 }
311
312 async fn set_submitted_at(tx: &mut PgConnection, submission_id: Uuid, seconds: i64) {
315 crate::exercise_task_submissions::shift_created_at(
316 tx,
317 submission_id,
318 chrono::Duration::seconds(seconds),
319 )
320 .await
321 .unwrap();
322 }
323
324 #[tokio::test]
325 async fn returns_file_answers_ordered_by_user_then_submission_then_position() {
326 insert_data!(:tx, user:first_user, :org, course:course_id, instance:_instance, course_module:_cm, chapter:_chapter, page:_page, exercise:exercise_id, slide:slide_id, task:task_id);
327 let second_user = crate::users::insert(
328 tx.as_mut(),
329 PKeyPolicy::Generate,
330 "ordering@example.org",
331 None,
332 None,
333 )
334 .await
335 .unwrap();
336 let (lesser_user, greater_user) = if first_user < second_user {
337 (first_user, second_user)
338 } else {
339 (second_user, first_user)
340 };
341
342 let later_first = insert_file(tx.as_mut(), "later-first.png").await;
343 let later_second = insert_file(tx.as_mut(), "later-second.png").await;
344 let earlier = insert_file(tx.as_mut(), "earlier.png").await;
345 let other_user_file = insert_file(tx.as_mut(), "other-user.png").await;
346
347 let (_, later) = insert_file_answer(
348 tx.as_mut(),
349 course_id,
350 lesser_user,
351 exercise_id,
352 slide_id,
353 task_id,
354 &[later_first, later_second],
355 )
356 .await;
357 set_submitted_at(tx.as_mut(), later, 60).await;
358 let (_, earlier_submission) = insert_file_answer(
359 tx.as_mut(),
360 course_id,
361 lesser_user,
362 exercise_id,
363 slide_id,
364 task_id,
365 &[earlier],
366 )
367 .await;
368 set_submitted_at(tx.as_mut(), earlier_submission, 0).await;
369 let (_, other_user_submission) = insert_file_answer(
370 tx.as_mut(),
371 course_id,
372 greater_user,
373 exercise_id,
374 slide_id,
375 task_id,
376 &[other_user_file],
377 )
378 .await;
379
380 let files = get_answer_files_by_exercise_id(tx.as_mut(), exercise_id)
381 .await
382 .unwrap();
383 assert_eq!(
384 files
385 .iter()
386 .map(|file| (
387 file.user_id,
388 file.exercise_task_submission_id,
389 file.order_number
390 ))
391 .collect::<Vec<_>>(),
392 vec![
393 (lesser_user, earlier_submission, 0),
394 (lesser_user, later, 0),
395 (lesser_user, later, 1),
396 (greater_user, other_user_submission, 0),
397 ]
398 );
399 tx.rollback().await;
400 }
401
402 #[tokio::test]
403 async fn omits_json_answers() {
404 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);
405 let json_submission = insert_task_submission(
406 tx.as_mut(),
407 course_id,
408 user_id,
409 exercise_id,
410 slide_id,
411 task_id,
412 )
413 .await;
414 let file_id = insert_file(tx.as_mut(), "smuggled.png").await;
416 insert_many(tx.as_mut(), json_submission, &[file_id])
417 .await
418 .unwrap();
419
420 assert!(
421 get_answer_files_by_exercise_id(tx.as_mut(), exercise_id)
422 .await
423 .unwrap()
424 .is_empty()
425 );
426 tx.rollback().await;
427 }
428
429 async fn insert_two_file_answers(
432 tx: &mut PgConnection,
433 course_id: Uuid,
434 user_id: Uuid,
435 exercise_id: Uuid,
436 slide_id: Uuid,
437 task_id: Uuid,
438 ) -> (Uuid, Uuid, Uuid, Uuid) {
439 let doomed_file = insert_file(tx, "doomed.png").await;
440 let surviving_file = insert_file(tx, "surviving.png").await;
441 let (doomed_slide_submission, doomed_submission) = insert_file_answer(
442 tx,
443 course_id,
444 user_id,
445 exercise_id,
446 slide_id,
447 task_id,
448 &[doomed_file],
449 )
450 .await;
451 let (_, surviving_submission) = insert_file_answer(
452 tx,
453 course_id,
454 user_id,
455 exercise_id,
456 slide_id,
457 task_id,
458 &[surviving_file],
459 )
460 .await;
461 (
462 doomed_slide_submission,
463 doomed_submission,
464 doomed_file,
465 surviving_submission,
466 )
467 }
468
469 async fn returned_submission_ids(tx: &mut PgConnection, exercise_id: Uuid) -> Vec<Uuid> {
470 get_answer_files_by_exercise_id(tx, exercise_id)
471 .await
472 .unwrap()
473 .into_iter()
474 .map(|file| file.exercise_task_submission_id)
475 .collect()
476 }
477
478 #[tokio::test]
479 async fn omits_a_deleted_task_submission() {
480 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);
481 let (_, doomed, _, surviving) = insert_two_file_answers(
482 tx.as_mut(),
483 course_id,
484 user_id,
485 exercise_id,
486 slide_id,
487 task_id,
488 )
489 .await;
490 crate::exercise_task_submissions::delete_by_id(tx.as_mut(), doomed)
491 .await
492 .unwrap();
493
494 assert_eq!(
495 returned_submission_ids(tx.as_mut(), exercise_id).await,
496 vec![surviving]
497 );
498 tx.rollback().await;
499 }
500
501 #[tokio::test]
502 async fn omits_a_deleted_slide_submission() {
503 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);
504 let (doomed_slide_submission, doomed, _, surviving) = insert_two_file_answers(
505 tx.as_mut(),
506 course_id,
507 user_id,
508 exercise_id,
509 slide_id,
510 task_id,
511 )
512 .await;
513 crate::exercise_slide_submissions::delete_by_id(tx.as_mut(), doomed_slide_submission)
514 .await
515 .unwrap();
516
517 let returned = returned_submission_ids(tx.as_mut(), exercise_id).await;
518 assert_eq!(returned, vec![surviving]);
519 assert!(!returned.contains(&doomed));
520 tx.rollback().await;
521 }
522
523 async fn soft_delete_link_row(
524 tx: &mut PgConnection,
525 submission_id: Uuid,
526 file_upload_id: Uuid,
527 ) {
528 crate::exercise_task_submission_files::delete_by_task_submission_and_file_upload_ids(
529 tx,
530 submission_id,
531 file_upload_id,
532 )
533 .await
534 .unwrap();
535 }
536
537 #[tokio::test]
538 async fn omits_a_deleted_link_row() {
539 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);
540 let (_, doomed, doomed_file, surviving) = insert_two_file_answers(
541 tx.as_mut(),
542 course_id,
543 user_id,
544 exercise_id,
545 slide_id,
546 task_id,
547 )
548 .await;
549 soft_delete_link_row(tx.as_mut(), doomed, doomed_file).await;
550
551 assert_eq!(
552 returned_submission_ids(tx.as_mut(), exercise_id).await,
553 vec![surviving]
554 );
555 tx.rollback().await;
556 }
557
558 #[tokio::test]
559 async fn omits_a_reaped_upload() {
560 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);
561 let (_, _, doomed_file, surviving) = insert_two_file_answers(
562 tx.as_mut(),
563 course_id,
564 user_id,
565 exercise_id,
566 slide_id,
567 task_id,
568 )
569 .await;
570 crate::file_uploads::delete_and_fetch_path(tx.as_mut(), doomed_file)
571 .await
572 .unwrap();
573
574 assert_eq!(
575 returned_submission_ids(tx.as_mut(), exercise_id).await,
576 vec![surviving]
577 );
578 tx.rollback().await;
579 }
580
581 #[tokio::test]
582 async fn round_trips_files_in_the_order_the_client_sent_them() {
583 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);
584 let submission_id = insert_task_submission(
585 tx.as_mut(),
586 course_id,
587 user_id,
588 exercise_id,
589 slide_id,
590 task_id,
591 )
592 .await;
593
594 let first = insert_file(tx.as_mut(), "a.tar.zst").await;
595 let second = insert_file(tx.as_mut(), "b.tar.zst").await;
596 insert_many(tx.as_mut(), submission_id, &[first, second])
597 .await
598 .unwrap();
599
600 let files = get_by_task_submission_ids(tx.as_mut(), &[submission_id])
601 .await
602 .unwrap();
603 assert_eq!(
604 files
605 .iter()
606 .map(|f| (f.file_upload_id, f.order_number, f.name.as_str()))
607 .collect::<Vec<_>>(),
608 vec![(first, 0, "a.tar.zst"), (second, 1, "b.tar.zst")]
609 );
610 assert_eq!(files[0].path, "tmc/a.tar.zst");
611 tx.rollback().await;
612 }
613
614 #[tokio::test]
617 async fn never_returns_another_submissions_files() {
618 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);
619 let mine = insert_task_submission(
620 tx.as_mut(),
621 course_id,
622 user_id,
623 exercise_id,
624 slide_id,
625 task_id,
626 )
627 .await;
628 let theirs = insert_task_submission(
629 tx.as_mut(),
630 course_id,
631 user_id,
632 exercise_id,
633 slide_id,
634 task_id,
635 )
636 .await;
637
638 let my_file = insert_file(tx.as_mut(), "mine.tar.zst").await;
639 let their_file = insert_file(tx.as_mut(), "theirs.tar.zst").await;
640 let unrelated = insert_file(tx.as_mut(), "unrelated.png").await;
642 insert_many(tx.as_mut(), mine, &[my_file]).await.unwrap();
643 insert_many(tx.as_mut(), theirs, &[their_file])
644 .await
645 .unwrap();
646
647 let files = get_by_task_submission_ids(tx.as_mut(), &[mine])
648 .await
649 .unwrap();
650 assert_eq!(
651 files.iter().map(|f| f.file_upload_id).collect::<Vec<_>>(),
652 vec![my_file]
653 );
654 assert!(!files.iter().any(|f| f.file_upload_id == their_file));
655 assert!(!files.iter().any(|f| f.file_upload_id == unrelated));
656 tx.rollback().await;
657 }
658
659 #[tokio::test]
660 async fn omits_a_deleted_file_upload() {
661 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);
662 let submission_id = insert_task_submission(
663 tx.as_mut(),
664 course_id,
665 user_id,
666 exercise_id,
667 slide_id,
668 task_id,
669 )
670 .await;
671 let file_id = insert_file(tx.as_mut(), "reaped.tar.zst").await;
672 insert_many(tx.as_mut(), submission_id, &[file_id])
673 .await
674 .unwrap();
675
676 crate::file_uploads::delete_and_fetch_path(tx.as_mut(), file_id)
677 .await
678 .unwrap();
679 assert!(
680 get_by_task_submission_ids(tx.as_mut(), &[submission_id])
681 .await
682 .unwrap()
683 .is_empty()
684 );
685 tx.rollback().await;
686 }
687
688 #[tokio::test]
691 async fn an_empty_file_list_records_nothing() {
692 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);
693 let submission_id = insert_task_submission(
694 tx.as_mut(),
695 course_id,
696 user_id,
697 exercise_id,
698 slide_id,
699 task_id,
700 )
701 .await;
702 insert_many(tx.as_mut(), submission_id, &[]).await.unwrap();
703 assert!(
704 get_by_task_submission_ids(tx.as_mut(), &[submission_id])
705 .await
706 .unwrap()
707 .is_empty()
708 );
709 assert!(
710 get_by_task_submission_ids(tx.as_mut(), &[])
711 .await
712 .unwrap()
713 .is_empty()
714 );
715 tx.rollback().await;
716 }
717 #[tokio::test]
718 async fn reports_answer_files_only_where_a_file_answer_exists() {
719 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);
720 assert!(
721 !exercise_has_answer_files(tx.as_mut(), exercise_id)
722 .await
723 .unwrap()
724 );
725
726 let json_submission = insert_task_submission(
727 tx.as_mut(),
728 course_id,
729 user_id,
730 exercise_id,
731 slide_id,
732 task_id,
733 )
734 .await;
735 let file_id = insert_file(tx.as_mut(), "smuggled.png").await;
736 insert_many(tx.as_mut(), json_submission, &[file_id])
737 .await
738 .unwrap();
739 assert!(
740 !exercise_has_answer_files(tx.as_mut(), exercise_id)
741 .await
742 .unwrap()
743 );
744
745 let answer_file = insert_file(tx.as_mut(), "answer.png").await;
746 insert_file_answer(
747 tx.as_mut(),
748 course_id,
749 user_id,
750 exercise_id,
751 slide_id,
752 task_id,
753 &[answer_file],
754 )
755 .await;
756 assert!(
757 exercise_has_answer_files(tx.as_mut(), exercise_id)
758 .await
759 .unwrap()
760 );
761 tx.rollback().await;
762 }
763}