1use std::collections::HashMap;
2
3use futures::future;
4
5use chrono::Utc;
6use headless_lms_models::user_exercise_states::UserExerciseState;
7use models::{
8 course_exams,
9 exams::{self, Exam, NewExam},
10 exercise_slide_submissions::{
11 ExerciseSlideSubmissionAndUserExerciseState,
12 ExerciseSlideSubmissionAndUserExerciseStateList,
13 },
14 exercises::Exercise,
15 library::user_exercise_state_updater,
16 teacher_grading_decisions,
17};
18use utoipa::{OpenApi, ToSchema};
19
20use crate::{
21 domain::csv_export::{
22 general_export, points::ExamPointExportOperation,
23 submissions::ExamSubmissionExportOperation,
24 },
25 prelude::*,
26};
27
28#[derive(OpenApi)]
29#[openapi(paths(
30 get_exam,
31 set_course,
32 unset_course,
33 export_points,
34 export_submissions,
35 edit_exam,
36 duplicate_exam,
37 get_exercise_slide_submissions_and_user_exercise_states_with_exam_id,
38 get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id,
39 release_grades,
40 get_exercises_with_exam_id
41))]
42pub(crate) struct MainFrontendExamsApiDoc;
43
44#[utoipa::path(
48 get,
49 path = "/{id}",
50 operation_id = "getExam",
51 tag = "exams",
52 params(
53 ("id" = Uuid, Path, description = "Exam id")
54 ),
55 responses(
56 (status = 200, description = "Exam", body = Exam)
57 )
58)]
59#[instrument(skip(pool))]
60pub async fn get_exam(
61 pool: web::Data<PgPool>,
62 exam_id: web::Path<Uuid>,
63 user: AuthUser,
64) -> ControllerResult<web::Json<Exam>> {
65 let mut conn = pool.acquire().await?;
66 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
67
68 let exam = exams::get(&mut conn, *exam_id).await?;
69
70 token.authorized_ok(web::Json(exam))
71}
72
73#[derive(Debug, Deserialize, ToSchema)]
74
75pub struct ExamCourseInfo {
76 course_id: Uuid,
77}
78
79#[utoipa::path(
83 post,
84 path = "/{id}/set",
85 operation_id = "setExamCourse",
86 tag = "exams",
87 params(
88 ("id" = Uuid, Path, description = "Exam id")
89 ),
90 request_body = ExamCourseInfo,
91 responses(
92 (status = 200, description = "Course set for exam")
93 )
94)]
95#[instrument(skip(pool))]
96pub async fn set_course(
97 pool: web::Data<PgPool>,
98 exam_id: web::Path<Uuid>,
99 exam: web::Json<ExamCourseInfo>,
100 user: AuthUser,
101) -> ControllerResult<web::Json<()>> {
102 let mut conn = pool.acquire().await?;
103 let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
104 let exam_identity = exams::get_identity_by_id(&mut conn, *exam_id).await?;
105 let course = models::courses::get_course(&mut conn, exam.course_id).await?;
106 if exam_identity.organization_id != course.organization_id {
107 return Err(controller_err!(
108 Forbidden,
109 "Course does not belong to the same organization as the exam".to_string()
110 ));
111 }
112 authorize(
113 &mut conn,
114 Act::Edit,
115 Some(user.id),
116 Res::Course(exam.course_id),
117 )
118 .await?;
119
120 course_exams::upsert(&mut conn, *exam_id, exam.course_id).await?;
121
122 token.authorized_ok(web::Json(()))
123}
124
125#[utoipa::path(
129 post,
130 path = "/{id}/unset",
131 operation_id = "unsetExamCourse",
132 tag = "exams",
133 params(
134 ("id" = Uuid, Path, description = "Exam id")
135 ),
136 request_body = ExamCourseInfo,
137 responses(
138 (status = 200, description = "Course unset from exam")
139 )
140)]
141#[instrument(skip(pool))]
142pub async fn unset_course(
143 pool: web::Data<PgPool>,
144 exam_id: web::Path<Uuid>,
145 exam: web::Json<ExamCourseInfo>,
146 user: AuthUser,
147) -> ControllerResult<web::Json<()>> {
148 let mut conn = pool.acquire().await?;
149 let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
150 let exam_identity = exams::get_identity_by_id(&mut conn, *exam_id).await?;
151 let course = models::courses::get_course(&mut conn, exam.course_id).await?;
152 if exam_identity.organization_id != course.organization_id {
153 return Err(controller_err!(
154 Forbidden,
155 "Course does not belong to the same organization as the exam".to_string()
156 ));
157 }
158 authorize(
159 &mut conn,
160 Act::Edit,
161 Some(user.id),
162 Res::Course(exam.course_id),
163 )
164 .await?;
165
166 course_exams::delete(&mut conn, *exam_id, exam.course_id).await?;
167
168 token.authorized_ok(web::Json(()))
169}
170
171#[utoipa::path(
175 get,
176 path = "/{id}/export-points",
177 operation_id = "exportExamPointsCsv",
178 tag = "exams",
179 params(
180 ("id" = Uuid, Path, description = "Exam id")
181 ),
182 responses(
183 (status = 200, description = "Exam points CSV export", body = String, content_type = "text/csv")
184 )
185)]
186#[instrument(skip(pool))]
187pub async fn export_points(
188 exam_id: web::Path<Uuid>,
189 pool: web::Data<PgPool>,
190 user: AuthUser,
191) -> ControllerResult<HttpResponse> {
192 let mut conn = pool.acquire().await?;
193 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
194
195 let exam = exams::get(&mut conn, *exam_id).await?;
196
197 general_export(
198 pool,
199 &format!(
200 "attachment; filename=\"Exam: {} - Point export {}.csv\"",
201 exam.name,
202 Utc::now().format("%Y-%m-%d")
203 ),
204 ExamPointExportOperation { exam_id: *exam_id },
205 token,
206 )
207 .await
208}
209
210#[utoipa::path(
214 get,
215 path = "/{id}/export-submissions",
216 operation_id = "exportExamSubmissionsCsv",
217 tag = "exams",
218 params(
219 ("id" = Uuid, Path, description = "Exam id")
220 ),
221 responses(
222 (status = 200, description = "Exam submissions CSV export", body = String, content_type = "text/csv")
223 )
224)]
225#[instrument(skip(pool))]
226pub async fn export_submissions(
227 exam_id: web::Path<Uuid>,
228 pool: web::Data<PgPool>,
229 user: AuthUser,
230) -> ControllerResult<HttpResponse> {
231 let mut conn = pool.acquire().await?;
232 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
233
234 let exam = exams::get(&mut conn, *exam_id).await?;
235
236 general_export(
237 pool,
238 &format!(
239 "attachment; filename=\"Exam: {} - Submissions {}.csv\"",
240 exam.name,
241 Utc::now().format("%Y-%m-%d")
242 ),
243 ExamSubmissionExportOperation { exam_id: *exam_id },
244 token,
245 )
246 .await
247}
248
249#[utoipa::path(
253 post,
254 path = "/{id}/duplicate",
255 operation_id = "duplicateExam",
256 tag = "exams",
257 params(
258 ("id" = Uuid, Path, description = "Exam id")
259 ),
260 request_body = NewExam,
261 responses(
262 (status = 200, description = "Exam duplicated", body = bool)
263 )
264)]
265#[instrument(skip(pool))]
266async fn duplicate_exam(
267 pool: web::Data<PgPool>,
268 exam_id: web::Path<Uuid>,
269 new_exam: web::Json<NewExam>,
270 user: AuthUser,
271) -> ControllerResult<web::Json<bool>> {
272 let mut conn = pool.acquire().await?;
273 let organization_id = models::exams::get_organization_id(&mut conn, *exam_id).await?;
274 let token = authorize(
275 &mut conn,
276 Act::CreateCoursesOrExams,
277 Some(user.id),
278 Res::Organization(organization_id),
279 )
280 .await?;
281
282 let mut tx = conn.begin().await?;
283 let new_exam = models::library::copying::copy_exam(&mut tx, &exam_id, &new_exam).await?;
284
285 models::roles::insert(
286 &mut tx,
287 user.id,
288 models::roles::UserRole::Teacher,
289 models::roles::RoleDomain::Exam(new_exam.id),
290 )
291 .await?;
292 tx.commit().await?;
293
294 token.authorized_ok(web::Json(true))
295}
296
297#[utoipa::path(
301 post,
302 path = "/{id}/edit-exam",
303 operation_id = "editExam",
304 tag = "exams",
305 params(
306 ("id" = Uuid, Path, description = "Exam id")
307 ),
308 request_body = NewExam,
309 responses(
310 (status = 200, description = "Exam edited")
311 )
312)]
313#[instrument(skip(pool))]
314async fn edit_exam(
315 pool: web::Data<PgPool>,
316 exam_id: web::Path<Uuid>,
317 payload: web::Json<NewExam>,
318 user: AuthUser,
319) -> ControllerResult<web::Json<()>> {
320 let mut conn = pool.acquire().await?;
321 let mut tx = conn.begin().await?;
322
323 let exam = payload.0;
324 let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
325
326 models::exams::edit(&mut tx, *exam_id, exam).await?;
327
328 tx.commit().await?;
329
330 token.authorized_ok(web::Json(()))
331}
332
333#[utoipa::path(
337 get,
338 path = "/{exercise_id}/submissions-with-exercise-id",
339 operation_id = "getExamSubmissionsWithExerciseId",
340 tag = "exams",
341 params(
342 ("exercise_id" = Uuid, Path, description = "Exercise id"),
343 ("page" = Option<u32>, Query, description = "Page number"),
344 ("limit" = Option<u32>, Query, description = "Page size")
345 ),
346 responses(
347 (status = 200, description = "Exercise submissions with exercise id", body = ExerciseSlideSubmissionAndUserExerciseStateList)
348 )
349)]
350#[instrument(skip(pool))]
351async fn get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id(
352 pool: web::Data<PgPool>,
353 exercise_id: web::Path<Uuid>,
354 pagination: web::Query<Pagination>,
355 user: AuthUser,
356) -> ControllerResult<web::Json<ExerciseSlideSubmissionAndUserExerciseStateList>> {
357 let mut conn = pool.acquire().await?;
358
359 let token = authorize(
360 &mut conn,
361 Act::Teach,
362 Some(user.id),
363 Res::Exercise(*exercise_id),
364 )
365 .await?;
366
367 let submission_count =
368 models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_id(
369 &mut conn,
370 *exercise_id,
371 );
372 let mut conn = pool.acquire().await?;
373 let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
374 &mut conn,
375 *exercise_id,
376 *pagination,
377 );
378 let (submission_count, submissions) = future::try_join(submission_count, submissions).await?;
379 let total_pages = pagination.total_pages(submission_count);
380
381 token.authorized_ok(web::Json(ExerciseSlideSubmissionAndUserExerciseStateList {
382 data: submissions,
383 total_pages,
384 }))
385}
386
387#[utoipa::path(
391 get,
392 path = "/{exam_id}/submissions-with-exam-id",
393 operation_id = "getExamSubmissionsWithExamId",
394 tag = "exams",
395 params(
396 ("exam_id" = Uuid, Path, description = "Exam id"),
397 ("page" = Option<u32>, Query, description = "Page number"),
398 ("limit" = Option<u32>, Query, description = "Page size")
399 ),
400 responses(
401 (status = 200, description = "Exercise submissions with exam id", body = Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>)
402 )
403)]
404#[instrument(skip(pool))]
405async fn get_exercise_slide_submissions_and_user_exercise_states_with_exam_id(
406 pool: web::Data<PgPool>,
407 exam_id: web::Path<Uuid>,
408 pagination: web::Query<Pagination>,
409 user: AuthUser,
410) -> ControllerResult<web::Json<Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>>> {
411 let mut conn = pool.acquire().await?;
412
413 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
414
415 let mut submissions_and_user_exercise_states: Vec<
416 Vec<ExerciseSlideSubmissionAndUserExerciseState>,
417 > = Vec::new();
418
419 let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
420
421 let mut conn = pool.acquire().await?;
422 for exercise in exercises.iter() {
423 let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
424 &mut conn,
425 exercise.id,
426 *pagination,
427 ).await?;
428 submissions_and_user_exercise_states.push(submissions)
429 }
430
431 token.authorized_ok(web::Json(submissions_and_user_exercise_states))
432}
433
434#[utoipa::path(
438 get,
439 path = "/{exam_id}/exam-exercises",
440 operation_id = "getExamExercises",
441 tag = "exams",
442 params(
443 ("exam_id" = Uuid, Path, description = "Exam id")
444 ),
445 responses(
446 (status = 200, description = "Exam exercises", body = Vec<Exercise>)
447 )
448)]
449#[instrument(skip(pool))]
450async fn get_exercises_with_exam_id(
451 pool: web::Data<PgPool>,
452 exam_id: web::Path<Uuid>,
453 user: AuthUser,
454) -> ControllerResult<web::Json<Vec<Exercise>>> {
455 let mut conn = pool.acquire().await?;
456 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
457
458 let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
459
460 token.authorized_ok(web::Json(exercises))
461}
462
463#[utoipa::path(
467 post,
468 path = "/{exam_id}/release-grades",
469 operation_id = "releaseExamGrades",
470 tag = "exams",
471 params(
472 ("exam_id" = Uuid, Path, description = "Exam id")
473 ),
474 request_body = Vec<Uuid>,
475 responses(
476 (status = 200, description = "Exam grades released")
477 )
478)]
479#[instrument(skip(pool))]
480async fn release_grades(
481 pool: web::Data<PgPool>,
482 exam_id: web::Path<Uuid>,
483 user: AuthUser,
484 payload: web::Json<Vec<Uuid>>,
485) -> ControllerResult<web::Json<()>> {
486 let mut conn = pool.acquire().await?;
487 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
488
489 let teacher_grading_decision_ids = payload.0;
490
491 let teacher_grading_decisions =
492 models::teacher_grading_decisions::get_by_ids(&mut conn, &teacher_grading_decision_ids)
493 .await?;
494
495 let user_exercise_state_mapping = models::user_exercise_states::get_by_ids(
496 &mut conn,
497 &teacher_grading_decisions
498 .iter()
499 .map(|x| x.user_exercise_state_id)
500 .collect::<Vec<Uuid>>(),
501 )
502 .await?
503 .into_iter()
504 .map(|x| (x.id, x))
505 .collect::<HashMap<Uuid, UserExerciseState>>();
506
507 let mut tx = conn.begin().await?;
508 for teacher_grading_decision in teacher_grading_decisions.iter() {
509 let user_exercise_state = user_exercise_state_mapping
510 .get(&teacher_grading_decision.user_exercise_state_id)
511 .ok_or_else(|| {
512 ControllerError::new(
513 ControllerErrorType::InternalServerError,
514 "User exercise state not found for a teacher grading decision",
515 None,
516 )
517 })?;
518
519 if user_exercise_state.exam_id != Some(*exam_id) {
520 return Err(ControllerError::new(
521 ControllerErrorType::BadRequest,
522 "Teacher grading decision does not belong to the specified exam.",
523 None,
524 ));
525 }
526
527 teacher_grading_decisions::update_teacher_grading_decision_hidden_field(
528 &mut tx,
529 teacher_grading_decision.id,
530 false,
531 )
532 .await?;
533 user_exercise_state_updater::update_user_exercise_state(&mut tx, user_exercise_state.id)
534 .await?;
535 }
536
537 tx.commit().await?;
538
539 token.authorized_ok(web::Json(()))
540}
541
542pub fn _add_routes(cfg: &mut ServiceConfig) {
550 cfg.route("/{id}", web::get().to(get_exam))
551 .route("/{id}/set", web::post().to(set_course))
552 .route("/{id}/unset", web::post().to(unset_course))
553 .route("/{id}/export-points", web::get().to(export_points))
554 .route(
555 "/{id}/export-submissions",
556 web::get().to(export_submissions),
557 )
558 .route("/{id}/edit-exam", web::post().to(edit_exam))
559 .route("/{id}/duplicate", web::post().to(duplicate_exam))
560 .route(
561 "/{exercise_id}/submissions-with-exercise-id",
562 web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id),
563 )
564 .route(
565 "/{exam_id}/submissions-with-exam-id",
566 web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exam_id),
567 )
568 .route("/{exam_id}/release-grades", web::post().to(release_grades))
569 .route(
570 "/{exam_id}/exam-exercises",
571 web::get().to(get_exercises_with_exam_id),
572 );
573}