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};
10
11use crate::prelude::*;
12
13/**
14GET /api/v0/course-material/exams/:id/enrollment
15*/
16#[instrument(skip(pool))]
17pub async fn enrollment(
18    pool: web::Data<PgPool>,
19    exam_id: web::Path<Uuid>,
20    user: AuthUser,
21) -> ControllerResult<web::Json<Option<ExamEnrollment>>> {
22    let mut conn = pool.acquire().await?;
23    let enrollment = exams::get_enrollment(&mut conn, *exam_id, user.id).await?;
24    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
25    token.authorized_ok(web::Json(enrollment))
26}
27
28#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
29#[cfg_attr(feature = "ts_rs", derive(TS))]
30pub struct IsTeacherTesting {
31    pub is_teacher_testing: bool,
32}
33/**
34POST /api/v0/course-material/exams/:id/enroll
35*/
36#[instrument(skip(pool))]
37pub async fn enroll(
38    pool: web::Data<PgPool>,
39    exam_id: web::Path<Uuid>,
40    user: AuthUser,
41    payload: web::Json<IsTeacherTesting>,
42) -> ControllerResult<web::Json<()>> {
43    let mut conn = pool.acquire().await?;
44    let exam = exams::get(&mut conn, *exam_id).await?;
45
46    // enroll if teacher is testing regardless of exams starting time
47    if payload.is_teacher_testing {
48        exams::enroll(&mut conn, *exam_id, user.id, payload.is_teacher_testing).await?;
49        let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
50        return token.authorized_ok(web::Json(()));
51    }
52
53    // check that the exam is not over
54    let now = Utc::now();
55    if exam.ended_at_or(now, false) {
56        return Err(ControllerError::new(
57            ControllerErrorType::Forbidden,
58            "Exam is over".to_string(),
59            None,
60        ));
61    }
62
63    if exam.started_at_or(now, false) {
64        // This check should probably be handled in the authorize function but I'm not sure of
65        // the proper action type.
66        let can_start =
67            models::library::progressing::user_can_take_exam(&mut conn, *exam_id, user.id).await?;
68        if !can_start {
69            return Err(ControllerError::new(
70                ControllerErrorType::Forbidden,
71                "User is not allowed to enroll to the exam.".to_string(),
72                None,
73            ));
74        }
75        exams::enroll(&mut conn, *exam_id, user.id, payload.is_teacher_testing).await?;
76        let token = skip_authorize();
77        return token.authorized_ok(web::Json(()));
78    }
79
80    // no start time defined or it's still upcoming
81    Err(ControllerError::new(
82        ControllerErrorType::Forbidden,
83        "Exam has not started yet".to_string(),
84        None,
85    ))
86}
87
88#[derive(Debug, Serialize)]
89#[cfg_attr(feature = "ts_rs", derive(TS))]
90pub struct ExamData {
91    pub id: Uuid,
92    pub name: String,
93    pub instructions: serde_json::Value,
94    pub starts_at: DateTime<Utc>,
95    pub ends_at: DateTime<Utc>,
96    pub ended: bool,
97    pub time_minutes: i32,
98    pub enrollment_data: ExamEnrollmentData,
99    pub language: String,
100}
101
102#[derive(Debug, Serialize)]
103#[cfg_attr(feature = "ts_rs", derive(TS))]
104#[serde(tag = "tag")]
105pub enum ExamEnrollmentData {
106    /// The student has enrolled to the exam and started it.
107    EnrolledAndStarted {
108        page_id: Uuid,
109        page: Box<Page>,
110        enrollment: ExamEnrollment,
111    },
112    /// The student has not enrolled to the exam yet. However, the the exam is open.
113    NotEnrolled { can_enroll: bool },
114    /// The exam's start time is in the future, no one can enroll yet.
115    NotYetStarted,
116    /// The exam is still open but the student has run out of time.
117    StudentTimeUp,
118    // Exam is still open but student can view published grading results
119    StudentCanViewGrading {
120        gradings: Vec<(TeacherGradingDecision, Exercise)>,
121        enrollment: ExamEnrollment,
122    },
123}
124
125/**
126GET /api/v0/course-material/exams/:id
127*/
128#[instrument(skip(pool))]
129pub async fn fetch_exam_for_user(
130    pool: web::Data<PgPool>,
131    exam_id: web::Path<Uuid>,
132    user: AuthUser,
133) -> ControllerResult<web::Json<ExamData>> {
134    let mut conn = pool.acquire().await?;
135    let exam = exams::get(&mut conn, *exam_id).await?;
136
137    let starts_at = if let Some(starts_at) = exam.starts_at {
138        starts_at
139    } else {
140        return Err(ControllerError::new(
141            ControllerErrorType::Forbidden,
142            "Cannot fetch exam that has no start time".to_string(),
143            None,
144        ));
145    };
146    let ends_at = if let Some(ends_at) = exam.ends_at {
147        ends_at
148    } else {
149        return Err(ControllerError::new(
150            ControllerErrorType::Forbidden,
151            "Cannot fetch exam that has no end time".to_string(),
152            None,
153        ));
154    };
155
156    let ended = ends_at < Utc::now();
157
158    if starts_at > Utc::now() {
159        // exam has not started yet
160        let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
161        return token.authorized_ok(web::Json(ExamData {
162            id: exam.id,
163            name: exam.name,
164            instructions: exam.instructions,
165            starts_at,
166            ends_at,
167            ended,
168            time_minutes: exam.time_minutes,
169            enrollment_data: ExamEnrollmentData::NotYetStarted,
170            language: exam.language,
171        }));
172    }
173
174    let enrollment = match exams::get_enrollment(&mut conn, *exam_id, user.id).await? {
175        Some(enrollment) => {
176            if exam.grade_manually {
177                // Get the grading results, if the student has any
178                let teachers_grading_decisions_list =
179                teacher_grading_decisions::get_all_latest_grading_decisions_by_user_id_and_exam_id(
180                    &mut conn, user.id, *exam_id,
181                )
182                .await?;
183                let teacher_grading_decisions = teachers_grading_decisions_list.clone();
184
185                let exam_exercises =
186                    exercises::get_exercises_by_exam_id(&mut conn, *exam_id).await?;
187
188                let user_exercise_states =
189                    user_exercise_states::get_all_for_user_and_course_or_exam(
190                        &mut conn,
191                        user.id,
192                        CourseOrExamId::Exam(*exam_id),
193                    )
194                    .await?;
195
196                let mut grading_decision_and_exercise_list: Vec<(
197                    TeacherGradingDecision,
198                    Exercise,
199                )> = Vec::new();
200
201                // Check if student has any published grading results they can view at the exam page
202                for grading_decision in teachers_grading_decisions_list.into_iter() {
203                    if let Some(hidden) = grading_decision.hidden
204                        && !hidden
205                    {
206                        // Get the corresponding exercise for the grading result
207                        for grading in teacher_grading_decisions.into_iter() {
208                            let user_exercise_state = user_exercise_states
209                                .iter()
210                                .find(|state| state.id == grading.user_exercise_state_id)
211                                .ok_or_else(|| {
212                                    ModelError::new(
213                                        ModelErrorType::Generic,
214                                        "User_exercise_state not found",
215                                        None,
216                                    )
217                                })?;
218
219                            let exercise = exam_exercises
220                                .iter()
221                                .find(|exercise| exercise.id == user_exercise_state.exercise_id)
222                                .ok_or_else(|| {
223                                    ModelError::new(
224                                        ModelErrorType::Generic,
225                                        "Exercise not found",
226                                        None,
227                                    )
228                                })?;
229
230                            grading_decision_and_exercise_list.push((grading, exercise.clone()));
231                        }
232
233                        let token =
234                            authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id))
235                                .await?;
236                        return token.authorized_ok(web::Json(ExamData {
237                            id: exam.id,
238                            name: exam.name,
239                            instructions: exam.instructions,
240                            starts_at,
241                            ends_at,
242                            ended,
243                            time_minutes: exam.time_minutes,
244                            enrollment_data: ExamEnrollmentData::StudentCanViewGrading {
245                                gradings: grading_decision_and_exercise_list,
246                                enrollment,
247                            },
248                            language: exam.language,
249                        }));
250                    }
251                }
252                // user has ended the exam
253                if enrollment.ended_at.is_some() {
254                    let token: domain::authorization::AuthorizationToken =
255                        authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
256                    return token.authorized_ok(web::Json(ExamData {
257                        id: exam.id,
258                        name: exam.name,
259                        instructions: exam.instructions,
260                        starts_at,
261                        ends_at,
262                        ended,
263                        time_minutes: exam.time_minutes,
264                        enrollment_data: ExamEnrollmentData::StudentTimeUp,
265                        language: exam.language,
266                    }));
267                }
268            }
269
270            // user has started the exam
271            if Utc::now() < ends_at
272                && (Utc::now()
273                    > enrollment.started_at + Duration::minutes(exam.time_minutes.into())
274                    || enrollment.ended_at.is_some())
275            {
276                // exam is still open but the student's time has expired or student has ended their exam
277                if enrollment.ended_at.is_none() {
278                    exams::update_exam_ended_at(&mut conn, *exam_id, user.id, Utc::now()).await?;
279                }
280                let token: domain::authorization::AuthorizationToken =
281                    authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
282                return token.authorized_ok(web::Json(ExamData {
283                    id: exam.id,
284                    name: exam.name,
285                    instructions: exam.instructions,
286                    starts_at,
287                    ends_at,
288                    ended,
289                    time_minutes: exam.time_minutes,
290                    enrollment_data: ExamEnrollmentData::StudentTimeUp,
291                    language: exam.language,
292                }));
293            }
294            enrollment
295        }
296        _ => {
297            // user has not started the exam
298            let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
299            let can_enroll =
300                models::library::progressing::user_can_take_exam(&mut conn, *exam_id, user.id)
301                    .await?;
302            return token.authorized_ok(web::Json(ExamData {
303                id: exam.id,
304                name: exam.name,
305                instructions: exam.instructions,
306                starts_at,
307                ends_at,
308                ended,
309                time_minutes: exam.time_minutes,
310                enrollment_data: ExamEnrollmentData::NotEnrolled { can_enroll },
311                language: exam.language,
312            }));
313        }
314    };
315
316    let page = pages::get_page(&mut conn, exam.page_id).await?;
317
318    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
319    token.authorized_ok(web::Json(ExamData {
320        id: exam.id,
321        name: exam.name,
322        instructions: exam.instructions,
323        starts_at,
324        ends_at,
325        ended,
326        time_minutes: exam.time_minutes,
327        enrollment_data: ExamEnrollmentData::EnrolledAndStarted {
328            page_id: exam.page_id,
329            page: Box::new(page),
330            enrollment,
331        },
332        language: exam.language,
333    }))
334}
335
336/**
337GET /api/v0/course-material/exams/:id/fetch-exam-for-testing
338
339Fetches an exam for testing.
340*/
341#[instrument(skip(pool))]
342pub async fn fetch_exam_for_testing(
343    pool: web::Data<PgPool>,
344    exam_id: web::Path<Uuid>,
345    user: AuthUser,
346) -> ControllerResult<web::Json<ExamData>> {
347    let mut conn = pool.acquire().await?;
348    let exam = exams::get(&mut conn, *exam_id).await?;
349
350    let starts_at = Utc::now();
351    let ends_at = if let Some(ends_at) = exam.ends_at {
352        ends_at
353    } else {
354        return Err(ControllerError::new(
355            ControllerErrorType::Forbidden,
356            "Cannot fetch exam that has no end time".to_string(),
357            None,
358        ));
359    };
360    let ended = ends_at < Utc::now();
361
362    let enrollment = match exams::get_enrollment(&mut conn, *exam_id, user.id).await? {
363        Some(enrollment) => enrollment,
364        _ => {
365            // user has not started the exam
366            let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
367            let can_enroll =
368                models::library::progressing::user_can_take_exam(&mut conn, *exam_id, user.id)
369                    .await?;
370            return token.authorized_ok(web::Json(ExamData {
371                id: exam.id,
372                name: exam.name,
373                instructions: exam.instructions,
374                starts_at,
375                ends_at,
376                ended,
377                time_minutes: exam.time_minutes,
378                enrollment_data: ExamEnrollmentData::NotEnrolled { can_enroll },
379                language: exam.language,
380            }));
381        }
382    };
383
384    let page = pages::get_page(&mut conn, exam.page_id).await?;
385
386    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
387    token.authorized_ok(web::Json(ExamData {
388        id: exam.id,
389        name: exam.name,
390        instructions: exam.instructions,
391        starts_at,
392        ends_at,
393        ended,
394        time_minutes: exam.time_minutes,
395        enrollment_data: ExamEnrollmentData::EnrolledAndStarted {
396            page_id: exam.page_id,
397            page: Box::new(page),
398            enrollment,
399        },
400        language: exam.language,
401    }))
402}
403
404#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
405#[cfg_attr(feature = "ts_rs", derive(TS))]
406pub struct ShowExerciseAnswers {
407    pub show_exercise_answers: bool,
408}
409/**
410POST /api/v0/course-material/exams/:id/update-show-exercise-answers
411
412Used for testing an exam, updates wheter exercise answers are shown.
413*/
414#[instrument(skip(pool))]
415pub async fn update_show_exercise_answers(
416    pool: web::Data<PgPool>,
417    exam_id: web::Path<Uuid>,
418    user: AuthUser,
419    payload: web::Json<ShowExerciseAnswers>,
420) -> ControllerResult<web::Json<()>> {
421    let mut conn = pool.acquire().await?;
422    let show_answers = payload.show_exercise_answers;
423    exams::update_show_exercise_answers(&mut conn, *exam_id, user.id, show_answers).await?;
424    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
425    token.authorized_ok(web::Json(()))
426}
427
428/**
429POST /api/v0/course-material/exams/:id/reset-exam-progress
430
431Used for testing an exam, resets exercise submissions and restarts the exam time.
432*/
433#[instrument(skip(pool))]
434pub async fn reset_exam_progress(
435    pool: web::Data<PgPool>,
436    exam_id: web::Path<Uuid>,
437    user: AuthUser,
438) -> ControllerResult<web::Json<()>> {
439    let mut conn = pool.acquire().await?;
440
441    let started_at = Utc::now();
442    exams::update_exam_start_time(&mut conn, *exam_id, user.id, started_at).await?;
443
444    models::exercise_slide_submissions::delete_exercise_submissions_with_exam_id_and_user_id(
445        &mut conn, *exam_id, user.id,
446    )
447    .await?;
448
449    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
450    token.authorized_ok(web::Json(()))
451}
452
453/**
454POST /api/v0/course-material/exams/:id/end-exam-time
455
456Used for marking the students exam as ended in the exam enrollment
457*/
458#[instrument(skip(pool))]
459pub async fn end_exam_time(
460    pool: web::Data<PgPool>,
461    exam_id: web::Path<Uuid>,
462    user: AuthUser,
463) -> ControllerResult<web::Json<()>> {
464    let mut conn = pool.acquire().await?;
465
466    let ended_at = Utc::now();
467    models::exams::update_exam_ended_at(&mut conn, *exam_id, user.id, ended_at).await?;
468
469    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
470    token.authorized_ok(web::Json(()))
471}
472
473/**
474Add a route for each controller in this module.
475
476The name starts with an underline in order to appear before other functions in the module documentation.
477
478We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
479*/
480pub fn _add_routes(cfg: &mut ServiceConfig) {
481    cfg.route("/{id}/enrollment", web::get().to(enrollment))
482        .route("/{id}/enroll", web::post().to(enroll))
483        .route("/{id}", web::get().to(fetch_exam_for_user))
484        .route(
485            "/testexam/{id}/fetch-exam-for-testing",
486            web::get().to(fetch_exam_for_testing),
487        )
488        .route(
489            "/testexam/{id}/update-show-exercise-answers",
490            web::post().to(update_show_exercise_answers),
491        )
492        .route(
493            "/testexam/{id}/reset-exam-progress",
494            web::post().to(reset_exam_progress),
495        )
496        .route("/{id}/end-exam-time", web::post().to(end_exam_time));
497}