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_tasks::ExerciseTask,
14    library::grading::AnswersRequiringAttention,
15};
16use utoipa::{OpenApi, ToSchema};
17
18use crate::{domain::models_requests, prelude::*};
19
20const EXERCISE_SERVICE_CSV_EXPORT_BATCH_SIZE: usize = 1000;
21
22#[derive(OpenApi)]
23#[openapi(paths(
24    get_exercise,
25    get_exercise_submissions,
26    get_exercise_submissions_for_user,
27    get_exercise_csv_export_task_options,
28    export_exercise_task_definitions_csv,
29    export_exercise_task_answers_csv,
30    get_exercise_answers_requiring_attention,
31    get_exercises_by_course_id,
32    reset_exercises_for_selected_users
33))]
34pub(crate) struct MainFrontendExercisesApiDoc;
35
36#[derive(Debug, Serialize, ToSchema)]
37
38pub struct ExerciseSubmissions {
39    pub data: Vec<ExerciseSlideSubmission>,
40    pub total_pages: u32,
41}
42
43#[derive(Debug, Serialize, ToSchema)]
44
45pub struct ExerciseCsvExportTaskOption {
46    pub exercise_task_id: Uuid,
47    pub exercise_type: String,
48    pub order_number: i32,
49    pub supports_csv_export_definitions: bool,
50    pub supports_csv_export_answers: bool,
51}
52
53#[derive(Debug, Deserialize)]
54pub struct ExerciseCsvExportQuery {
55    pub exercise_task_id: Uuid,
56    #[serde(default)]
57    pub only_latest_per_user: bool,
58}
59
60#[derive(Debug, Clone)]
61struct CsvColumnDefinition {
62    key: String,
63    header: String,
64}
65
66impl CsvColumnDefinition {
67    fn new(key: impl Into<String>, header: impl Into<String>) -> Self {
68        Self {
69            key: key.into(),
70            header: header.into(),
71        }
72    }
73}
74
75#[derive(Debug, Serialize)]
76struct ExerciseDefinitionsCsvExportRequestItem<'a> {
77    private_spec: &'a Option<Value>,
78}
79
80#[derive(Debug, Serialize)]
81struct ExerciseAnswersCsvExportRequestItem<'a> {
82    private_spec: &'a Option<Value>,
83    answer: &'a Option<Value>,
84    grading: Option<&'a ExerciseTaskGrading>,
85    model_solution_spec: &'a Option<Value>,
86}
87
88/// Returns true if the endpoint path is non-empty.
89fn csv_endpoint_is_supported(path: &Option<String>) -> bool {
90    path.as_ref().is_some_and(|value| !value.trim().is_empty())
91}
92
93/// Parses and validates endpoint path or returns BadRequest if missing/empty.
94fn get_csv_export_endpoint_path(
95    path: &Option<String>,
96    endpoint_name: &str,
97) -> Result<String, ControllerError> {
98    let endpoint_path = path
99        .as_ref()
100        .map(|value| value.trim())
101        .filter(|value| !value.is_empty())
102        .ok_or_else(|| {
103            ControllerError::new(
104                ControllerErrorType::BadRequest,
105                format!(
106                    "Exercise service does not support {} CSV export.",
107                    endpoint_name
108                ),
109                None,
110            )
111        })?;
112    Ok(endpoint_path.to_string())
113}
114
115/// Fetches exercise service from DB and service info via HTTP; prefers internal_url, falls back to public_url.
116async fn fetch_exercise_service_and_info(
117    conn: &mut PgConnection,
118    exercise_type: &str,
119) -> models::ModelResult<(ExerciseService, ExerciseServiceInfoApi)> {
120    let exercise_service =
121        models::exercise_services::get_exercise_service_by_exercise_type(conn, exercise_type)
122            .await?;
123    let internal_url = exercise_service.internal_url.clone();
124    let public_url = exercise_service.public_url.clone();
125    let slug = exercise_service.slug.clone();
126    let service_info_url = match internal_url.as_ref() {
127        Some(url_str) => match url_str.parse::<Url>() {
128            Ok(url) => url,
129            Err(error) => {
130                warn!(
131                    exercise_service_slug = ?slug,
132                    ?error,
133                    "Internal URL for service info is invalid, falling back to public URL."
134                );
135                public_url.parse()?
136            }
137        },
138        None => public_url.parse()?,
139    };
140    let service_info = models_requests::fetch_service_info_fast(service_info_url).await?;
141    Ok((exercise_service, service_info))
142}
143
144/// Builds final CSV endpoint URL using internally preferred base URL.
145fn build_service_endpoint_url(
146    exercise_service: &ExerciseService,
147    endpoint_path: &str,
148) -> Result<Url, ControllerError> {
149    let mut url = models::exercise_services::get_exercise_service_internally_preferred_baseurl(
150        exercise_service,
151    )?;
152    url.set_path(endpoint_path);
153    Ok(url)
154}
155
156/// Selects task by id or returns BadRequest if not found in list.
157fn get_selected_task(
158    tasks: &[ExerciseTask],
159    exercise_task_id: Uuid,
160) -> Result<ExerciseTask, ControllerError> {
161    tasks
162        .iter()
163        .find(|task| task.id == exercise_task_id)
164        .cloned()
165        .ok_or_else(|| {
166            ControllerError::new(
167                ControllerErrorType::BadRequest,
168                "Selected task does not belong to this exercise.".to_string(),
169                None,
170            )
171        })
172}
173
174/// Merges base and service columns ensuring unique keys and mapping; errors on duplicate original service keys or final name collisions.
175fn build_final_columns(
176    base_columns: &[CsvColumnDefinition],
177    service_columns: &[models_requests::ExerciseServiceCsvExportColumn],
178) -> Result<(Vec<CsvColumnDefinition>, HashMap<String, String>), ControllerError> {
179    let mut final_columns = base_columns.to_vec();
180    let mut used_keys = base_columns
181        .iter()
182        .map(|column| column.key.clone())
183        .collect::<HashSet<_>>();
184    let mut service_key_to_final_key = HashMap::new();
185
186    for column in service_columns {
187        let key = column.key.trim();
188        if key.is_empty() {
189            return Err(ControllerError::new(
190                ControllerErrorType::BadRequest,
191                "Exercise service CSV export response contains an empty column key.".to_string(),
192                None,
193            ));
194        }
195        if service_key_to_final_key.contains_key(key) {
196            return Err(ControllerError::new(
197                ControllerErrorType::BadRequest,
198                format!(
199                    "Exercise service CSV export response contains duplicate original column key '{}'.",
200                    key
201                ),
202                None,
203            ));
204        }
205
206        let mut final_key = key.to_string();
207        if used_keys.contains(&final_key) {
208            final_key = format!("service_{}", final_key);
209        }
210        if used_keys.contains(&final_key) {
211            return Err(ControllerError::new(
212                ControllerErrorType::BadRequest,
213                format!(
214                    "Exercise service CSV export response contains duplicate column key '{}'.",
215                    key
216                ),
217                None,
218            ));
219        }
220
221        used_keys.insert(final_key.clone());
222        service_key_to_final_key.insert(key.to_string(), final_key.clone());
223        final_columns.push(CsvColumnDefinition::new(final_key, column.header.clone()));
224    }
225
226    Ok((final_columns, service_key_to_final_key))
227}
228
229/// Builds key -> column index map for final columns.
230fn build_column_index_map(columns: &[CsvColumnDefinition]) -> HashMap<String, usize> {
231    columns
232        .iter()
233        .enumerate()
234        .map(|(index, column)| (column.key.clone(), index))
235        .collect()
236}
237
238/// Converts scalar JSON to CSV string; errors on array/object.
239fn scalar_json_to_csv_value(value: &Value) -> Result<String, ControllerError> {
240    match value {
241        Value::Null => Ok(String::new()),
242        Value::Bool(value) => Ok(value.to_string()),
243        Value::Number(value) => Ok(value.to_string()),
244        Value::String(value) => Ok(value.clone()),
245        Value::Array(_) | Value::Object(_) => Err(ControllerError::new(
246            ControllerErrorType::BadRequest,
247            "Exercise service CSV export response contains a non-scalar cell value.".to_string(),
248            None,
249        )),
250    }
251}
252
253/// Writes CSV rows merging base_row and service rows; validates keys against mapping.
254fn write_csv_rows(
255    writer: &mut csv::Writer<Vec<u8>>,
256    final_columns: &[CsvColumnDefinition],
257    column_index_map: &HashMap<String, usize>,
258    service_key_to_final_key: &HashMap<String, String>,
259    base_row: &HashMap<String, String>,
260    rows: &[HashMap<String, Value>],
261) -> Result<(), ControllerError> {
262    let write_single_row = |service_row: Option<&HashMap<String, Value>>,
263                            writer: &mut csv::Writer<Vec<u8>>|
264     -> Result<(), ControllerError> {
265        let mut record = vec![String::new(); final_columns.len()];
266
267        for (base_key, base_value) in base_row {
268            let index = column_index_map.get(base_key).ok_or_else(|| {
269                ControllerError::new(
270                    ControllerErrorType::InternalServerError,
271                    format!(
272                        "Base CSV column '{}' is missing from the final header.",
273                        base_key
274                    ),
275                    None,
276                )
277            })?;
278            record[*index] = base_value.clone();
279        }
280
281        if let Some(row) = service_row {
282            for (service_key, value) in row {
283                let final_key = service_key_to_final_key.get(service_key).ok_or_else(|| {
284                    ControllerError::new(
285                        ControllerErrorType::BadRequest,
286                        format!(
287                            "Exercise service CSV export response contains an unknown column key '{}'.",
288                            service_key
289                        ),
290                        None,
291                    )
292                })?;
293                let index = column_index_map.get(final_key).ok_or_else(|| {
294                    ControllerError::new(
295                        ControllerErrorType::InternalServerError,
296                        format!(
297                            "CSV column '{}' is missing from the final header.",
298                            final_key
299                        ),
300                        None,
301                    )
302                })?;
303                record[*index] = scalar_json_to_csv_value(value)?;
304            }
305        }
306
307        writer.write_record(record).map_err(|error| {
308            ControllerError::new(
309                ControllerErrorType::InternalServerError,
310                format!("Failed to write CSV row: {}", error),
311                Some(error.into()),
312            )
313        })?;
314        Ok(())
315    };
316
317    if rows.is_empty() {
318        write_single_row(None, writer)?;
319    } else {
320        for row in rows {
321            write_single_row(Some(row), writer)?;
322        }
323    }
324
325    Ok(())
326}
327
328/// Finalizes writer into bytes and maps CSV errors to ControllerError.
329fn csv_writer_into_bytes(writer: csv::Writer<Vec<u8>>) -> Result<Vec<u8>, ControllerError> {
330    writer.into_inner().map_err(|error| {
331        let csv_error = error.into_error();
332        ControllerError::new(
333            ControllerErrorType::InternalServerError,
334            format!("Failed to finalize CSV export: {}", csv_error),
335            Some(csv_error.into()),
336        )
337    })
338}
339
340/**
341GET `/api/v0/main-frontend/exercises/:exercise_id` - Returns a single exercise.
342 */
343#[instrument(skip(pool))]
344#[utoipa::path(
345    get,
346    path = "/{exercise_id}",
347    operation_id = "getExercise",
348    tag = "exercises",
349    params(
350        ("exercise_id" = Uuid, Path, description = "Exercise id")
351    ),
352    responses(
353        (status = 200, description = "Exercise", body = Exercise)
354    )
355)]
356async fn get_exercise(
357    pool: web::Data<PgPool>,
358    exercise_id: web::Path<Uuid>,
359    user: AuthUser,
360) -> ControllerResult<web::Json<Exercise>> {
361    let mut conn = pool.acquire().await?;
362
363    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
364
365    let token = if let Some(course_id) = exercise.course_id {
366        authorize(&mut conn, Act::View, Some(user.id), Res::Course(course_id)).await?
367    } else if let Some(exam_id) = exercise.exam_id {
368        authorize(&mut conn, Act::View, Some(user.id), Res::Exam(exam_id)).await?
369    } else {
370        return Err(ControllerError::new(
371            ControllerErrorType::BadRequest,
372            "Exercise is not associated with a course or exam".to_string(),
373            None,
374        ));
375    };
376
377    token.authorized_ok(web::Json(exercise))
378}
379
380/**
381GET `/api/v0/main-frontend/exercises/:exercise_id/submissions` - Returns an exercise's submissions.
382 */
383#[instrument(skip(pool))]
384#[utoipa::path(
385    get,
386    path = "/{exercise_id}/submissions",
387    operation_id = "getExerciseSubmissions",
388    tag = "exercises",
389    params(
390        ("exercise_id" = Uuid, Path, description = "Exercise id"),
391        ("page" = Option<i64>, Query, description = "Page number"),
392        ("limit" = Option<i64>, Query, description = "Page size")
393    ),
394    responses(
395        (status = 200, description = "Exercise submissions", body = ExerciseSubmissions)
396    )
397)]
398async fn get_exercise_submissions(
399    pool: web::Data<PgPool>,
400    exercise_id: web::Path<Uuid>,
401    pagination: web::Query<Pagination>,
402    user: AuthUser,
403) -> ControllerResult<web::Json<ExerciseSubmissions>> {
404    let mut conn = pool.acquire().await?;
405
406    let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
407        CourseOrExamId::Course(id) => {
408            authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
409        }
410        CourseOrExamId::Exam(id) => {
411            authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
412        }
413    };
414
415    let submission_count = models::exercise_slide_submissions::exercise_slide_submission_count(
416        &mut conn,
417        *exercise_id,
418    );
419    let mut conn = pool.acquire().await?;
420    let submissions = models::exercise_slide_submissions::exercise_slide_submissions(
421        &mut conn,
422        *exercise_id,
423        *pagination,
424    );
425    let (submission_count, submissions) = future::try_join(submission_count, submissions).await?;
426
427    let total_pages = pagination.total_pages(submission_count);
428
429    token.authorized_ok(web::Json(ExerciseSubmissions {
430        data: submissions,
431        total_pages,
432    }))
433}
434
435/**
436GET `/api/v0/main-frontend/exercises/:exercise_id/submissions/user/:user_id` - Returns an exercise's submissions for a user.
437 */
438#[instrument(skip(pool, user))]
439#[utoipa::path(
440    get,
441    path = "/{exercise_id}/submissions/user/{user_id}",
442    operation_id = "getExerciseSubmissionsForUser",
443    tag = "exercises",
444    params(
445        ("exercise_id" = Uuid, Path, description = "Exercise id"),
446        ("user_id" = Uuid, Path, description = "User id")
447    ),
448    responses(
449        (status = 200, description = "Exercise submissions for user", body = [ExerciseSlideSubmission])
450    )
451)]
452async fn get_exercise_submissions_for_user(
453    pool: web::Data<PgPool>,
454    ids: web::Path<(Uuid, Uuid)>,
455    user: AuthUser,
456) -> ControllerResult<web::Json<Vec<ExerciseSlideSubmission>>> {
457    let (exercise_id, user_id) = ids.into_inner();
458    let mut conn = pool.acquire().await?;
459
460    let target_user = models::users::get_by_id(&mut conn, user_id).await?;
461
462    let course_or_exam_id =
463        models::exercises::get_course_or_exam_id(&mut conn, exercise_id).await?;
464
465    let token = match course_or_exam_id {
466        CourseOrExamId::Course(id) => {
467            authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
468        }
469        CourseOrExamId::Exam(id) => {
470            authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
471        }
472    };
473
474    let submissions = models::exercise_slide_submissions::get_users_submissions_for_exercise(
475        &mut conn,
476        target_user.id,
477        exercise_id,
478    )
479    .await?;
480
481    token.authorized_ok(web::Json(submissions))
482}
483
484/**
485GET `/api/v0/main-frontend/exercises/:exercise_id/csv-export-task-options` - Returns available exercise tasks and CSV export support flags for each task's exercise service.
486 */
487#[instrument(skip(pool))]
488#[utoipa::path(
489    get,
490    path = "/{exercise_id}/csv-export-task-options",
491    operation_id = "getExerciseCsvExportTaskOptions",
492    tag = "exercises",
493    params(
494        ("exercise_id" = Uuid, Path, description = "Exercise id")
495    ),
496    responses(
497        (status = 200, description = "Exercise CSV export task options", body = [ExerciseCsvExportTaskOption])
498    )
499)]
500async fn get_exercise_csv_export_task_options(
501    pool: web::Data<PgPool>,
502    exercise_id: web::Path<Uuid>,
503    user: AuthUser,
504) -> ControllerResult<web::Json<Vec<ExerciseCsvExportTaskOption>>> {
505    let mut conn = pool.acquire().await?;
506    let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
507        CourseOrExamId::Course(id) => {
508            authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
509        }
510        CourseOrExamId::Exam(id) => {
511            authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
512        }
513    };
514
515    let mut tasks =
516        models::exercise_tasks::get_exercise_tasks_by_exercise_id(&mut conn, *exercise_id).await?;
517    tasks.sort_by_key(|task| (task.order_number, task.id));
518
519    let unique_exercise_types = tasks
520        .iter()
521        .map(|task| task.exercise_type.clone())
522        .collect::<HashSet<_>>();
523
524    let mut exercise_type_support = HashMap::new();
525    for exercise_type in unique_exercise_types {
526        let support = match fetch_exercise_service_and_info(&mut conn, &exercise_type).await {
527            Ok((_service, service_info)) => (
528                csv_endpoint_is_supported(&service_info.csv_export_definitions_endpoint_path),
529                csv_endpoint_is_supported(&service_info.csv_export_answers_endpoint_path),
530            ),
531            Err(error) => {
532                warn!(
533                    exercise_type = ?exercise_type,
534                    ?error,
535                    "Could not fetch exercise service info for CSV export support detection."
536                );
537                (false, false)
538            }
539        };
540        exercise_type_support.insert(exercise_type, support);
541    }
542
543    let options = tasks
544        .into_iter()
545        .map(|task| {
546            let (supports_csv_export_definitions, supports_csv_export_answers) =
547                exercise_type_support
548                    .get(&task.exercise_type)
549                    .copied()
550                    .unwrap_or((false, false));
551            ExerciseCsvExportTaskOption {
552                exercise_task_id: task.id,
553                exercise_type: task.exercise_type,
554                order_number: task.order_number,
555                supports_csv_export_definitions,
556                supports_csv_export_answers,
557            }
558        })
559        .collect::<Vec<_>>();
560
561    token.authorized_ok(web::Json(options))
562}
563
564/**
565GET `/api/v0/main-frontend/exercises/:exercise_id/export-definitions-csv` - Exports one exercise task definition as CSV using the task's exercise service.
566 */
567#[instrument(skip(pool))]
568#[utoipa::path(
569    get,
570    path = "/{exercise_id}/export-definitions-csv",
571    operation_id = "exportExerciseDefinitionsCsv",
572    tag = "exercises",
573    params(
574        ("exercise_id" = Uuid, Path, description = "Exercise id"),
575        ("exercise_task_id" = Uuid, Query, description = "Exercise task id"),
576        ("only_latest_per_user" = Option<bool>, Query, description = "Only include latest submission per user")
577    ),
578    responses(
579        (status = 200, description = "Exercise definitions CSV", body = String, content_type = "text/csv")
580    )
581)]
582async fn export_exercise_task_definitions_csv(
583    pool: web::Data<PgPool>,
584    exercise_id: web::Path<Uuid>,
585    query: web::Query<ExerciseCsvExportQuery>,
586    user: AuthUser,
587) -> ControllerResult<HttpResponse> {
588    let mut conn = pool.acquire().await?;
589    let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
590        CourseOrExamId::Course(id) => {
591            authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
592        }
593        CourseOrExamId::Exam(id) => {
594            authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
595        }
596    };
597
598    let tasks =
599        models::exercise_tasks::get_exercise_tasks_by_exercise_id(&mut conn, *exercise_id).await?;
600    let selected_task = get_selected_task(&tasks, query.exercise_task_id)?;
601
602    let (exercise_service, service_info) =
603        fetch_exercise_service_and_info(&mut conn, &selected_task.exercise_type).await?;
604    let endpoint_path = get_csv_export_endpoint_path(
605        &service_info.csv_export_definitions_endpoint_path,
606        "definitions",
607    )?;
608    let endpoint_url = build_service_endpoint_url(&exercise_service, &endpoint_path)?;
609
610    let request_items = vec![ExerciseDefinitionsCsvExportRequestItem {
611        private_spec: &selected_task.private_spec,
612    }];
613    let response =
614        models_requests::post_exercise_service_csv_export_request(endpoint_url, &request_items)
615            .await?;
616    if response.results.len() != request_items.len() {
617        return Err(ControllerError::new(
618            ControllerErrorType::BadRequest,
619            format!(
620                "Exercise service returned {} results for {} definition items.",
621                response.results.len(),
622                request_items.len()
623            ),
624            None,
625        ));
626    }
627
628    let base_columns = vec![CsvColumnDefinition::new(
629        "exercise_task_id",
630        "Exercise task id",
631    )];
632    let (final_columns, service_key_to_final_key) =
633        build_final_columns(&base_columns, &response.columns)?;
634    let column_index_map = build_column_index_map(&final_columns);
635
636    let mut writer = csv::Writer::from_writer(Vec::new());
637    writer
638        .write_record(final_columns.iter().map(|column| column.header.as_str()))
639        .map_err(|error| {
640            ControllerError::new(
641                ControllerErrorType::InternalServerError,
642                format!("Failed to write CSV headers: {}", error),
643                Some(error.into()),
644            )
645        })?;
646
647    let mut base_row = HashMap::new();
648    base_row.insert("exercise_task_id".to_string(), selected_task.id.to_string());
649    write_csv_rows(
650        &mut writer,
651        &final_columns,
652        &column_index_map,
653        &service_key_to_final_key,
654        &base_row,
655        &response.results[0].rows,
656    )?;
657
658    let csv_bytes = csv_writer_into_bytes(writer)?;
659    let content_disposition = format!(
660        "attachment; filename=\"exercise-{}-definitions-{}.csv\"",
661        *exercise_id, selected_task.id
662    );
663
664    token.authorized_ok(
665        HttpResponse::Ok()
666            .append_header(("Content-Disposition", content_disposition))
667            .append_header(("Content-Type", "text/csv; charset=utf-8"))
668            .body(csv_bytes),
669    )
670}
671
672/**
673GET `/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.
674 */
675#[instrument(skip(pool))]
676#[utoipa::path(
677    get,
678    path = "/{exercise_id}/export-answers-csv",
679    operation_id = "exportExerciseAnswersCsv",
680    tag = "exercises",
681    params(
682        ("exercise_id" = Uuid, Path, description = "Exercise id"),
683        ("exercise_task_id" = Uuid, Query, description = "Exercise task id"),
684        ("only_latest_per_user" = Option<bool>, Query, description = "Only include latest submission per user")
685    ),
686    responses(
687        (status = 200, description = "Exercise answers CSV", body = String, content_type = "text/csv")
688    )
689)]
690async fn export_exercise_task_answers_csv(
691    pool: web::Data<PgPool>,
692    exercise_id: web::Path<Uuid>,
693    query: web::Query<ExerciseCsvExportQuery>,
694    user: AuthUser,
695) -> ControllerResult<HttpResponse> {
696    let mut conn = pool.acquire().await?;
697    let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
698        CourseOrExamId::Course(id) => {
699            authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
700        }
701        CourseOrExamId::Exam(id) => {
702            authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
703        }
704    };
705
706    let tasks =
707        models::exercise_tasks::get_exercise_tasks_by_exercise_id(&mut conn, *exercise_id).await?;
708    let selected_task = get_selected_task(&tasks, query.exercise_task_id)?;
709
710    let (exercise_service, service_info) =
711        fetch_exercise_service_and_info(&mut conn, &selected_task.exercise_type).await?;
712    let endpoint_path =
713        get_csv_export_endpoint_path(&service_info.csv_export_answers_endpoint_path, "answers")?;
714    let endpoint_url = build_service_endpoint_url(&exercise_service, &endpoint_path)?;
715
716    let export_data = if query.only_latest_per_user {
717        models::exercise_task_submissions::get_csv_export_data_by_exercise_and_task_latest_per_user(
718            &mut conn,
719            *exercise_id,
720            selected_task.id,
721        )
722        .await?
723    } else {
724        models::exercise_task_submissions::get_csv_export_data_by_exercise_and_task(
725            &mut conn,
726            *exercise_id,
727            selected_task.id,
728        )
729        .await?
730    };
731    let submission_ids = export_data
732        .iter()
733        .map(|submission| submission.exercise_task_submission_id)
734        .collect::<Vec<_>>();
735    let gradings_by_submission_id =
736        models::exercise_task_gradings::get_by_exercise_task_submission_ids(
737            &mut conn,
738            &submission_ids,
739        )
740        .await?;
741
742    let base_columns = vec![
743        CsvColumnDefinition::new(
744            "exercise_slide_submission_id",
745            "Exercise slide submission id",
746        ),
747        CsvColumnDefinition::new("exercise_task_submission_id", "Exercise task submission id"),
748        CsvColumnDefinition::new("exercise_task_id", "Exercise task id"),
749        CsvColumnDefinition::new("exercise_id", "Exercise id"),
750        CsvColumnDefinition::new("user_id", "User id"),
751        CsvColumnDefinition::new("submitted_at", "Submitted at"),
752    ];
753
754    let mut writer = csv::Writer::from_writer(Vec::new());
755    let mut expected_service_columns: Option<Vec<models_requests::ExerciseServiceCsvExportColumn>> =
756        None;
757    let mut final_columns: Option<Vec<CsvColumnDefinition>> = None;
758    let mut column_index_map = HashMap::new();
759    let mut service_key_to_final_key = HashMap::new();
760
761    for export_chunk in export_data.chunks(EXERCISE_SERVICE_CSV_EXPORT_BATCH_SIZE) {
762        let mut request_items = Vec::with_capacity(export_chunk.len());
763        let mut base_rows = Vec::with_capacity(export_chunk.len());
764        for submission in export_chunk {
765            let grading = gradings_by_submission_id.get(&submission.exercise_task_submission_id);
766            request_items.push(ExerciseAnswersCsvExportRequestItem {
767                private_spec: &selected_task.private_spec,
768                answer: &submission.answer,
769                grading,
770                model_solution_spec: &selected_task.model_solution_spec,
771            });
772            let mut base_row = HashMap::new();
773            base_row.insert(
774                "exercise_slide_submission_id".to_string(),
775                submission.exercise_slide_submission_id.to_string(),
776            );
777            base_row.insert(
778                "exercise_task_submission_id".to_string(),
779                submission.exercise_task_submission_id.to_string(),
780            );
781            base_row.insert(
782                "exercise_task_id".to_string(),
783                submission.exercise_task_id.to_string(),
784            );
785            base_row.insert(
786                "exercise_id".to_string(),
787                submission.exercise_id.to_string(),
788            );
789            base_row.insert("user_id".to_string(), submission.user_id.to_string());
790            base_row.insert(
791                "submitted_at".to_string(),
792                submission.submitted_at.to_rfc3339(),
793            );
794            base_rows.push(base_row);
795        }
796
797        let response = models_requests::post_exercise_service_csv_export_request(
798            endpoint_url.clone(),
799            &request_items,
800        )
801        .await?;
802
803        if response.results.len() != request_items.len() {
804            return Err(ControllerError::new(
805                ControllerErrorType::BadRequest,
806                format!(
807                    "Exercise service returned {} results for {} answer items.",
808                    response.results.len(),
809                    request_items.len()
810                ),
811                None,
812            ));
813        }
814
815        if let Some(expected_columns) = &expected_service_columns {
816            if expected_columns != &response.columns {
817                return Err(ControllerError::new(
818                    ControllerErrorType::BadRequest,
819                    "Exercise service returned different CSV columns for different answer batches."
820                        .to_string(),
821                    None,
822                ));
823            }
824        } else {
825            expected_service_columns = Some(response.columns.clone());
826        }
827
828        if final_columns.is_none() {
829            let (columns, service_key_mapping) =
830                build_final_columns(&base_columns, &response.columns)?;
831            column_index_map = build_column_index_map(&columns);
832            service_key_to_final_key = service_key_mapping;
833            writer
834                .write_record(columns.iter().map(|column| column.header.as_str()))
835                .map_err(|error| {
836                    ControllerError::new(
837                        ControllerErrorType::InternalServerError,
838                        format!("Failed to write CSV headers: {}", error),
839                        Some(error.into()),
840                    )
841                })?;
842            final_columns = Some(columns);
843        }
844
845        let final_columns_ref = final_columns.as_ref().ok_or_else(|| {
846            ControllerError::new(
847                ControllerErrorType::InternalServerError,
848                "CSV columns were not initialized.".to_string(),
849                None,
850            )
851        })?;
852
853        for (chunk_row_index, result) in response.results.iter().enumerate() {
854            let base_row = base_rows.get(chunk_row_index).ok_or_else(|| {
855                ControllerError::new(
856                    ControllerErrorType::InternalServerError,
857                    "Could not map CSV export response rows back to request items.".to_string(),
858                    None,
859                )
860            })?;
861            write_csv_rows(
862                &mut writer,
863                final_columns_ref,
864                &column_index_map,
865                &service_key_to_final_key,
866                base_row,
867                &result.rows,
868            )?;
869        }
870    }
871
872    if final_columns.is_none() {
873        let columns = base_columns;
874        writer
875            .write_record(columns.iter().map(|column| column.header.as_str()))
876            .map_err(|error| {
877                ControllerError::new(
878                    ControllerErrorType::InternalServerError,
879                    format!("Failed to write CSV headers: {}", error),
880                    Some(error.into()),
881                )
882            })?;
883    }
884
885    let csv_bytes = csv_writer_into_bytes(writer)?;
886    let content_disposition = format!(
887        "attachment; filename=\"exercise-{}-answers-{}.csv\"",
888        *exercise_id, selected_task.id
889    );
890
891    token.authorized_ok(
892        HttpResponse::Ok()
893            .append_header(("Content-Disposition", content_disposition))
894            .append_header(("Content-Type", "text/csv; charset=utf-8"))
895            .body(csv_bytes),
896    )
897}
898
899/**
900GET `/api/v0/main-frontend/exercises/:exercise_id/answers-requiring-attention` - Returns an exercise's answers requiring attention.
901 */
902#[instrument(skip(pool))]
903#[utoipa::path(
904    get,
905    path = "/{exercise_id}/answers-requiring-attention",
906    operation_id = "getExerciseAnswersRequiringAttention",
907    tag = "exercises",
908    params(
909        ("exercise_id" = Uuid, Path, description = "Exercise id"),
910        ("page" = Option<i64>, Query, description = "Page number"),
911        ("limit" = Option<i64>, Query, description = "Page size")
912    ),
913    responses(
914        (status = 200, description = "Answers requiring attention", body = AnswersRequiringAttention)
915    )
916)]
917async fn get_exercise_answers_requiring_attention(
918    pool: web::Data<PgPool>,
919    exercise_id: web::Path<Uuid>,
920    pagination: web::Query<Pagination>,
921    user: AuthUser,
922) -> ControllerResult<web::Json<AnswersRequiringAttention>> {
923    let mut conn = pool.acquire().await?;
924    let token = match models::exercises::get_course_or_exam_id(&mut conn, *exercise_id).await? {
925        CourseOrExamId::Course(id) => {
926            authorize(&mut conn, Act::Teach, Some(user.id), Res::Course(id)).await?
927        }
928        CourseOrExamId::Exam(id) => {
929            authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(id)).await?
930        }
931    };
932    let res = models::library::grading::get_paginated_answers_requiring_attention_for_exercise(
933        &mut conn,
934        *exercise_id,
935        *pagination,
936        user.id,
937        models_requests::fetch_service_info,
938    )
939    .await?;
940    token.authorized_ok(web::Json(res))
941}
942
943/**
944GET `/api/v0/main-frontend/exercises/:course_id/exercises-by-course-id` - Returns all exercises for a course with course_id
945 */
946#[utoipa::path(
947    get,
948    path = "/{course_id}/exercises-by-course-id",
949    operation_id = "getExercisesByCourseId",
950    tag = "exercises",
951    params(
952        ("course_id" = Uuid, Path, description = "Course id")
953    ),
954    responses(
955        (status = 200, description = "Exercises by course id", body = [Exercise])
956    )
957)]
958pub async fn get_exercises_by_course_id(
959    course_id: web::Path<Uuid>,
960    pool: web::Data<PgPool>,
961    user: AuthUser,
962) -> ControllerResult<web::Json<Vec<Exercise>>> {
963    let mut conn = pool.acquire().await?;
964
965    let token = authorize(
966        &mut conn,
967        Act::ViewUserProgressOrDetails,
968        Some(user.id),
969        Res::Course(*course_id),
970    )
971    .await?;
972
973    let mut exercises =
974        models::exercises::get_exercises_by_course_id(&mut conn, *course_id).await?;
975
976    exercises.sort_by_key(|e| (e.chapter_id, e.page_id, e.order_number));
977
978    token.authorized_ok(web::Json(exercises))
979}
980
981#[derive(Deserialize, ToSchema)]
982pub struct ResetExercisesPayload {
983    pub user_ids: Vec<Uuid>,
984    pub exercise_ids: Vec<Uuid>,
985    pub threshold: Option<f64>,
986    pub reset_all_below_max_points: bool,
987    pub reset_only_locked_peer_reviews: bool,
988}
989
990/**
991POST `/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
992 */
993#[utoipa::path(
994    post,
995    path = "/{course_id}/reset-exercises-for-selected-users",
996    operation_id = "resetExercisesForSelectedUsers",
997    tag = "exercises",
998    params(
999        ("course_id" = Uuid, Path, description = "Course id")
1000    ),
1001    request_body = ResetExercisesPayload,
1002    responses(
1003        (status = 200, description = "Number of successful resets", body = i32)
1004    )
1005)]
1006pub async fn reset_exercises_for_selected_users(
1007    course_id: web::Path<Uuid>,
1008    pool: web::Data<PgPool>,
1009    user: AuthUser,
1010    payload: web::Json<ResetExercisesPayload>,
1011) -> ControllerResult<web::Json<i32>> {
1012    let mut conn = pool.acquire().await?;
1013
1014    let token = authorize(
1015        &mut conn,
1016        Act::Teach,
1017        Some(user.id),
1018        Res::Course(*course_id),
1019    )
1020    .await?;
1021
1022    // Gets all valid users and their related exercises using the given filters
1023    let users_and_exercises = models::exercises::collect_user_ids_and_exercise_ids_for_reset(
1024        &mut conn,
1025        &payload.user_ids,
1026        &payload.exercise_ids,
1027        payload.threshold,
1028        payload.reset_all_below_max_points,
1029        payload.reset_only_locked_peer_reviews,
1030    )
1031    .await?;
1032
1033    // Resets exercises for selected users and add the resets to a log
1034    let reset_results = models::exercises::reset_exercises_for_selected_users(
1035        &mut conn,
1036        &users_and_exercises,
1037        Some(user.id),
1038        *course_id,
1039        Some("reset-by-staff".to_string()),
1040    )
1041    .await?;
1042
1043    let successful_resets_count = reset_results.len();
1044
1045    token.authorized_ok(web::Json(successful_resets_count as i32))
1046}
1047
1048/**
1049Add a route for each controller in this module.
1050
1051The name starts with an underline in order to appear before other functions in the module documentation.
1052
1053We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
1054*/
1055pub fn _add_routes(cfg: &mut ServiceConfig) {
1056    cfg.route(
1057        "/{exercise_id}/submissions",
1058        web::get().to(get_exercise_submissions),
1059    )
1060    .route(
1061        "/{exercise_id}/csv-export-task-options",
1062        web::get().to(get_exercise_csv_export_task_options),
1063    )
1064    .route(
1065        "/{exercise_id}/export-definitions-csv",
1066        web::get().to(export_exercise_task_definitions_csv),
1067    )
1068    .route(
1069        "/{exercise_id}/export-answers-csv",
1070        web::get().to(export_exercise_task_answers_csv),
1071    )
1072    .route(
1073        "/{exercise_id}/answers-requiring-attention",
1074        web::get().to(get_exercise_answers_requiring_attention),
1075    )
1076    .route(
1077        "/{course_id}/exercises-by-course-id",
1078        web::get().to(get_exercises_by_course_id),
1079    )
1080    .route(
1081        "/{course_id}/reset-exercises-for-selected-users",
1082        web::post().to(reset_exercises_for_selected_users),
1083    )
1084    .route("/{exercise_id}", web::get().to(get_exercise))
1085    .route(
1086        "/{exercise_id}/submissions/user/{user_id}",
1087        web::get().to(get_exercise_submissions_for_user),
1088    );
1089}