Skip to main content

headless_lms_server/controllers/main_frontend/
exercises.rs

1//! Controllers for requests starting with `/api/v0/main-frontend/exercises`.
2
3use 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
94/// Returns true if the endpoint path is non-empty.
95fn csv_endpoint_is_supported(path: &Option<String>) -> bool {
96    path.as_ref().is_some_and(|value| !value.trim().is_empty())
97}
98
99/// Parses and validates endpoint path or returns BadRequest if missing/empty.
100fn 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
121/// Fetches exercise service from DB and service info via HTTP; prefers internal_url, falls back to public_url.
122async 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
150/// Builds final CSV endpoint URL using internally preferred base URL.
151fn 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
162/// Selects task by id or returns BadRequest if not found in list.
163fn 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
180/// Merges base and service columns ensuring unique keys and mapping; errors on duplicate original service keys or final name collisions.
181fn 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
235/// Builds key -> column index map for final columns.
236fn 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
244/// Converts scalar JSON to CSV string; errors on array/object.
245fn 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
259/// Writes CSV rows merging base_row and service rows; validates keys against mapping.
260fn 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
334/// Finalizes writer into bytes and maps CSV errors to ControllerError.
335fn 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/**
347GET `/api/v0/main-frontend/exercises/:exercise_id` - Returns a single exercise.
348 */
349#[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/**
387GET `/api/v0/main-frontend/exercises/:exercise_id/submissions` - Returns an exercise's submissions.
388 */
389#[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/**
442GET `/api/v0/main-frontend/exercises/:exercise_id/submissions/user/:user_id` - Returns an exercise's submissions for a user.
443 */
444#[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/**
491GET `/api/v0/main-frontend/exercises/:exercise_id/csv-export-task-options` - Returns available exercise tasks and, for each task's exercise service, the CSV export support flags and whether its answers are files.
492 */
493#[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/**
576GET `/api/v0/main-frontend/exercises/:exercise_id/export-definitions-csv` - Exports one exercise task definition as CSV using the task's exercise service.
577 */
578#[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/**
684GET `/api/v0/main-frontend/exercises/:exercise_id/export-answers-csv` - Exports all answers for one exercise task as CSV using the task's exercise service.
685 */
686#[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/**
917GET `/api/v0/main-frontend/exercises/:exercise_id/download-answer-files` - Streams every file-typed answer to the exercise as a zip archive.
918 */
919#[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/**
968GET `/api/v0/main-frontend/exercises/:exercise_id/has-answer-files` - Tells whether the exercise has any file-typed answer to download.
969 */
970#[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/**
1004GET `/api/v0/main-frontend/exercises/:exercise_id/answers-requiring-attention` - Returns an exercise's answers requiring attention.
1005 */
1006#[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/**
1052GET `/api/v0/main-frontend/exercises/:course_id/exercises-by-course-id` - Returns all exercises for a course with course_id
1053 */
1054#[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/**
1099POST `/api/v0/main-frontend/exercises/:course_id/reset-exercises-for-selected-users` - Resets all selected exercises for selected users and then logs the resets to exercise_reset_logs table
1100 */
1101#[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    // Gets all valid users and their related exercises using the given filters.
1144    // This selects reset candidates, but course ownership is enforced above and in the scoped reset.
1145    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    // Resets grouped user-exercise pairs to avoid cross-user cross-product resets.
1156    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
1170/**
1171Add a route for each controller in this module.
1172
1173The name starts with an underline in order to appear before other functions in the module documentation.
1174
1175We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
1176*/
1177pub 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    /// Puts a user into the session without going through a login flow, so `AuthUser` resolves.
1238    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    /// Stores the bytes in the file store under a fresh path and records the `file_uploads` row
1304    /// that names them. The stored name is deliberately identifying, so a test can prove the
1305    /// archive does not use it.
1306    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}