headless_lms_server/controllers/course_material/
exams.rs

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/**
27GET /api/v0/course-material/exams/:id/enrollment
28*/
29#[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/**
59POST /api/v0/course-material/exams/:id/enroll
60*/
61#[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    // enroll if teacher is testing regardless of exams starting time
85    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    // check that the exam is not over
92    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        // This check should probably be handled in the authorize function but I'm not sure of
103        // the proper action type.
104        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    // no start time defined or it's still upcoming
119    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    /// The student has enrolled to the exam and started it.
144    EnrolledAndStarted {
145        page_id: Uuid,
146        page: Box<Page>,
147        enrollment: ExamEnrollment,
148    },
149    /// The student has not enrolled to the exam yet. However, the the exam is open.
150    NotEnrolled { can_enroll: bool },
151    /// The exam's start time is in the future, no one can enroll yet.
152    NotYetStarted,
153    /// The exam is still open but the student has run out of time.
154    StudentTimeUp,
155    // Exam is still open but student can view published grading results
156    StudentCanViewGrading {
157        gradings: Vec<(TeacherGradingDecision, Exercise)>,
158        enrollment: ExamEnrollment,
159    },
160}
161
162/**
163GET /api/v0/course-material/exams/:id
164*/
165#[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        // exam has not started yet
209        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                // Get the grading results, if the student has any
227                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                // Check if student has any published grading results they can view at the exam page
251                for grading_decision in teachers_grading_decisions_list.into_iter() {
252                    if let Some(hidden) = grading_decision.hidden
253                        && !hidden
254                    {
255                        // Get the corresponding exercise for the grading result
256                        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                // user has ended the exam
302                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            // user has started the exam
320            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                // exam is still open but the student's time has expired or student has ended their exam
326                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            // user has not started the exam
347            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/**
386GET /api/v0/course-material/exams/:id/fetch-exam-for-testing
387
388Fetches an exam for testing.
389*/
390#[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            // user has not started the exam
427            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/**
471POST /api/v0/course-material/exams/:id/update-show-exercise-answers
472
473Used for testing an exam, updates wheter exercise answers are shown.
474*/
475#[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/**
503POST /api/v0/course-material/exams/:id/reset-exam-progress
504
505Used for testing an exam, resets exercise submissions and restarts the exam time.
506*/
507#[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/**
540POST /api/v0/course-material/exams/:id/end-exam-time
541
542Used for marking the students exam as ended in the exam enrollment
543*/
544#[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
571/**
572Add a route for each controller in this module.
573
574The name starts with an underline in order to appear before other functions in the module documentation.
575
576We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
577*/
578pub 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}