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
105 course_exams::upsert(&mut conn, *exam_id, exam.course_id).await?;
106
107 token.authorized_ok(web::Json(()))
108}
109
110#[utoipa::path(
114 post,
115 path = "/{id}/unset",
116 operation_id = "unsetExamCourse",
117 tag = "exams",
118 params(
119 ("id" = Uuid, Path, description = "Exam id")
120 ),
121 request_body = ExamCourseInfo,
122 responses(
123 (status = 200, description = "Course unset from exam")
124 )
125)]
126#[instrument(skip(pool))]
127pub async fn unset_course(
128 pool: web::Data<PgPool>,
129 exam_id: web::Path<Uuid>,
130 exam: web::Json<ExamCourseInfo>,
131 user: AuthUser,
132) -> ControllerResult<web::Json<()>> {
133 let mut conn = pool.acquire().await?;
134 let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
135
136 course_exams::delete(&mut conn, *exam_id, exam.course_id).await?;
137
138 token.authorized_ok(web::Json(()))
139}
140
141#[utoipa::path(
145 get,
146 path = "/{id}/export-points",
147 operation_id = "exportExamPointsCsv",
148 tag = "exams",
149 params(
150 ("id" = Uuid, Path, description = "Exam id")
151 ),
152 responses(
153 (status = 200, description = "Exam points CSV export", body = String, content_type = "text/csv")
154 )
155)]
156#[instrument(skip(pool))]
157pub async fn export_points(
158 exam_id: web::Path<Uuid>,
159 pool: web::Data<PgPool>,
160 user: AuthUser,
161) -> ControllerResult<HttpResponse> {
162 let mut conn = pool.acquire().await?;
163 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
164
165 let exam = exams::get(&mut conn, *exam_id).await?;
166
167 general_export(
168 pool,
169 &format!(
170 "attachment; filename=\"Exam: {} - Point export {}.csv\"",
171 exam.name,
172 Utc::now().format("%Y-%m-%d")
173 ),
174 ExamPointExportOperation { exam_id: *exam_id },
175 token,
176 )
177 .await
178}
179
180#[utoipa::path(
184 get,
185 path = "/{id}/export-submissions",
186 operation_id = "exportExamSubmissionsCsv",
187 tag = "exams",
188 params(
189 ("id" = Uuid, Path, description = "Exam id")
190 ),
191 responses(
192 (status = 200, description = "Exam submissions CSV export", body = String, content_type = "text/csv")
193 )
194)]
195#[instrument(skip(pool))]
196pub async fn export_submissions(
197 exam_id: web::Path<Uuid>,
198 pool: web::Data<PgPool>,
199 user: AuthUser,
200) -> ControllerResult<HttpResponse> {
201 let mut conn = pool.acquire().await?;
202 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
203
204 let exam = exams::get(&mut conn, *exam_id).await?;
205
206 general_export(
207 pool,
208 &format!(
209 "attachment; filename=\"Exam: {} - Submissions {}.csv\"",
210 exam.name,
211 Utc::now().format("%Y-%m-%d")
212 ),
213 ExamSubmissionExportOperation { exam_id: *exam_id },
214 token,
215 )
216 .await
217}
218
219#[utoipa::path(
223 post,
224 path = "/{id}/duplicate",
225 operation_id = "duplicateExam",
226 tag = "exams",
227 params(
228 ("id" = Uuid, Path, description = "Exam id")
229 ),
230 request_body = NewExam,
231 responses(
232 (status = 200, description = "Exam duplicated", body = bool)
233 )
234)]
235#[instrument(skip(pool))]
236async fn duplicate_exam(
237 pool: web::Data<PgPool>,
238 exam_id: web::Path<Uuid>,
239 new_exam: web::Json<NewExam>,
240 user: AuthUser,
241) -> ControllerResult<web::Json<bool>> {
242 let mut conn = pool.acquire().await?;
243 let organization_id = models::exams::get_organization_id(&mut conn, *exam_id).await?;
244 let token = authorize(
245 &mut conn,
246 Act::CreateCoursesOrExams,
247 Some(user.id),
248 Res::Organization(organization_id),
249 )
250 .await?;
251
252 let mut tx = conn.begin().await?;
253 let new_exam = models::library::copying::copy_exam(&mut tx, &exam_id, &new_exam).await?;
254
255 models::roles::insert(
256 &mut tx,
257 user.id,
258 models::roles::UserRole::Teacher,
259 models::roles::RoleDomain::Exam(new_exam.id),
260 )
261 .await?;
262 tx.commit().await?;
263
264 token.authorized_ok(web::Json(true))
265}
266
267#[utoipa::path(
271 post,
272 path = "/{id}/edit-exam",
273 operation_id = "editExam",
274 tag = "exams",
275 params(
276 ("id" = Uuid, Path, description = "Exam id")
277 ),
278 request_body = NewExam,
279 responses(
280 (status = 200, description = "Exam edited")
281 )
282)]
283#[instrument(skip(pool))]
284async fn edit_exam(
285 pool: web::Data<PgPool>,
286 exam_id: web::Path<Uuid>,
287 payload: web::Json<NewExam>,
288 user: AuthUser,
289) -> ControllerResult<web::Json<()>> {
290 let mut conn = pool.acquire().await?;
291 let mut tx = conn.begin().await?;
292
293 let exam = payload.0;
294 let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
295
296 models::exams::edit(&mut tx, *exam_id, exam).await?;
297
298 tx.commit().await?;
299
300 token.authorized_ok(web::Json(()))
301}
302
303#[utoipa::path(
307 get,
308 path = "/{exercise_id}/submissions-with-exercise-id",
309 operation_id = "getExamSubmissionsWithExerciseId",
310 tag = "exams",
311 params(
312 ("exercise_id" = Uuid, Path, description = "Exercise id"),
313 ("page" = Option<u32>, Query, description = "Page number"),
314 ("limit" = Option<u32>, Query, description = "Page size")
315 ),
316 responses(
317 (status = 200, description = "Exercise submissions with exercise id", body = ExerciseSlideSubmissionAndUserExerciseStateList)
318 )
319)]
320#[instrument(skip(pool))]
321async fn get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id(
322 pool: web::Data<PgPool>,
323 exercise_id: web::Path<Uuid>,
324 pagination: web::Query<Pagination>,
325 user: AuthUser,
326) -> ControllerResult<web::Json<ExerciseSlideSubmissionAndUserExerciseStateList>> {
327 let mut conn = pool.acquire().await?;
328
329 let token = authorize(
330 &mut conn,
331 Act::Teach,
332 Some(user.id),
333 Res::Exercise(*exercise_id),
334 )
335 .await?;
336
337 let submission_count =
338 models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_id(
339 &mut conn,
340 *exercise_id,
341 );
342 let mut conn = pool.acquire().await?;
343 let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
344 &mut conn,
345 *exercise_id,
346 *pagination,
347 );
348 let (submission_count, submissions) = future::try_join(submission_count, submissions).await?;
349 let total_pages = pagination.total_pages(submission_count);
350
351 token.authorized_ok(web::Json(ExerciseSlideSubmissionAndUserExerciseStateList {
352 data: submissions,
353 total_pages,
354 }))
355}
356
357#[utoipa::path(
361 get,
362 path = "/{exam_id}/submissions-with-exam-id",
363 operation_id = "getExamSubmissionsWithExamId",
364 tag = "exams",
365 params(
366 ("exam_id" = Uuid, Path, description = "Exam id"),
367 ("page" = Option<u32>, Query, description = "Page number"),
368 ("limit" = Option<u32>, Query, description = "Page size")
369 ),
370 responses(
371 (status = 200, description = "Exercise submissions with exam id", body = Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>)
372 )
373)]
374#[instrument(skip(pool))]
375async fn get_exercise_slide_submissions_and_user_exercise_states_with_exam_id(
376 pool: web::Data<PgPool>,
377 exam_id: web::Path<Uuid>,
378 pagination: web::Query<Pagination>,
379 user: AuthUser,
380) -> ControllerResult<web::Json<Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>>> {
381 let mut conn = pool.acquire().await?;
382
383 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
384
385 let mut submissions_and_user_exercise_states: Vec<
386 Vec<ExerciseSlideSubmissionAndUserExerciseState>,
387 > = Vec::new();
388
389 let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
390
391 let mut conn = pool.acquire().await?;
392 for exercise in exercises.iter() {
393 let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
394 &mut conn,
395 exercise.id,
396 *pagination,
397 ).await?;
398 submissions_and_user_exercise_states.push(submissions)
399 }
400
401 token.authorized_ok(web::Json(submissions_and_user_exercise_states))
402}
403
404#[utoipa::path(
408 get,
409 path = "/{exam_id}/exam-exercises",
410 operation_id = "getExamExercises",
411 tag = "exams",
412 params(
413 ("exam_id" = Uuid, Path, description = "Exam id")
414 ),
415 responses(
416 (status = 200, description = "Exam exercises", body = Vec<Exercise>)
417 )
418)]
419#[instrument(skip(pool))]
420async fn get_exercises_with_exam_id(
421 pool: web::Data<PgPool>,
422 exam_id: web::Path<Uuid>,
423 user: AuthUser,
424) -> ControllerResult<web::Json<Vec<Exercise>>> {
425 let mut conn = pool.acquire().await?;
426 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
427
428 let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
429
430 token.authorized_ok(web::Json(exercises))
431}
432
433#[utoipa::path(
437 post,
438 path = "/{exam_id}/release-grades",
439 operation_id = "releaseExamGrades",
440 tag = "exams",
441 params(
442 ("exam_id" = Uuid, Path, description = "Exam id")
443 ),
444 request_body = Vec<Uuid>,
445 responses(
446 (status = 200, description = "Exam grades released")
447 )
448)]
449#[instrument(skip(pool))]
450async fn release_grades(
451 pool: web::Data<PgPool>,
452 exam_id: web::Path<Uuid>,
453 user: AuthUser,
454 payload: web::Json<Vec<Uuid>>,
455) -> ControllerResult<web::Json<()>> {
456 let mut conn = pool.acquire().await?;
457 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
458
459 let teacher_grading_decision_ids = payload.0;
460
461 let teacher_grading_decisions =
462 models::teacher_grading_decisions::get_by_ids(&mut conn, &teacher_grading_decision_ids)
463 .await?;
464
465 let user_exercise_state_mapping = models::user_exercise_states::get_by_ids(
466 &mut conn,
467 &teacher_grading_decisions
468 .iter()
469 .map(|x| x.user_exercise_state_id)
470 .collect::<Vec<Uuid>>(),
471 )
472 .await?
473 .into_iter()
474 .map(|x| (x.id, x))
475 .collect::<HashMap<Uuid, UserExerciseState>>();
476
477 let mut tx = conn.begin().await?;
478 for teacher_grading_decision in teacher_grading_decisions.iter() {
479 let user_exercise_state = user_exercise_state_mapping
480 .get(&teacher_grading_decision.user_exercise_state_id)
481 .ok_or_else(|| {
482 ControllerError::new(
483 ControllerErrorType::InternalServerError,
484 "User exercise state not found for a teacher grading decision",
485 None,
486 )
487 })?;
488
489 if user_exercise_state.exam_id != Some(*exam_id) {
490 return Err(ControllerError::new(
491 ControllerErrorType::BadRequest,
492 "Teacher grading decision does not belong to the specified exam.",
493 None,
494 ));
495 }
496
497 teacher_grading_decisions::update_teacher_grading_decision_hidden_field(
498 &mut tx,
499 teacher_grading_decision.id,
500 false,
501 )
502 .await?;
503 user_exercise_state_updater::update_user_exercise_state(&mut tx, user_exercise_state.id)
504 .await?;
505 }
506
507 tx.commit().await?;
508
509 token.authorized_ok(web::Json(()))
510}
511
512pub fn _add_routes(cfg: &mut ServiceConfig) {
520 cfg.route("/{id}", web::get().to(get_exam))
521 .route("/{id}/set", web::post().to(set_course))
522 .route("/{id}/unset", web::post().to(unset_course))
523 .route("/{id}/export-points", web::get().to(export_points))
524 .route(
525 "/{id}/export-submissions",
526 web::get().to(export_submissions),
527 )
528 .route("/{id}/edit-exam", web::post().to(edit_exam))
529 .route("/{id}/duplicate", web::post().to(duplicate_exam))
530 .route(
531 "/{exercise_id}/submissions-with-exercise-id",
532 web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id),
533 )
534 .route(
535 "/{exam_id}/submissions-with-exam-id",
536 web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exam_id),
537 )
538 .route("/{exam_id}/release-grades", web::post().to(release_grades))
539 .route(
540 "/{exam_id}/exam-exercises",
541 web::get().to(get_exercises_with_exam_id),
542 );
543}