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