1use chrono::{DateTime, Duration, Utc};
2use headless_lms_models::{CourseOrExamId, ModelError, ModelErrorType, exercises::Exercise};
3use models::{
4 exams::{self, ExamEnrollment},
5 exercises,
6 pages::{self, Page},
7 teacher_grading_decisions::{self, TeacherGradingDecision},
8 user_exercise_states,
9};
10use utoipa::{OpenApi, ToSchema};
11
12use crate::prelude::*;
13
14#[derive(OpenApi)]
15#[openapi(paths(
16 enrollment,
17 enroll,
18 fetch_exam_for_user,
19 fetch_exam_for_testing,
20 update_show_exercise_answers,
21 reset_exam_progress,
22 end_exam_time
23))]
24pub(crate) struct CourseMaterialExamsApiDoc;
25
26#[utoipa::path(
30 get,
31 path = "/{id}/enrollment",
32 operation_id = "fetchExamEnrollment",
33 tag = "course-material-exams",
34 params(
35 ("id" = Uuid, Path, description = "Exam id")
36 ),
37 responses(
38 (status = 200, description = "Exam enrollment", body = Option<ExamEnrollment>)
39 )
40)]
41#[instrument(skip(pool))]
42pub async fn enrollment(
43 pool: web::Data<PgPool>,
44 exam_id: web::Path<Uuid>,
45 user: AuthUser,
46) -> ControllerResult<web::Json<Option<ExamEnrollment>>> {
47 let mut conn = pool.acquire().await?;
48 let enrollment = exams::get_enrollment(&mut conn, *exam_id, user.id).await?;
49 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
50 token.authorized_ok(web::Json(enrollment))
51}
52
53#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
54
55pub struct IsTeacherTesting {
56 pub is_teacher_testing: bool,
57}
58#[utoipa::path(
62 post,
63 path = "/{id}/enroll",
64 operation_id = "enrollInExam",
65 tag = "course-material-exams",
66 params(
67 ("id" = Uuid, Path, description = "Exam id")
68 ),
69 request_body = IsTeacherTesting,
70 responses(
71 (status = 200, description = "Enrollment created", body = ())
72 )
73)]
74#[instrument(skip(pool))]
75pub async fn enroll(
76 pool: web::Data<PgPool>,
77 exam_id: web::Path<Uuid>,
78 user: AuthUser,
79 payload: web::Json<IsTeacherTesting>,
80) -> ControllerResult<web::Json<()>> {
81 let mut conn = pool.acquire().await?;
82 let exam = exams::get(&mut conn, *exam_id).await?;
83
84 if payload.is_teacher_testing {
86 exams::enroll(&mut conn, *exam_id, user.id, payload.is_teacher_testing).await?;
87 let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
88 return token.authorized_ok(web::Json(()));
89 }
90
91 let now = Utc::now();
93 if exam.ended_at_or(now, false) {
94 return Err(ControllerError::new(
95 ControllerErrorType::Forbidden,
96 "Exam is over".to_string(),
97 None,
98 ));
99 }
100
101 if exam.started_at_or(now, false) {
102 let can_start =
105 models::library::progressing::user_can_take_exam(&mut conn, *exam_id, user.id).await?;
106 if !can_start {
107 return Err(ControllerError::new(
108 ControllerErrorType::Forbidden,
109 "User is not allowed to enroll to the exam.".to_string(),
110 None,
111 ));
112 }
113 exams::enroll(&mut conn, *exam_id, user.id, payload.is_teacher_testing).await?;
114 let token = skip_authorize();
115 return token.authorized_ok(web::Json(()));
116 }
117
118 Err(ControllerError::new(
120 ControllerErrorType::Forbidden,
121 "Exam has not started yet".to_string(),
122 None,
123 ))
124}
125
126#[derive(Debug, Serialize, ToSchema)]
127
128pub struct ExamData {
129 pub id: Uuid,
130 pub name: String,
131 pub instructions: serde_json::Value,
132 pub starts_at: DateTime<Utc>,
133 pub ends_at: DateTime<Utc>,
134 pub ended: bool,
135 pub time_minutes: i32,
136 pub enrollment_data: ExamEnrollmentData,
137 pub language: String,
138}
139
140#[derive(Debug, Serialize, ToSchema)]
141#[serde(tag = "tag")]
142pub enum ExamEnrollmentData {
143 EnrolledAndStarted {
145 page_id: Uuid,
146 page: Box<Page>,
147 enrollment: ExamEnrollment,
148 },
149 NotEnrolled { can_enroll: bool },
151 NotYetStarted,
153 StudentTimeUp,
155 StudentCanViewGrading {
157 gradings: Vec<(TeacherGradingDecision, Exercise)>,
158 enrollment: ExamEnrollment,
159 },
160}
161
162#[utoipa::path(
166 get,
167 path = "/{id}",
168 operation_id = "fetchExam",
169 tag = "course-material-exams",
170 params(
171 ("id" = Uuid, Path, description = "Exam id")
172 ),
173 responses(
174 (status = 200, description = "Exam data", body = ExamData)
175 )
176)]
177#[instrument(skip(pool))]
178pub async fn fetch_exam_for_user(
179 pool: web::Data<PgPool>,
180 exam_id: web::Path<Uuid>,
181 user: AuthUser,
182) -> ControllerResult<web::Json<ExamData>> {
183 let mut conn = pool.acquire().await?;
184 let exam = exams::get(&mut conn, *exam_id).await?;
185
186 let starts_at = if let Some(starts_at) = exam.starts_at {
187 starts_at
188 } else {
189 return Err(ControllerError::new(
190 ControllerErrorType::Forbidden,
191 "Cannot fetch exam that has no start time".to_string(),
192 None,
193 ));
194 };
195 let ends_at = if let Some(ends_at) = exam.ends_at {
196 ends_at
197 } else {
198 return Err(ControllerError::new(
199 ControllerErrorType::Forbidden,
200 "Cannot fetch exam that has no end time".to_string(),
201 None,
202 ));
203 };
204
205 let ended = ends_at < Utc::now();
206
207 if starts_at > Utc::now() {
208 let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
210 return token.authorized_ok(web::Json(ExamData {
211 id: exam.id,
212 name: exam.name,
213 instructions: exam.instructions,
214 starts_at,
215 ends_at,
216 ended,
217 time_minutes: exam.time_minutes,
218 enrollment_data: ExamEnrollmentData::NotYetStarted,
219 language: exam.language,
220 }));
221 }
222
223 let enrollment = match exams::get_enrollment(&mut conn, *exam_id, user.id).await? {
224 Some(enrollment) => {
225 if exam.grade_manually {
226 let teachers_grading_decisions_list =
228 teacher_grading_decisions::get_all_latest_grading_decisions_by_user_id_and_exam_id(
229 &mut conn, user.id, *exam_id,
230 )
231 .await?;
232 let teacher_grading_decisions = teachers_grading_decisions_list.clone();
233
234 let exam_exercises =
235 exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
236
237 let user_exercise_states =
238 user_exercise_states::get_all_for_user_and_course_or_exam(
239 &mut conn,
240 user.id,
241 CourseOrExamId::Exam(*exam_id),
242 )
243 .await?;
244
245 let mut grading_decision_and_exercise_list: Vec<(
246 TeacherGradingDecision,
247 Exercise,
248 )> = Vec::new();
249
250 for grading_decision in teachers_grading_decisions_list.into_iter() {
252 if let Some(hidden) = grading_decision.hidden
253 && !hidden
254 {
255 for grading in teacher_grading_decisions.into_iter() {
257 let user_exercise_state = user_exercise_states
258 .iter()
259 .find(|state| state.id == grading.user_exercise_state_id)
260 .ok_or_else(|| {
261 ModelError::new(
262 ModelErrorType::Generic,
263 "User_exercise_state not found",
264 None,
265 )
266 })?;
267
268 let exercise = exam_exercises
269 .iter()
270 .find(|exercise| exercise.id == user_exercise_state.exercise_id)
271 .ok_or_else(|| {
272 ModelError::new(
273 ModelErrorType::Generic,
274 "Exercise not found",
275 None,
276 )
277 })?;
278
279 grading_decision_and_exercise_list.push((grading, exercise.clone()));
280 }
281
282 let token =
283 authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id))
284 .await?;
285 return token.authorized_ok(web::Json(ExamData {
286 id: exam.id,
287 name: exam.name,
288 instructions: exam.instructions,
289 starts_at,
290 ends_at,
291 ended,
292 time_minutes: exam.time_minutes,
293 enrollment_data: ExamEnrollmentData::StudentCanViewGrading {
294 gradings: grading_decision_and_exercise_list,
295 enrollment,
296 },
297 language: exam.language,
298 }));
299 }
300 }
301 if enrollment.ended_at.is_some() {
303 let token: domain::authorization::AuthorizationToken =
304 authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
305 return token.authorized_ok(web::Json(ExamData {
306 id: exam.id,
307 name: exam.name,
308 instructions: exam.instructions,
309 starts_at,
310 ends_at,
311 ended,
312 time_minutes: exam.time_minutes,
313 enrollment_data: ExamEnrollmentData::StudentTimeUp,
314 language: exam.language,
315 }));
316 }
317 }
318
319 if Utc::now() < ends_at
321 && (Utc::now()
322 > enrollment.started_at + Duration::minutes(exam.time_minutes.into())
323 || enrollment.ended_at.is_some())
324 {
325 if enrollment.ended_at.is_none() {
327 exams::update_exam_ended_at(&mut conn, *exam_id, user.id, Utc::now()).await?;
328 }
329 let token: domain::authorization::AuthorizationToken =
330 authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
331 return token.authorized_ok(web::Json(ExamData {
332 id: exam.id,
333 name: exam.name,
334 instructions: exam.instructions,
335 starts_at,
336 ends_at,
337 ended,
338 time_minutes: exam.time_minutes,
339 enrollment_data: ExamEnrollmentData::StudentTimeUp,
340 language: exam.language,
341 }));
342 }
343 enrollment
344 }
345 _ => {
346 let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
348 let can_enroll =
349 models::library::progressing::user_can_take_exam(&mut conn, *exam_id, user.id)
350 .await?;
351 return token.authorized_ok(web::Json(ExamData {
352 id: exam.id,
353 name: exam.name,
354 instructions: exam.instructions,
355 starts_at,
356 ends_at,
357 ended,
358 time_minutes: exam.time_minutes,
359 enrollment_data: ExamEnrollmentData::NotEnrolled { can_enroll },
360 language: exam.language,
361 }));
362 }
363 };
364
365 let page = pages::get_page(&mut conn, exam.page_id).await?;
366
367 let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
368 token.authorized_ok(web::Json(ExamData {
369 id: exam.id,
370 name: exam.name,
371 instructions: exam.instructions,
372 starts_at,
373 ends_at,
374 ended,
375 time_minutes: exam.time_minutes,
376 enrollment_data: ExamEnrollmentData::EnrolledAndStarted {
377 page_id: exam.page_id,
378 page: Box::new(page),
379 enrollment,
380 },
381 language: exam.language,
382 }))
383}
384
385#[utoipa::path(
391 get,
392 path = "/testexam/{id}/fetch-exam-for-testing",
393 operation_id = "fetchExamForTesting",
394 tag = "course-material-exams",
395 params(
396 ("id" = Uuid, Path, description = "Exam id")
397 ),
398 responses(
399 (status = 200, description = "Exam data for testing", body = ExamData)
400 )
401)]
402#[instrument(skip(pool))]
403pub async fn fetch_exam_for_testing(
404 pool: web::Data<PgPool>,
405 exam_id: web::Path<Uuid>,
406 user: AuthUser,
407) -> ControllerResult<web::Json<ExamData>> {
408 let mut conn = pool.acquire().await?;
409 let exam = exams::get(&mut conn, *exam_id).await?;
410
411 let starts_at = Utc::now();
412 let ends_at = if let Some(ends_at) = exam.ends_at {
413 ends_at
414 } else {
415 return Err(ControllerError::new(
416 ControllerErrorType::Forbidden,
417 "Cannot fetch exam that has no end time".to_string(),
418 None,
419 ));
420 };
421 let ended = ends_at < Utc::now();
422
423 let enrollment = match exams::get_enrollment(&mut conn, *exam_id, user.id).await? {
424 Some(enrollment) => enrollment,
425 _ => {
426 let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
428 let can_enroll =
429 models::library::progressing::user_can_take_exam(&mut conn, *exam_id, user.id)
430 .await?;
431 return token.authorized_ok(web::Json(ExamData {
432 id: exam.id,
433 name: exam.name,
434 instructions: exam.instructions,
435 starts_at,
436 ends_at,
437 ended,
438 time_minutes: exam.time_minutes,
439 enrollment_data: ExamEnrollmentData::NotEnrolled { can_enroll },
440 language: exam.language,
441 }));
442 }
443 };
444
445 let page = pages::get_page(&mut conn, exam.page_id).await?;
446
447 let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
448 token.authorized_ok(web::Json(ExamData {
449 id: exam.id,
450 name: exam.name,
451 instructions: exam.instructions,
452 starts_at,
453 ends_at,
454 ended,
455 time_minutes: exam.time_minutes,
456 enrollment_data: ExamEnrollmentData::EnrolledAndStarted {
457 page_id: exam.page_id,
458 page: Box::new(page),
459 enrollment,
460 },
461 language: exam.language,
462 }))
463}
464
465#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
466
467pub struct ShowExerciseAnswers {
468 pub show_exercise_answers: bool,
469}
470#[utoipa::path(
476 post,
477 path = "/testexam/{id}/update-show-exercise-answers",
478 operation_id = "updateShowExerciseAnswers",
479 tag = "course-material-exams",
480 params(
481 ("id" = Uuid, Path, description = "Exam id")
482 ),
483 request_body = ShowExerciseAnswers,
484 responses(
485 (status = 200, description = "Show answers flag updated", body = ())
486 )
487)]
488#[instrument(skip(pool))]
489pub async fn update_show_exercise_answers(
490 pool: web::Data<PgPool>,
491 exam_id: web::Path<Uuid>,
492 user: AuthUser,
493 payload: web::Json<ShowExerciseAnswers>,
494) -> ControllerResult<web::Json<()>> {
495 let mut conn = pool.acquire().await?;
496 let show_answers = payload.show_exercise_answers;
497 exams::update_show_exercise_answers(&mut conn, *exam_id, user.id, show_answers).await?;
498 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
499 token.authorized_ok(web::Json(()))
500}
501
502#[utoipa::path(
508 post,
509 path = "/testexam/{id}/reset-exam-progress",
510 operation_id = "resetExamProgress",
511 tag = "course-material-exams",
512 params(
513 ("id" = Uuid, Path, description = "Exam id")
514 ),
515 responses(
516 (status = 200, description = "Exam progress reset", body = ())
517 )
518)]
519#[instrument(skip(pool))]
520pub async fn reset_exam_progress(
521 pool: web::Data<PgPool>,
522 exam_id: web::Path<Uuid>,
523 user: AuthUser,
524) -> ControllerResult<web::Json<()>> {
525 let mut conn = pool.acquire().await?;
526
527 let started_at = Utc::now();
528 exams::update_exam_start_time(&mut conn, *exam_id, user.id, started_at).await?;
529
530 models::exercise_slide_submissions::delete_exercise_submissions_with_exam_id_and_user_id(
531 &mut conn, *exam_id, user.id,
532 )
533 .await?;
534
535 let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
536 token.authorized_ok(web::Json(()))
537}
538
539#[utoipa::path(
545 post,
546 path = "/{id}/end-exam-time",
547 operation_id = "endExamTime",
548 tag = "course-material-exams",
549 params(
550 ("id" = Uuid, Path, description = "Exam id")
551 ),
552 responses(
553 (status = 200, description = "Exam end time updated", body = ())
554 )
555)]
556#[instrument(skip(pool))]
557pub async fn end_exam_time(
558 pool: web::Data<PgPool>,
559 exam_id: web::Path<Uuid>,
560 user: AuthUser,
561) -> ControllerResult<web::Json<()>> {
562 let mut conn = pool.acquire().await?;
563
564 let ended_at = Utc::now();
565 models::exams::update_exam_ended_at(&mut conn, *exam_id, user.id, ended_at).await?;
566
567 let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
568 token.authorized_ok(web::Json(()))
569}
570
571pub fn _add_routes(cfg: &mut ServiceConfig) {
579 cfg.route("/{id}/enrollment", web::get().to(enrollment))
580 .route("/{id}/enroll", web::post().to(enroll))
581 .route("/{id}", web::get().to(fetch_exam_for_user))
582 .route(
583 "/testexam/{id}/fetch-exam-for-testing",
584 web::get().to(fetch_exam_for_testing),
585 )
586 .route(
587 "/testexam/{id}/update-show-exercise-answers",
588 web::post().to(update_show_exercise_answers),
589 )
590 .route(
591 "/testexam/{id}/reset-exam-progress",
592 web::post().to(reset_exam_progress),
593 )
594 .route("/{id}/end-exam-time", web::post().to(end_exam_time));
595}