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