1use std::collections::{HashMap, HashSet};
4
5use futures::future;
6use serde_json::Value;
7use url::Url;
8
9use headless_lms_models::exercises::Exercise;
10use models::{
11 exercise_service_info::ExerciseServiceInfoApi, exercise_services::ExerciseService,
12 exercise_slide_submissions::ExerciseSlideSubmission,
13 exercise_task_gradings::ExerciseTaskGrading, exercise_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
88fn csv_endpoint_is_supported(path: &Option<String>) -> bool {
90 path.as_ref().is_some_and(|value| !value.trim().is_empty())
91}
92
93fn 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
115async 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
144fn 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
156fn 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
174fn 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
229fn 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
238fn 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
253fn 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
328fn 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#[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#[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#[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#[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#[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#[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#[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#[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#[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 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 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
1048pub 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}