1use std::collections::{HashMap, HashSet};
4
5use futures::future;
6use serde_json::Value;
7use url::Url;
8
9use headless_lms_models::exercises::Exercise;
10use models::{
11 exercise_service_info::ExerciseServiceInfoApi, exercise_services::ExerciseService,
12 exercise_slide_submissions::ExerciseSlideSubmission,
13 exercise_task_gradings::ExerciseTaskGrading, exercise_task_submissions::AnswerData,
14 exercise_tasks::ExerciseTask, library::grading::AnswersRequiringAttention,
15};
16use utoipa::{OpenApi, ToSchema};
17
18use crate::{
19 domain::{answer_files_archive, models_requests},
20 prelude::*,
21};
22
23const EXERCISE_SERVICE_CSV_EXPORT_BATCH_SIZE: usize = 1000;
24
25#[derive(OpenApi)]
26#[openapi(paths(
27 get_exercise,
28 get_exercise_submissions,
29 get_exercise_submissions_for_user,
30 get_exercise_csv_export_task_options,
31 export_exercise_task_definitions_csv,
32 export_exercise_task_answers_csv,
33 download_exercise_answer_files,
34 exercise_has_answer_files,
35 get_exercise_answers_requiring_attention,
36 get_exercises_by_course_id,
37 reset_exercises_for_selected_users
38))]
39pub(crate) struct MainFrontendExercisesApiDoc;
40
41#[derive(Debug, Serialize, ToSchema)]
42
43pub struct ExerciseSubmissions {
44 pub data: Vec<ExerciseSlideSubmission>,
45 pub total_pages: u32,
46}
47
48#[derive(Debug, Serialize, ToSchema)]
49
50pub struct ExerciseCsvExportTaskOption {
51 pub exercise_task_id: Uuid,
52 pub exercise_type: String,
53 pub order_number: i32,
54 pub supports_csv_export_definitions: bool,
55 pub supports_csv_export_answers: bool,
56 pub produces_file_answers: bool,
57}
58
59#[derive(Debug, Deserialize)]
60pub struct ExerciseCsvExportQuery {
61 pub exercise_task_id: Uuid,
62 #[serde(default)]
63 pub only_latest_per_user: bool,
64}
65
66#[derive(Debug, Clone)]
67struct CsvColumnDefinition {
68 key: String,
69 header: String,
70}
71
72impl CsvColumnDefinition {
73 fn new(key: impl Into<String>, header: impl Into<String>) -> Self {
74 Self {
75 key: key.into(),
76 header: header.into(),
77 }
78 }
79}
80
81#[derive(Debug, Serialize)]
82struct ExerciseDefinitionsCsvExportRequestItem<'a> {
83 private_spec: &'a Option<Value>,
84}
85
86#[derive(Debug, Serialize)]
87struct ExerciseAnswersCsvExportRequestItem<'a> {
88 private_spec: &'a Option<Value>,
89 answer: Option<&'a Value>,
90 grading: Option<&'a ExerciseTaskGrading>,
91 model_solution_spec: &'a Option<Value>,
92}
93
94fn csv_endpoint_is_supported(path: &Option<String>) -> bool {
96 path.as_ref().is_some_and(|value| !value.trim().is_empty())
97}
98
99fn get_csv_export_endpoint_path(
101 path: &Option<String>,
102 endpoint_name: &str,
103) -> Result<String, ControllerError> {
104 let endpoint_path = path
105 .as_ref()
106 .map(|value| value.trim())
107 .filter(|value| !value.is_empty())
108 .ok_or_else(|| {
109 ControllerError::new(
110 ControllerErrorType::BadRequest,
111 format!(
112 "Exercise service does not support {} CSV export.",
113 endpoint_name
114 ),
115 None,
116 )
117 })?;
118 Ok(endpoint_path.to_string())
119}
120
121async fn fetch_exercise_service_and_info(
123 conn: &mut PgConnection,
124 exercise_type: &str,
125) -> models::ModelResult<(ExerciseService, ExerciseServiceInfoApi)> {
126 let exercise_service =
127 models::exercise_services::get_exercise_service_by_exercise_type(conn, exercise_type)
128 .await?;
129 let internal_url = exercise_service.internal_url.clone();
130 let public_url = exercise_service.public_url.clone();
131 let slug = exercise_service.slug.clone();
132 let service_info_url = match internal_url.as_ref() {
133 Some(url_str) => match url_str.parse::<Url>() {
134 Ok(url) => url,
135 Err(error) => {
136 warn!(
137 exercise_service_slug = ?slug,
138 ?error,
139 "Internal URL for service info is invalid, falling back to public URL."
140 );
141 public_url.parse()?
142 }
143 },
144 None => public_url.parse()?,
145 };
146 let service_info = models_requests::fetch_service_info_fast(service_info_url).await?;
147 Ok((exercise_service, service_info))
148}
149
150fn build_service_endpoint_url(
152 exercise_service: &ExerciseService,
153 endpoint_path: &str,
154) -> Result<Url, ControllerError> {
155 let mut url = models::exercise_services::get_exercise_service_internally_preferred_baseurl(
156 exercise_service,
157 )?;
158 url.set_path(endpoint_path);
159 Ok(url)
160}
161
162fn get_selected_task(
164 tasks: &[ExerciseTask],
165 exercise_task_id: Uuid,
166) -> Result<ExerciseTask, ControllerError> {
167 tasks
168 .iter()
169 .find(|task| task.id == exercise_task_id)
170 .cloned()
171 .ok_or_else(|| {
172 ControllerError::new(
173 ControllerErrorType::BadRequest,
174 "Selected task does not belong to this exercise.".to_string(),
175 None,
176 )
177 })
178}
179
180fn build_final_columns(
182 base_columns: &[CsvColumnDefinition],
183 service_columns: &[models_requests::ExerciseServiceCsvExportColumn],
184) -> Result<(Vec<CsvColumnDefinition>, HashMap<String, String>), ControllerError> {
185 let mut final_columns = base_columns.to_vec();
186 let mut used_keys = base_columns
187 .iter()
188 .map(|column| column.key.clone())
189 .collect::<HashSet<_>>();
190 let mut service_key_to_final_key = HashMap::new();
191
192 for column in service_columns {
193 let key = column.key.trim();
194 if key.is_empty() {
195 return Err(ControllerError::new(
196 ControllerErrorType::BadRequest,
197 "Exercise service CSV export response contains an empty column key.".to_string(),
198 None,
199 ));
200 }
201 if service_key_to_final_key.contains_key(key) {
202 return Err(ControllerError::new(
203 ControllerErrorType::BadRequest,
204 format!(
205 "Exercise service CSV export response contains duplicate original column key '{}'.",
206 key
207 ),
208 None,
209 ));
210 }
211
212 let mut final_key = key.to_string();
213 if used_keys.contains(&final_key) {
214 final_key = format!("service_{}", final_key);
215 }
216 if used_keys.contains(&final_key) {
217 return Err(ControllerError::new(
218 ControllerErrorType::BadRequest,
219 format!(
220 "Exercise service CSV export response contains duplicate column key '{}'.",
221 key
222 ),
223 None,
224 ));
225 }
226
227 used_keys.insert(final_key.clone());
228 service_key_to_final_key.insert(key.to_string(), final_key.clone());
229 final_columns.push(CsvColumnDefinition::new(final_key, column.header.clone()));
230 }
231
232 Ok((final_columns, service_key_to_final_key))
233}
234
235fn build_column_index_map(columns: &[CsvColumnDefinition]) -> HashMap<String, usize> {
237 columns
238 .iter()
239 .enumerate()
240 .map(|(index, column)| (column.key.clone(), index))
241 .collect()
242}
243
244fn scalar_json_to_csv_value(value: &Value) -> Result<String, ControllerError> {
246 match value {
247 Value::Null => Ok(String::new()),
248 Value::Bool(value) => Ok(value.to_string()),
249 Value::Number(value) => Ok(value.to_string()),
250 Value::String(value) => Ok(value.clone()),
251 Value::Array(_) | Value::Object(_) => Err(ControllerError::new(
252 ControllerErrorType::BadRequest,
253 "Exercise service CSV export response contains a non-scalar cell value.".to_string(),
254 None,
255 )),
256 }
257}
258
259fn write_csv_rows(
261 writer: &mut csv::Writer<Vec<u8>>,
262 final_columns: &[CsvColumnDefinition],
263 column_index_map: &HashMap<String, usize>,
264 service_key_to_final_key: &HashMap<String, String>,
265 base_row: &HashMap<String, String>,
266 rows: &[HashMap<String, Value>],
267) -> Result<(), ControllerError> {
268 let write_single_row = |service_row: Option<&HashMap<String, Value>>,
269 writer: &mut csv::Writer<Vec<u8>>|
270 -> Result<(), ControllerError> {
271 let mut record = vec![String::new(); final_columns.len()];
272
273 for (base_key, base_value) in base_row {
274 let index = column_index_map.get(base_key).ok_or_else(|| {
275 ControllerError::new(
276 ControllerErrorType::InternalServerError,
277 format!(
278 "Base CSV column '{}' is missing from the final header.",
279 base_key
280 ),
281 None,
282 )
283 })?;
284 record[*index] = base_value.clone();
285 }
286
287 if let Some(row) = service_row {
288 for (service_key, value) in row {
289 let final_key = service_key_to_final_key.get(service_key).ok_or_else(|| {
290 ControllerError::new(
291 ControllerErrorType::BadRequest,
292 format!(
293 "Exercise service CSV export response contains an unknown column key '{}'.",
294 service_key
295 ),
296 None,
297 )
298 })?;
299 let index = column_index_map.get(final_key).ok_or_else(|| {
300 ControllerError::new(
301 ControllerErrorType::InternalServerError,
302 format!(
303 "CSV column '{}' is missing from the final header.",
304 final_key
305 ),
306 None,
307 )
308 })?;
309 record[*index] = scalar_json_to_csv_value(value)?;
310 }
311 }
312
313 writer.write_record(record).map_err(|error| {
314 ControllerError::new(
315 ControllerErrorType::InternalServerError,
316 format!("Failed to write CSV row: {}", error),
317 Some(error.into()),
318 )
319 })?;
320 Ok(())
321 };
322
323 if rows.is_empty() {
324 write_single_row(None, writer)?;
325 } else {
326 for row in rows {
327 write_single_row(Some(row), writer)?;
328 }
329 }
330
331 Ok(())
332}
333
334fn csv_writer_into_bytes(writer: csv::Writer<Vec<u8>>) -> Result<Vec<u8>, ControllerError> {
336 writer.into_inner().map_err(|error| {
337 let csv_error = error.into_error();
338 ControllerError::new(
339 ControllerErrorType::InternalServerError,
340 format!("Failed to finalize CSV export: {}", csv_error),
341 Some(csv_error.into()),
342 )
343 })
344}
345
346#[instrument(skip(pool))]
350#[utoipa::path(
351 get,
352 path = "/{exercise_id}",
353 operation_id = "getExercise",
354 tag = "exercises",
355 params(
356 ("exercise_id" = Uuid, Path, description = "Exercise id")
357 ),
358 responses(
359 (status = 200, description = "Exercise", body = Exercise)
360 )
361)]
362async fn get_exercise(
363 pool: web::Data<PgPool>,
364 exercise_id: web::Path<Uuid>,
365 user: AuthUser,
366) -> ControllerResult<web::Json<Exercise>> {
367 let mut conn = pool.acquire().await?;
368
369 let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
370
371 let token = if let Some(course_id) = exercise.course_id {
372 authorize(&mut conn, Act::View, Some(user.id), Res::Course(course_id)).await?
373 } else if let Some(exam_id) = exercise.exam_id {
374 authorize(&mut conn, Act::View, Some(user.id), Res::Exam(exam_id)).await?
375 } else {
376 return Err(ControllerError::new(
377 ControllerErrorType::BadRequest,
378 "Exercise is not associated with a course or exam".to_string(),
379 None,
380 ));
381 };
382
383 token.authorized_ok(web::Json(exercise))
384}
385
386#[instrument(skip(pool))]
390#[utoipa::path(
391 get,
392 path = "/{exercise_id}/submissions",
393 operation_id = "getExerciseSubmissions",
394 tag = "exercises",
395 params(
396 ("exercise_id" = Uuid, Path, description = "Exercise id"),
397 ("page" = Option<i64>, Query, description = "Page number"),
398 ("limit" = Option<i64>, Query, description = "Page size")
399 ),
400 responses(
401 (status = 200, description = "Exercise submissions", body = ExerciseSubmissions)
402 )
403)]
404async fn get_exercise_submissions(
405 pool: web::Data<PgPool>,
406 exercise_id: web::Path<Uuid>,
407 pagination: web::Query<Pagination>,
408 user: AuthUser,
409) -> ControllerResult<web::Json<ExerciseSubmissions>> {
410 let mut conn = pool.acquire().await?;
411
412 let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
413 CourseOrExamId::Course(id) => {
414 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
415 }
416 CourseOrExamId::Exam(id) => {
417 authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
418 }
419 };
420
421 let submission_count = models::exercise_slide_submissions::exercise_slide_submission_count(
422 &mut conn,
423 *exercise_id,
424 );
425 let mut conn = pool.acquire().await?;
426 let submissions = models::exercise_slide_submissions::exercise_slide_submissions(
427 &mut conn,
428 *exercise_id,
429 *pagination,
430 );
431 let (submission_count, submissions) = future::try_join(submission_count, submissions).await?;
432
433 let total_pages = pagination.total_pages(submission_count);
434
435 token.authorized_ok(web::Json(ExerciseSubmissions {
436 data: submissions,
437 total_pages,
438 }))
439}
440
441#[instrument(skip(pool, user))]
445#[utoipa::path(
446 get,
447 path = "/{exercise_id}/submissions/user/{user_id}",
448 operation_id = "getExerciseSubmissionsForUser",
449 tag = "exercises",
450 params(
451 ("exercise_id" = Uuid, Path, description = "Exercise id"),
452 ("user_id" = Uuid, Path, description = "User id")
453 ),
454 responses(
455 (status = 200, description = "Exercise submissions for user", body = [ExerciseSlideSubmission])
456 )
457)]
458async fn get_exercise_submissions_for_user(
459 pool: web::Data<PgPool>,
460 ids: web::Path<(Uuid, Uuid)>,
461 user: AuthUser,
462) -> ControllerResult<web::Json<Vec<ExerciseSlideSubmission>>> {
463 let (exercise_id, user_id) = ids.into_inner();
464 let mut conn = pool.acquire().await?;
465
466 let target_user = models::users::get_by_id(&mut conn, user_id).await?;
467
468 let course_or_exam_id =
469 models::exercises::get_course_or_exam_id(&mut conn, exercise_id).await?;
470
471 let token = match course_or_exam_id {
472 CourseOrExamId::Course(id) => {
473 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
474 }
475 CourseOrExamId::Exam(id) => {
476 authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
477 }
478 };
479
480 let submissions = models::exercise_slide_submissions::get_users_submissions_for_exercise(
481 &mut conn,
482 target_user.id,
483 exercise_id,
484 )
485 .await?;
486
487 token.authorized_ok(web::Json(submissions))
488}
489
490#[instrument(skip(pool))]
494#[utoipa::path(
495 get,
496 path = "/{exercise_id}/csv-export-task-options",
497 operation_id = "getExerciseCsvExportTaskOptions",
498 tag = "exercises",
499 params(
500 ("exercise_id" = Uuid, Path, description = "Exercise id")
501 ),
502 responses(
503 (status = 200, description = "Exercise CSV export task options", body = [ExerciseCsvExportTaskOption])
504 )
505)]
506async fn get_exercise_csv_export_task_options(
507 pool: web::Data<PgPool>,
508 exercise_id: web::Path<Uuid>,
509 user: AuthUser,
510) -> ControllerResult<web::Json<Vec<ExerciseCsvExportTaskOption>>> {
511 let mut conn = pool.acquire().await?;
512 let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
513 CourseOrExamId::Course(id) => {
514 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
515 }
516 CourseOrExamId::Exam(id) => {
517 authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
518 }
519 };
520
521 let mut tasks =
522 models::exercise_tasks::get_exercise_tasks_by_exercise_id(&mut conn, *exercise_id).await?;
523 tasks.sort_by_key(|task| (task.order_number, task.id));
524
525 let unique_exercise_types = tasks
526 .iter()
527 .map(|task| task.exercise_type.clone())
528 .collect::<HashSet<_>>();
529
530 let mut exercise_type_support = HashMap::new();
531 for exercise_type in unique_exercise_types {
532 let support = match fetch_exercise_service_and_info(&mut conn, &exercise_type).await {
533 Ok((_service, service_info)) => (
534 csv_endpoint_is_supported(&service_info.csv_export_definitions_endpoint_path),
535 csv_endpoint_is_supported(&service_info.csv_export_answers_endpoint_path),
536 service_info.produces_file_answers,
537 ),
538 Err(error) => {
539 warn!(
540 exercise_type = ?exercise_type,
541 ?error,
542 "Could not fetch exercise service info for exercise task capability detection."
543 );
544 (false, false, false)
545 }
546 };
547 exercise_type_support.insert(exercise_type, support);
548 }
549
550 let options = tasks
551 .into_iter()
552 .map(|task| {
553 let (
554 supports_csv_export_definitions,
555 supports_csv_export_answers,
556 produces_file_answers,
557 ) = exercise_type_support
558 .get(&task.exercise_type)
559 .copied()
560 .unwrap_or((false, false, false));
561 ExerciseCsvExportTaskOption {
562 exercise_task_id: task.id,
563 exercise_type: task.exercise_type,
564 order_number: task.order_number,
565 supports_csv_export_definitions,
566 supports_csv_export_answers,
567 produces_file_answers,
568 }
569 })
570 .collect::<Vec<_>>();
571
572 token.authorized_ok(web::Json(options))
573}
574
575#[instrument(skip(pool))]
579#[utoipa::path(
580 get,
581 path = "/{exercise_id}/export-definitions-csv",
582 operation_id = "exportExerciseDefinitionsCsv",
583 tag = "exercises",
584 params(
585 ("exercise_id" = Uuid, Path, description = "Exercise id"),
586 ("exercise_task_id" = Uuid, Query, description = "Exercise task id"),
587 ("only_latest_per_user" = Option<bool>, Query, description = "Only include latest submission per user")
588 ),
589 responses(
590 (status = 200, description = "Exercise definitions CSV", body = String, content_type = "text/csv")
591 )
592)]
593async fn export_exercise_task_definitions_csv(
594 pool: web::Data<PgPool>,
595 exercise_id: web::Path<Uuid>,
596 query: web::Query<ExerciseCsvExportQuery>,
597 user: AuthUser,
598) -> ControllerResult<HttpResponse> {
599 let mut conn = pool.acquire().await?;
600 let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
601 CourseOrExamId::Course(id) => {
602 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
603 }
604 CourseOrExamId::Exam(id) => {
605 authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
606 }
607 };
608
609 let tasks =
610 models::exercise_tasks::get_exercise_tasks_by_exercise_id(&mut conn, *exercise_id).await?;
611 let selected_task = get_selected_task(&tasks, query.exercise_task_id)?;
612
613 let (exercise_service, service_info) =
614 fetch_exercise_service_and_info(&mut conn, &selected_task.exercise_type).await?;
615 let endpoint_path = get_csv_export_endpoint_path(
616 &service_info.csv_export_definitions_endpoint_path,
617 "definitions",
618 )?;
619 let endpoint_url = build_service_endpoint_url(&exercise_service, &endpoint_path)?;
620
621 let request_items = vec![ExerciseDefinitionsCsvExportRequestItem {
622 private_spec: &selected_task.private_spec,
623 }];
624 let response =
625 models_requests::post_exercise_service_csv_export_request(endpoint_url, &request_items)
626 .await?;
627 if response.results.len() != request_items.len() {
628 return Err(ControllerError::new(
629 ControllerErrorType::BadRequest,
630 format!(
631 "Exercise service returned {} results for {} definition items.",
632 response.results.len(),
633 request_items.len()
634 ),
635 None,
636 ));
637 }
638
639 let base_columns = vec![CsvColumnDefinition::new(
640 "exercise_task_id",
641 "Exercise task id",
642 )];
643 let (final_columns, service_key_to_final_key) =
644 build_final_columns(&base_columns, &response.columns)?;
645 let column_index_map = build_column_index_map(&final_columns);
646
647 let mut writer = csv::Writer::from_writer(Vec::new());
648 writer
649 .write_record(final_columns.iter().map(|column| column.header.as_str()))
650 .map_err(|error| {
651 ControllerError::new(
652 ControllerErrorType::InternalServerError,
653 format!("Failed to write CSV headers: {}", error),
654 Some(error.into()),
655 )
656 })?;
657
658 let mut base_row = HashMap::new();
659 base_row.insert("exercise_task_id".to_string(), selected_task.id.to_string());
660 write_csv_rows(
661 &mut writer,
662 &final_columns,
663 &column_index_map,
664 &service_key_to_final_key,
665 &base_row,
666 &response.results[0].rows,
667 )?;
668
669 let csv_bytes = csv_writer_into_bytes(writer)?;
670 let content_disposition = format!(
671 "attachment; filename=\"exercise-{}-definitions-{}.csv\"",
672 *exercise_id, selected_task.id
673 );
674
675 token.authorized_ok(
676 HttpResponse::Ok()
677 .append_header(("Content-Disposition", content_disposition))
678 .append_header(("Content-Type", "text/csv; charset=utf-8"))
679 .body(csv_bytes),
680 )
681}
682
683#[instrument(skip(pool, file_store, app_conf))]
687#[utoipa::path(
688 get,
689 path = "/{exercise_id}/export-answers-csv",
690 operation_id = "exportExerciseAnswersCsv",
691 tag = "exercises",
692 params(
693 ("exercise_id" = Uuid, Path, description = "Exercise id"),
694 ("exercise_task_id" = Uuid, Query, description = "Exercise task id"),
695 ("only_latest_per_user" = Option<bool>, Query, description = "Only include latest submission per user")
696 ),
697 responses(
698 (status = 200, description = "Exercise answers CSV", body = String, content_type = "text/csv")
699 )
700)]
701async fn export_exercise_task_answers_csv(
702 pool: web::Data<PgPool>,
703 exercise_id: web::Path<Uuid>,
704 query: web::Query<ExerciseCsvExportQuery>,
705 user: AuthUser,
706 file_store: web::Data<dyn FileStore>,
707 app_conf: web::Data<ApplicationConfiguration>,
708) -> ControllerResult<HttpResponse> {
709 let mut conn = pool.acquire().await?;
710 let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
711 CourseOrExamId::Course(id) => {
712 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
713 }
714 CourseOrExamId::Exam(id) => {
715 authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
716 }
717 };
718
719 let tasks =
720 models::exercise_tasks::get_exercise_tasks_by_exercise_id(&mut conn, *exercise_id).await?;
721 let selected_task = get_selected_task(&tasks, query.exercise_task_id)?;
722
723 let (exercise_service, service_info) =
724 fetch_exercise_service_and_info(&mut conn, &selected_task.exercise_type).await?;
725 let endpoint_path =
726 get_csv_export_endpoint_path(&service_info.csv_export_answers_endpoint_path, "answers")?;
727 let endpoint_url = build_service_endpoint_url(&exercise_service, &endpoint_path)?;
728
729 let export_data = if query.only_latest_per_user {
730 models::exercise_task_submissions::get_csv_export_data_by_exercise_and_task_latest_per_user(
731 &mut conn,
732 *exercise_id,
733 selected_task.id,
734 file_store.as_ref(),
735 app_conf.as_ref(),
736 )
737 .await?
738 } else {
739 models::exercise_task_submissions::get_csv_export_data_by_exercise_and_task(
740 &mut conn,
741 *exercise_id,
742 selected_task.id,
743 file_store.as_ref(),
744 app_conf.as_ref(),
745 )
746 .await?
747 };
748 let submission_ids = export_data
749 .iter()
750 .map(|submission| submission.exercise_task_submission_id)
751 .collect::<Vec<_>>();
752 let gradings_by_submission_id =
753 models::exercise_task_gradings::get_by_exercise_task_submission_ids(
754 &mut conn,
755 &submission_ids,
756 )
757 .await?;
758
759 let base_columns = vec![
760 CsvColumnDefinition::new(
761 "exercise_slide_submission_id",
762 "Exercise slide submission id",
763 ),
764 CsvColumnDefinition::new("exercise_task_submission_id", "Exercise task submission id"),
765 CsvColumnDefinition::new("exercise_task_id", "Exercise task id"),
766 CsvColumnDefinition::new("exercise_id", "Exercise id"),
767 CsvColumnDefinition::new("user_id", "User id"),
768 CsvColumnDefinition::new("submitted_at", "Submitted at"),
769 ];
770
771 let mut writer = csv::Writer::from_writer(Vec::new());
772 let mut expected_service_columns: Option<Vec<models_requests::ExerciseServiceCsvExportColumn>> =
773 None;
774 let mut final_columns: Option<Vec<CsvColumnDefinition>> = None;
775 let mut column_index_map = HashMap::new();
776 let mut service_key_to_final_key = HashMap::new();
777
778 for export_chunk in export_data.chunks(EXERCISE_SERVICE_CSV_EXPORT_BATCH_SIZE) {
779 let mut request_items = Vec::with_capacity(export_chunk.len());
780 let mut base_rows = Vec::with_capacity(export_chunk.len());
781 for submission in export_chunk {
782 let grading = gradings_by_submission_id.get(&submission.exercise_task_submission_id);
783 request_items.push(ExerciseAnswersCsvExportRequestItem {
784 private_spec: &selected_task.private_spec,
785 answer: submission.answer.as_ref().and_then(AnswerData::plugin_json),
786 grading,
787 model_solution_spec: &selected_task.model_solution_spec,
788 });
789 let mut base_row = HashMap::new();
790 base_row.insert(
791 "exercise_slide_submission_id".to_string(),
792 submission.exercise_slide_submission_id.to_string(),
793 );
794 base_row.insert(
795 "exercise_task_submission_id".to_string(),
796 submission.exercise_task_submission_id.to_string(),
797 );
798 base_row.insert(
799 "exercise_task_id".to_string(),
800 submission.exercise_task_id.to_string(),
801 );
802 base_row.insert(
803 "exercise_id".to_string(),
804 submission.exercise_id.to_string(),
805 );
806 base_row.insert("user_id".to_string(), submission.user_id.to_string());
807 base_row.insert(
808 "submitted_at".to_string(),
809 submission.submitted_at.to_rfc3339(),
810 );
811 base_rows.push(base_row);
812 }
813
814 let response = models_requests::post_exercise_service_csv_export_request(
815 endpoint_url.clone(),
816 &request_items,
817 )
818 .await?;
819
820 if response.results.len() != request_items.len() {
821 return Err(ControllerError::new(
822 ControllerErrorType::BadRequest,
823 format!(
824 "Exercise service returned {} results for {} answer items.",
825 response.results.len(),
826 request_items.len()
827 ),
828 None,
829 ));
830 }
831
832 if let Some(expected_columns) = &expected_service_columns {
833 if expected_columns != &response.columns {
834 return Err(ControllerError::new(
835 ControllerErrorType::BadRequest,
836 "Exercise service returned different CSV columns for different answer batches."
837 .to_string(),
838 None,
839 ));
840 }
841 } else {
842 expected_service_columns = Some(response.columns.clone());
843 }
844
845 if final_columns.is_none() {
846 let (columns, service_key_mapping) =
847 build_final_columns(&base_columns, &response.columns)?;
848 column_index_map = build_column_index_map(&columns);
849 service_key_to_final_key = service_key_mapping;
850 writer
851 .write_record(columns.iter().map(|column| column.header.as_str()))
852 .map_err(|error| {
853 ControllerError::new(
854 ControllerErrorType::InternalServerError,
855 format!("Failed to write CSV headers: {}", error),
856 Some(error.into()),
857 )
858 })?;
859 final_columns = Some(columns);
860 }
861
862 let final_columns_ref = final_columns.as_ref().ok_or_else(|| {
863 ControllerError::new(
864 ControllerErrorType::InternalServerError,
865 "CSV columns were not initialized.".to_string(),
866 None,
867 )
868 })?;
869
870 for (chunk_row_index, result) in response.results.iter().enumerate() {
871 let base_row = base_rows.get(chunk_row_index).ok_or_else(|| {
872 ControllerError::new(
873 ControllerErrorType::InternalServerError,
874 "Could not map CSV export response rows back to request items.".to_string(),
875 None,
876 )
877 })?;
878 write_csv_rows(
879 &mut writer,
880 final_columns_ref,
881 &column_index_map,
882 &service_key_to_final_key,
883 base_row,
884 &result.rows,
885 )?;
886 }
887 }
888
889 if final_columns.is_none() {
890 let columns = base_columns;
891 writer
892 .write_record(columns.iter().map(|column| column.header.as_str()))
893 .map_err(|error| {
894 ControllerError::new(
895 ControllerErrorType::InternalServerError,
896 format!("Failed to write CSV headers: {}", error),
897 Some(error.into()),
898 )
899 })?;
900 }
901
902 let csv_bytes = csv_writer_into_bytes(writer)?;
903 let content_disposition = format!(
904 "attachment; filename=\"exercise-{}-answers-{}.csv\"",
905 *exercise_id, selected_task.id
906 );
907
908 token.authorized_ok(
909 HttpResponse::Ok()
910 .append_header(("Content-Disposition", content_disposition))
911 .append_header(("Content-Type", "text/csv; charset=utf-8"))
912 .body(csv_bytes),
913 )
914}
915
916#[instrument(skip(pool, file_store))]
920#[utoipa::path(
921 get,
922 path = "/{exercise_id}/download-answer-files",
923 operation_id = "downloadExerciseAnswerFiles",
924 tag = "exercises",
925 params(("exercise_id" = Uuid, Path, description = "Exercise id")),
926 responses(
927 (status = 200, description = "Zip archive of the exercise's answer files", body = String, content_type = "application/zip")
928 )
929)]
930async fn download_exercise_answer_files(
931 pool: web::Data<PgPool>,
932 exercise_id: web::Path<Uuid>,
933 user: AuthUser,
934 file_store: web::Data<dyn FileStore>,
935) -> ControllerResult<HttpResponse> {
936 let mut conn = pool.acquire().await?;
937 let course_or_exam_id =
938 models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await?;
939 let token = match course_or_exam_id {
940 CourseOrExamId::Course(id) => {
941 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
942 }
943 CourseOrExamId::Exam(id) => {
944 authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
945 }
946 };
947
948 let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
949 let container_name = match course_or_exam_id {
950 CourseOrExamId::Course(id) => models::courses::get_course(&mut conn, id).await?.slug,
951 CourseOrExamId::Exam(id) => models::exams::get(&mut conn, id).await?.name,
952 };
953 let content_disposition =
954 answer_files_archive::content_disposition(&container_name, &exercise.name);
955 drop(conn);
956
957 answer_files_archive::stream_exercise_answer_files(
958 pool,
959 file_store,
960 *exercise_id,
961 content_disposition,
962 token,
963 )
964 .await
965}
966
967#[instrument(skip(pool))]
971#[utoipa::path(
972 get,
973 path = "/{exercise_id}/has-answer-files",
974 operation_id = "exerciseHasAnswerFiles",
975 tag = "exercises",
976 params(("exercise_id" = Uuid, Path, description = "Exercise id")),
977 responses(
978 (status = 200, description = "Whether the exercise has answer files", body = bool)
979 )
980)]
981async fn exercise_has_answer_files(
982 pool: web::Data<PgPool>,
983 exercise_id: web::Path<Uuid>,
984 user: AuthUser,
985) -> ControllerResult<web::Json<bool>> {
986 let mut conn = pool.acquire().await?;
987 let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
988 CourseOrExamId::Course(id) => {
989 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
990 }
991 CourseOrExamId::Exam(id) => {
992 authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
993 }
994 };
995
996 let has_answer_files =
997 models::exercise_task_submission_files::exercise_has_answer_files(&mut conn, *exercise_id)
998 .await?;
999
1000 token.authorized_ok(web::Json(has_answer_files))
1001}
1002
1003#[instrument(skip(pool, file_store, app_conf))]
1007#[utoipa::path(
1008 get,
1009 path = "/{exercise_id}/answers-requiring-attention",
1010 operation_id = "getExerciseAnswersRequiringAttention",
1011 tag = "exercises",
1012 params(
1013 ("exercise_id" = Uuid, Path, description = "Exercise id"),
1014 ("page" = Option<i64>, Query, description = "Page number"),
1015 ("limit" = Option<i64>, Query, description = "Page size")
1016 ),
1017 responses(
1018 (status = 200, description = "Answers requiring attention", body = AnswersRequiringAttention)
1019 )
1020)]
1021async fn get_exercise_answers_requiring_attention(
1022 pool: web::Data<PgPool>,
1023 exercise_id: web::Path<Uuid>,
1024 pagination: web::Query<Pagination>,
1025 user: AuthUser,
1026 file_store: web::Data<dyn FileStore>,
1027 app_conf: web::Data<ApplicationConfiguration>,
1028) -> ControllerResult<web::Json<AnswersRequiringAttention>> {
1029 let mut conn = pool.acquire().await?;
1030 let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
1031 CourseOrExamId::Course(id) => {
1032 authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
1033 }
1034 CourseOrExamId::Exam(id) => {
1035 authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
1036 }
1037 };
1038 let res = models::library::grading::get_paginated_answers_requiring_attention_for_exercise(
1039 &mut conn,
1040 *exercise_id,
1041 *pagination,
1042 user.id,
1043 models_requests::fetch_service_info,
1044 file_store.as_ref(),
1045 app_conf.as_ref(),
1046 )
1047 .await?;
1048 token.authorized_ok(web::Json(res))
1049}
1050
1051#[utoipa::path(
1055 get,
1056 path = "/{course_id}/exercises-by-course-id",
1057 operation_id = "getExercisesByCourseId",
1058 tag = "exercises",
1059 params(
1060 ("course_id" = Uuid, Path, description = "Course id")
1061 ),
1062 responses(
1063 (status = 200, description = "Exercises by course id", body = [Exercise])
1064 )
1065)]
1066pub async fn get_exercises_by_course_id(
1067 course_id: web::Path<Uuid>,
1068 pool: web::Data<PgPool>,
1069 user: AuthUser,
1070) -> ControllerResult<web::Json<Vec<Exercise>>> {
1071 let mut conn = pool.acquire().await?;
1072
1073 let token = authorize(
1074 &mut conn,
1075 Act::ViewUserProgressOrDetails,
1076 Some(user.id),
1077 Res::Course(*course_id),
1078 )
1079 .await?;
1080
1081 let mut exercises =
1082 models::exercises::get_exercises_by_course_id(&mut conn, *course_id).await?;
1083
1084 exercises.sort_by_key(|e| (e.chapter_id, e.page_id, e.order_number));
1085
1086 token.authorized_ok(web::Json(exercises))
1087}
1088
1089#[derive(Deserialize, ToSchema)]
1090pub struct ResetExercisesPayload {
1091 pub user_ids: Vec<Uuid>,
1092 pub exercise_ids: Vec<Uuid>,
1093 pub threshold: Option<f64>,
1094 pub reset_all_below_max_points: bool,
1095 pub reset_only_locked_peer_reviews: bool,
1096}
1097
1098#[utoipa::path(
1102 post,
1103 path = "/{course_id}/reset-exercises-for-selected-users",
1104 operation_id = "resetExercisesForSelectedUsers",
1105 tag = "exercises",
1106 params(
1107 ("course_id" = Uuid, Path, description = "Course id")
1108 ),
1109 request_body = ResetExercisesPayload,
1110 responses(
1111 (status = 200, description = "Number of successful resets", body = i32)
1112 )
1113)]
1114pub async fn reset_exercises_for_selected_users(
1115 course_id: web::Path<Uuid>,
1116 pool: web::Data<PgPool>,
1117 user: AuthUser,
1118 payload: web::Json<ResetExercisesPayload>,
1119) -> ControllerResult<web::Json<i32>> {
1120 let mut conn = pool.acquire().await?;
1121
1122 let token = authorize(
1123 &mut conn,
1124 Act::Teach,
1125 Some(user.id),
1126 Res::Course(*course_id),
1127 )
1128 .await?;
1129
1130 let requested_exercises =
1131 models::exercises::get_non_deleted_by_ids(&mut conn, &payload.exercise_ids).await?;
1132 if requested_exercises.len() != payload.exercise_ids.len()
1133 || requested_exercises
1134 .iter()
1135 .any(|exercise| exercise.course_id != Some(*course_id))
1136 {
1137 return Err(controller_err!(
1138 Forbidden,
1139 "All exercises must belong to the requested course".to_string()
1140 ));
1141 }
1142
1143 let users_and_exercises = models::exercises::collect_user_ids_and_exercise_ids_for_reset(
1146 &mut conn,
1147 &payload.user_ids,
1148 &payload.exercise_ids,
1149 payload.threshold,
1150 payload.reset_all_below_max_points,
1151 payload.reset_only_locked_peer_reviews,
1152 )
1153 .await?;
1154
1155 let reset_results = models::exercises::reset_exercises_for_selected_users(
1157 &mut conn,
1158 &users_and_exercises,
1159 Some(user.id),
1160 *course_id,
1161 Some("reset-by-staff".to_string()),
1162 )
1163 .await?;
1164
1165 let successful_resets_count = reset_results.len();
1166
1167 token.authorized_ok(web::Json(successful_resets_count as i32))
1168}
1169
1170pub fn _add_routes(cfg: &mut ServiceConfig) {
1178 cfg.route(
1179 "/{exercise_id}/submissions",
1180 web::get().to(get_exercise_submissions),
1181 )
1182 .route(
1183 "/{exercise_id}/csv-export-task-options",
1184 web::get().to(get_exercise_csv_export_task_options),
1185 )
1186 .route(
1187 "/{exercise_id}/export-definitions-csv",
1188 web::get().to(export_exercise_task_definitions_csv),
1189 )
1190 .route(
1191 "/{exercise_id}/export-answers-csv",
1192 web::get().to(export_exercise_task_answers_csv),
1193 )
1194 .route(
1195 "/{exercise_id}/download-answer-files",
1196 web::get().to(download_exercise_answer_files),
1197 )
1198 .route(
1199 "/{exercise_id}/has-answer-files",
1200 web::get().to(exercise_has_answer_files),
1201 )
1202 .route(
1203 "/{exercise_id}/answers-requiring-attention",
1204 web::get().to(get_exercise_answers_requiring_attention),
1205 )
1206 .route(
1207 "/{course_id}/exercises-by-course-id",
1208 web::get().to(get_exercises_by_course_id),
1209 )
1210 .route(
1211 "/{course_id}/reset-exercises-for-selected-users",
1212 web::post().to(reset_exercises_for_selected_users),
1213 )
1214 .route("/{exercise_id}", web::get().to(get_exercise))
1215 .route(
1216 "/{exercise_id}/submissions/user/{user_id}",
1217 web::get().to(get_exercise_submissions_for_user),
1218 );
1219}
1220
1221#[cfg(test)]
1222mod answer_file_download_tests {
1223 use super::*;
1224 use crate::test_helper::*;
1225 use actix_session::{SessionMiddleware, storage::CookieSessionStore};
1226 use actix_web::cookie::{Key, SameSite};
1227 use actix_web::http::StatusCode;
1228 use actix_web::{App, test};
1229 use async_zip::base::read::mem::ZipFileReader;
1230 use models::library::grading::SubmittedAnswer;
1231 use models::roles::{RoleDomain, UserRole};
1232 use std::sync::Arc;
1233
1234 const SESSION_KEY_BYTES: &[u8] =
1235 b"answer-file-download-tests-cookie-signing-key-long-enough-abcdef";
1236
1237 async fn test_login(user_id: web::Path<Uuid>, session: actix_session::Session) -> HttpResponse {
1239 let now = Utc::now();
1240 crate::domain::authentication::remember(
1241 &session,
1242 models::users::User {
1243 id: *user_id,
1244 created_at: now,
1245 updated_at: now,
1246 deleted_at: None,
1247 upstream_id: None,
1248 email_domain: None,
1249 },
1250 )
1251 .expect("remember the user");
1252 HttpResponse::Ok().finish()
1253 }
1254
1255 macro_rules! exercises_app {
1256 ($file_store:expr) => {{
1257 let pool = PgPool::connect(&test_database_url()).await.expect("pool");
1258 let file_store: Arc<dyn FileStore> = $file_store;
1259 test::init_service(
1260 App::new()
1261 .app_data(web::Data::new(pool))
1262 .app_data(web::Data::from(file_store))
1263 .app_data(web::Data::new(init_app_conf().expect("app conf")))
1264 .service(
1265 web::resource("/test-login/{user_id}").route(web::post().to(test_login)),
1266 )
1267 .service(web::scope("/api/v0/main-frontend/exercises").configure(_add_routes))
1268 .wrap(
1269 SessionMiddleware::builder(
1270 CookieSessionStore::default(),
1271 Key::from(SESSION_KEY_BYTES),
1272 )
1273 .cookie_secure(false)
1274 .cookie_same_site(SameSite::Lax)
1275 .cookie_path("/".to_string())
1276 .build(),
1277 ),
1278 )
1279 .await
1280 }};
1281 }
1282
1283 macro_rules! login {
1284 ($app:expr, $user:expr) => {{
1285 let request = test::TestRequest::post()
1286 .uri(&format!("/test-login/{}", $user))
1287 .to_request();
1288 let response = test::call_service(&$app, request).await;
1289 assert_eq!(response.status(), StatusCode::OK);
1290 response
1291 .response()
1292 .cookies()
1293 .next()
1294 .expect("session cookie")
1295 .into_owned()
1296 }};
1297 }
1298
1299 fn download_uri(exercise_id: Uuid) -> String {
1300 format!("/api/v0/main-frontend/exercises/{exercise_id}/download-answer-files")
1301 }
1302
1303 async fn upload_answer_file(
1307 conn: &mut PgConnection,
1308 file_store: &TempFileStore,
1309 contents: &str,
1310 ) -> Uuid {
1311 let path = format!("answer-uploads/{}", Uuid::new_v4());
1312 FileStore::upload(
1313 file_store,
1314 std::path::Path::new(&path),
1315 contents.as_bytes().to_vec(),
1316 "text/plain",
1317 )
1318 .await
1319 .expect("upload");
1320 models::file_uploads::insert(
1321 conn,
1322 "student-real-name.txt",
1323 &path,
1324 "text/plain",
1325 None,
1326 Some(contents.len() as i64),
1327 )
1328 .await
1329 .expect("file upload row")
1330 }
1331
1332 async fn submit_file_answer(
1333 conn: &mut PgConnection,
1334 course_id: Uuid,
1335 user_id: Uuid,
1336 exercise_id: Uuid,
1337 exercise_slide_id: Uuid,
1338 exercise_task_id: Uuid,
1339 file_upload_ids: Vec<Uuid>,
1340 ) -> Uuid {
1341 let slide_submission = models::exercise_slide_submissions::insert_exercise_slide_submission(
1342 conn,
1343 models::exercise_slide_submissions::NewExerciseSlideSubmission {
1344 exercise_slide_id,
1345 course_id: Some(course_id),
1346 exam_id: None,
1347 user_id,
1348 exercise_id,
1349 user_points_update_strategy:
1350 models::exercise_task_gradings::UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
1351 },
1352 )
1353 .await
1354 .expect("slide submission");
1355 models::exercise_task_submissions::insert(
1356 conn,
1357 PKeyPolicy::Generate,
1358 slide_submission.id,
1359 exercise_slide_id,
1360 exercise_task_id,
1361 &SubmittedAnswer::File {
1362 file_upload_ids,
1363 metadata: None,
1364 },
1365 )
1366 .await
1367 .expect("task submission")
1368 }
1369
1370 async fn archive_entries(body: Vec<u8>) -> Vec<(String, String)> {
1371 use futures::io::AsyncReadExt;
1372 let reader = ZipFileReader::new(body).await.expect("a readable zip");
1373 let mut entries = Vec::new();
1374 for index in 0..reader.file().entries().len() {
1375 let name = reader.file().entries()[index]
1376 .filename()
1377 .as_str()
1378 .expect("a utf-8 entry name")
1379 .to_string();
1380 let mut contents = String::new();
1381 reader
1382 .reader_with_entry(index)
1383 .await
1384 .expect("entry reader")
1385 .read_to_string(&mut contents)
1386 .await
1387 .expect("entry contents");
1388 entries.push((name, contents));
1389 }
1390 entries
1391 }
1392
1393 #[actix_web::test]
1394 async fn streams_every_users_answer_files_with_positional_names() {
1395 insert_data!(:tx, user: first_student, :org, course: course, instance: _instance, :course_module, :chapter, :page, exercise: exercise, slide: slide, task: task);
1396 let second_student = models::users::insert(
1397 tx.as_mut(),
1398 PKeyPolicy::Generate,
1399 &format!("second-student-{}@example.org", Uuid::new_v4()),
1400 None,
1401 None,
1402 )
1403 .await
1404 .expect("second student");
1405 let teacher = models::users::insert(
1406 tx.as_mut(),
1407 PKeyPolicy::Generate,
1408 &format!("teacher-{}@example.org", Uuid::new_v4()),
1409 None,
1410 None,
1411 )
1412 .await
1413 .expect("teacher");
1414 models::roles::insert(
1415 tx.as_mut(),
1416 teacher,
1417 UserRole::Teacher,
1418 RoleDomain::Course(course),
1419 )
1420 .await
1421 .expect("teacher role");
1422
1423 let file_store = temp_file_store();
1424 let first_first = upload_answer_file(tx.as_mut(), &file_store, "first user file one").await;
1425 let first_second =
1426 upload_answer_file(tx.as_mut(), &file_store, "first user file two").await;
1427 let earlier = upload_answer_file(tx.as_mut(), &file_store, "first user earlier").await;
1428 let second_users_file = upload_answer_file(tx.as_mut(), &file_store, "second user").await;
1429
1430 let earlier_submission = submit_file_answer(
1431 tx.as_mut(),
1432 course,
1433 first_student,
1434 exercise,
1435 slide,
1436 task,
1437 vec![earlier],
1438 )
1439 .await;
1440 let later_submission = submit_file_answer(
1441 tx.as_mut(),
1442 course,
1443 first_student,
1444 exercise,
1445 slide,
1446 task,
1447 vec![first_first, first_second],
1448 )
1449 .await;
1450 let second_students_submission = submit_file_answer(
1451 tx.as_mut(),
1452 course,
1453 second_student,
1454 exercise,
1455 slide,
1456 task,
1457 vec![second_users_file],
1458 )
1459 .await;
1460 tx.commit().await;
1461
1462 let app = exercises_app!(Arc::new(file_store));
1463 let cookie = login!(app, teacher);
1464 let response = test::call_service(
1465 &app,
1466 test::TestRequest::get()
1467 .uri(&download_uri(exercise))
1468 .cookie(cookie)
1469 .to_request(),
1470 )
1471 .await;
1472
1473 assert_eq!(response.status(), StatusCode::OK);
1474 let disposition = response
1475 .headers()
1476 .get("Content-Disposition")
1477 .expect("a content disposition")
1478 .to_str()
1479 .expect("an ascii content disposition")
1480 .to_string();
1481 assert!(disposition.starts_with("attachment; "));
1482 assert!(disposition.ends_with("-answers.zip\""));
1483
1484 let entries = archive_entries(test::read_body(response).await.to_vec()).await;
1485 let mut expected = vec![
1486 (
1487 format!("{first_student}/{earlier_submission}/0.txt"),
1488 "first user earlier".to_string(),
1489 ),
1490 (
1491 format!("{first_student}/{later_submission}/0.txt"),
1492 "first user file one".to_string(),
1493 ),
1494 (
1495 format!("{first_student}/{later_submission}/1.txt"),
1496 "first user file two".to_string(),
1497 ),
1498 (
1499 format!("{second_student}/{second_students_submission}/0.txt"),
1500 "second user".to_string(),
1501 ),
1502 ];
1503 let mut actual = entries.clone();
1504 expected.sort();
1505 actual.sort();
1506 assert_eq!(actual, expected);
1507 assert!(
1508 !entries
1509 .iter()
1510 .any(|(name, _)| name.contains("student-real-name")),
1511 "the stored filename must not reach the archive"
1512 );
1513 }
1514
1515 #[actix_web::test]
1516 async fn a_non_teacher_is_forbidden() {
1517 insert_data!(:tx, user: student, :org, course: course, instance: _instance, :course_module, :chapter, :page, exercise: exercise, slide: slide, task: task);
1518 let file_store = temp_file_store();
1519 let file = upload_answer_file(tx.as_mut(), &file_store, "answer").await;
1520 submit_file_answer(
1521 tx.as_mut(),
1522 course,
1523 student,
1524 exercise,
1525 slide,
1526 task,
1527 vec![file],
1528 )
1529 .await;
1530 tx.commit().await;
1531
1532 let app = exercises_app!(Arc::new(file_store));
1533 let cookie = login!(app, student);
1534 let response = test::call_service(
1535 &app,
1536 test::TestRequest::get()
1537 .uri(&download_uri(exercise))
1538 .cookie(cookie)
1539 .to_request(),
1540 )
1541 .await;
1542
1543 assert_eq!(response.status(), StatusCode::FORBIDDEN);
1544 }
1545}