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, file_store, app_conf))]
226pub async fn export_submissions(
227 exam_id: web::Path<Uuid>,
228 pool: web::Data<PgPool>,
229 user: AuthUser,
230 file_store: web::Data<dyn FileStore>,
231 app_conf: web::Data<ApplicationConfiguration>,
232) -> ControllerResult<HttpResponse> {
233 let mut conn = pool.acquire().await?;
234 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
235
236 let exam = exams::get(&mut conn, *exam_id).await?;
237
238 general_export(
239 pool,
240 &format!(
241 "attachment; filename=\"Exam: {} - Submissions {}.csv\"",
242 exam.name,
243 Utc::now().format("%Y-%m-%d")
244 ),
245 ExamSubmissionExportOperation {
246 exam_id: *exam_id,
247 file_store,
248 app_conf,
249 },
250 token,
251 )
252 .await
253}
254
255#[utoipa::path(
259 post,
260 path = "/{id}/duplicate",
261 operation_id = "duplicateExam",
262 tag = "exams",
263 params(
264 ("id" = Uuid, Path, description = "Exam id")
265 ),
266 request_body = NewExam,
267 responses(
268 (status = 200, description = "Exam duplicated", body = bool)
269 )
270)]
271#[instrument(skip(pool))]
272async fn duplicate_exam(
273 pool: web::Data<PgPool>,
274 exam_id: web::Path<Uuid>,
275 new_exam: web::Json<NewExam>,
276 user: AuthUser,
277) -> ControllerResult<web::Json<bool>> {
278 let mut conn = pool.acquire().await?;
279 let organization_id = models::exams::get_organization_id(&mut conn, *exam_id).await?;
280 let token = authorize(
281 &mut conn,
282 Act::CreateCoursesOrExams,
283 Some(user.id),
284 Res::Organization(organization_id),
285 )
286 .await?;
287
288 let mut tx = conn.begin().await?;
289 let new_exam = models::library::copying::copy_exam(&mut tx, &exam_id, &new_exam).await?;
290
291 models::roles::insert(
292 &mut tx,
293 user.id,
294 models::roles::UserRole::Teacher,
295 models::roles::RoleDomain::Exam(new_exam.id),
296 )
297 .await?;
298 tx.commit().await?;
299
300 token.authorized_ok(web::Json(true))
301}
302
303#[utoipa::path(
307 post,
308 path = "/{id}/edit-exam",
309 operation_id = "editExam",
310 tag = "exams",
311 params(
312 ("id" = Uuid, Path, description = "Exam id")
313 ),
314 request_body = NewExam,
315 responses(
316 (status = 200, description = "Exam edited")
317 )
318)]
319#[instrument(skip(pool))]
320async fn edit_exam(
321 pool: web::Data<PgPool>,
322 exam_id: web::Path<Uuid>,
323 payload: web::Json<NewExam>,
324 user: AuthUser,
325) -> ControllerResult<web::Json<()>> {
326 let mut conn = pool.acquire().await?;
327 let mut tx = conn.begin().await?;
328
329 let exam = payload.0;
330 let token = authorize(&mut tx, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
331
332 models::exams::edit(&mut tx, *exam_id, exam).await?;
333
334 tx.commit().await?;
335
336 token.authorized_ok(web::Json(()))
337}
338
339#[utoipa::path(
343 get,
344 path = "/{exercise_id}/submissions-with-exercise-id",
345 operation_id = "getExamSubmissionsWithExerciseId",
346 tag = "exams",
347 params(
348 ("exercise_id" = Uuid, Path, description = "Exercise id"),
349 ("page" = Option<u32>, Query, description = "Page number"),
350 ("limit" = Option<u32>, Query, description = "Page size")
351 ),
352 responses(
353 (status = 200, description = "Exercise submissions with exercise id", body = ExerciseSlideSubmissionAndUserExerciseStateList)
354 )
355)]
356#[instrument(skip(pool))]
357async fn get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id(
358 pool: web::Data<PgPool>,
359 exercise_id: web::Path<Uuid>,
360 pagination: web::Query<Pagination>,
361 user: AuthUser,
362) -> ControllerResult<web::Json<ExerciseSlideSubmissionAndUserExerciseStateList>> {
363 let mut conn = pool.acquire().await?;
364
365 let token = authorize(
366 &mut conn,
367 Act::Teach,
368 Some(user.id),
369 Res::Exercise(*exercise_id),
370 )
371 .await?;
372
373 let submission_count =
374 models::exercise_slide_submissions::exercise_slide_submission_count_with_exercise_id(
375 &mut conn,
376 *exercise_id,
377 );
378 let mut conn = pool.acquire().await?;
379 let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
380 &mut conn,
381 *exercise_id,
382 *pagination,
383 );
384 let (submission_count, submissions) = future::try_join(submission_count, submissions).await?;
385 let total_pages = pagination.total_pages(submission_count);
386
387 token.authorized_ok(web::Json(ExerciseSlideSubmissionAndUserExerciseStateList {
388 data: submissions,
389 total_pages,
390 }))
391}
392
393#[utoipa::path(
397 get,
398 path = "/{exam_id}/submissions-with-exam-id",
399 operation_id = "getExamSubmissionsWithExamId",
400 tag = "exams",
401 params(
402 ("exam_id" = Uuid, Path, description = "Exam id"),
403 ("page" = Option<u32>, Query, description = "Page number"),
404 ("limit" = Option<u32>, Query, description = "Page size")
405 ),
406 responses(
407 (status = 200, description = "Exercise submissions with exam id", body = Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>)
408 )
409)]
410#[instrument(skip(pool))]
411async fn get_exercise_slide_submissions_and_user_exercise_states_with_exam_id(
412 pool: web::Data<PgPool>,
413 exam_id: web::Path<Uuid>,
414 pagination: web::Query<Pagination>,
415 user: AuthUser,
416) -> ControllerResult<web::Json<Vec<Vec<ExerciseSlideSubmissionAndUserExerciseState>>>> {
417 let mut conn = pool.acquire().await?;
418
419 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
420
421 let mut submissions_and_user_exercise_states: Vec<
422 Vec<ExerciseSlideSubmissionAndUserExerciseState>,
423 > = Vec::new();
424
425 let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
426
427 let mut conn = pool.acquire().await?;
428 for exercise in exercises.iter() {
429 let submissions = models::exercise_slide_submissions::get_latest_exercise_slide_submissions_and_user_exercise_state_list_with_exercise_id(
430 &mut conn,
431 exercise.id,
432 *pagination,
433 ).await?;
434 submissions_and_user_exercise_states.push(submissions)
435 }
436
437 token.authorized_ok(web::Json(submissions_and_user_exercise_states))
438}
439
440#[utoipa::path(
444 get,
445 path = "/{exam_id}/exam-exercises",
446 operation_id = "getExamExercises",
447 tag = "exams",
448 params(
449 ("exam_id" = Uuid, Path, description = "Exam id")
450 ),
451 responses(
452 (status = 200, description = "Exam exercises", body = Vec<Exercise>)
453 )
454)]
455#[instrument(skip(pool))]
456async fn get_exercises_with_exam_id(
457 pool: web::Data<PgPool>,
458 exam_id: web::Path<Uuid>,
459 user: AuthUser,
460) -> ControllerResult<web::Json<Vec<Exercise>>> {
461 let mut conn = pool.acquire().await?;
462 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
463
464 let exercises = models::exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
465
466 token.authorized_ok(web::Json(exercises))
467}
468
469#[utoipa::path(
473 post,
474 path = "/{exam_id}/release-grades",
475 operation_id = "releaseExamGrades",
476 tag = "exams",
477 params(
478 ("exam_id" = Uuid, Path, description = "Exam id")
479 ),
480 request_body = Vec<Uuid>,
481 responses(
482 (status = 200, description = "Exam grades released")
483 )
484)]
485#[instrument(skip(pool))]
486async fn release_grades(
487 pool: web::Data<PgPool>,
488 exam_id: web::Path<Uuid>,
489 user: AuthUser,
490 payload: web::Json<Vec<Uuid>>,
491) -> ControllerResult<web::Json<()>> {
492 let mut conn = pool.acquire().await?;
493 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
494
495 let teacher_grading_decision_ids = payload.0;
496
497 let teacher_grading_decisions =
498 models::teacher_grading_decisions::get_by_ids(&mut conn, &teacher_grading_decision_ids)
499 .await?;
500
501 let user_exercise_state_mapping = models::user_exercise_states::get_by_ids(
502 &mut conn,
503 &teacher_grading_decisions
504 .iter()
505 .map(|x| x.user_exercise_state_id)
506 .collect::<Vec<Uuid>>(),
507 )
508 .await?
509 .into_iter()
510 .map(|x| (x.id, x))
511 .collect::<HashMap<Uuid, UserExerciseState>>();
512
513 let mut tx = conn.begin().await?;
514 for teacher_grading_decision in teacher_grading_decisions.iter() {
515 let user_exercise_state = user_exercise_state_mapping
516 .get(&teacher_grading_decision.user_exercise_state_id)
517 .ok_or_else(|| {
518 ControllerError::new(
519 ControllerErrorType::InternalServerError,
520 "User exercise state not found for a teacher grading decision",
521 None,
522 )
523 })?;
524
525 if user_exercise_state.exam_id != Some(*exam_id) {
526 return Err(ControllerError::new(
527 ControllerErrorType::BadRequest,
528 "Teacher grading decision does not belong to the specified exam.",
529 None,
530 ));
531 }
532
533 teacher_grading_decisions::update_teacher_grading_decision_hidden_field(
534 &mut tx,
535 teacher_grading_decision.id,
536 false,
537 )
538 .await?;
539 user_exercise_state_updater::update_user_exercise_state(&mut tx, user_exercise_state.id)
540 .await?;
541 }
542
543 tx.commit().await?;
544
545 token.authorized_ok(web::Json(()))
546}
547
548pub fn _add_routes(cfg: &mut ServiceConfig) {
556 cfg.route("/{id}", web::get().to(get_exam))
557 .route("/{id}/set", web::post().to(set_course))
558 .route("/{id}/unset", web::post().to(unset_course))
559 .route("/{id}/export-points", web::get().to(export_points))
560 .route(
561 "/{id}/export-submissions",
562 web::get().to(export_submissions),
563 )
564 .route("/{id}/edit-exam", web::post().to(edit_exam))
565 .route("/{id}/duplicate", web::post().to(duplicate_exam))
566 .route(
567 "/{exercise_id}/submissions-with-exercise-id",
568 web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exercise_id),
569 )
570 .route(
571 "/{exam_id}/submissions-with-exam-id",
572 web::get().to(get_exercise_slide_submissions_and_user_exercise_states_with_exam_id),
573 )
574 .route("/{exam_id}/release-grades", web::post().to(release_grades))
575 .route(
576 "/{exam_id}/exam-exercises",
577 web::get().to(get_exercises_with_exam_id),
578 );
579}