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};
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
73fn csv_endpoint_is_supported(path: &Option<String>) -> bool {
75 path.as_ref().is_some_and(|value| !value.trim().is_empty())
76}
77
78fn 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
100async 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
129fn 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
141fn 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
159fn 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
214fn 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
223fn 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
238fn 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
313fn 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#[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#[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#[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#[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#[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#[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#[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
835pub 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
870pub 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 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 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
915pub 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}