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                        if !hidden {
205                            // Get the corresponding exercise for the grading result
206                            for grading in teacher_grading_decisions.into_iter() {
207                                let user_exercise_state = user_exercise_states
208                                    .iter()
209                                    .find(|state| state.id == grading.user_exercise_state_id)
210                                    .ok_or_else(|| {
211                                        ModelError::new(
212                                            ModelErrorType::Generic,
213                                            "User_exercise_state not found",
214                                            None,
215                                        )
216                                    })?;
217
218                                let exercise = exam_exercises
219                                    .iter()
220                                    .find(|exercise| exercise.id == user_exercise_state.exercise_id)
221                                    .ok_or_else(|| {
222                                        ModelError::new(
223                                            ModelErrorType::Generic,
224                                            "Exercise not found",
225                                            None,
226                                        )
227                                    })?;
228
229                                grading_decision_and_exercise_list
230                                    .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                }
253                // user has ended the exam
254                if enrollment.ended_at.is_some() {
255                    let token: domain::authorization::AuthorizationToken =
256                        authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
257                    return token.authorized_ok(web::Json(ExamData {
258                        id: exam.id,
259                        name: exam.name,
260                        instructions: exam.instructions,
261                        starts_at,
262                        ends_at,
263                        ended,
264                        time_minutes: exam.time_minutes,
265                        enrollment_data: ExamEnrollmentData::StudentTimeUp,
266                        language: exam.language,
267                    }));
268                }
269            }
270
271            // user has started the exam
272            if Utc::now() < ends_at
273                && (Utc::now()
274                    > enrollment.started_at + Duration::minutes(exam.time_minutes.into())
275                    || enrollment.ended_at.is_some())
276            {
277                // exam is still open but the student's time has expired or student has ended their exam
278                if enrollment.ended_at.is_none() {
279                    exams::update_exam_ended_at(&mut conn, *exam_id, user.id, Utc::now()).await?;
280                }
281                let token: domain::authorization::AuthorizationToken =
282                    authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
283                return token.authorized_ok(web::Json(ExamData {
284                    id: exam.id,
285                    name: exam.name,
286                    instructions: exam.instructions,
287                    starts_at,
288                    ends_at,
289                    ended,
290                    time_minutes: exam.time_minutes,
291                    enrollment_data: ExamEnrollmentData::StudentTimeUp,
292                    language: exam.language,
293                }));
294            }
295            enrollment
296        }
297        _ => {
298            // user has not started the exam
299            let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
300            let can_enroll =
301                models::library::progressing::user_can_take_exam(&mut conn, *exam_id, user.id)
302                    .await?;
303            return token.authorized_ok(web::Json(ExamData {
304                id: exam.id,
305                name: exam.name,
306                instructions: exam.instructions,
307                starts_at,
308                ends_at,
309                ended,
310                time_minutes: exam.time_minutes,
311                enrollment_data: ExamEnrollmentData::NotEnrolled { can_enroll },
312                language: exam.language,
313            }));
314        }
315    };
316
317    let page = pages::get_page(&mut conn, exam.page_id).await?;
318
319    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
320    token.authorized_ok(web::Json(ExamData {
321        id: exam.id,
322        name: exam.name,
323        instructions: exam.instructions,
324        starts_at,
325        ends_at,
326        ended,
327        time_minutes: exam.time_minutes,
328        enrollment_data: ExamEnrollmentData::EnrolledAndStarted {
329            page_id: exam.page_id,
330            page: Box::new(page),
331            enrollment,
332        },
333        language: exam.language,
334    }))
335}
336
337/**
338GET /api/v0/course-material/exams/:id/fetch-exam-for-testing
339
340Fetches an exam for testing.
341*/
342#[instrument(skip(pool))]
343pub async fn fetch_exam_for_testing(
344    pool: web::Data<PgPool>,
345    exam_id: web::Path<Uuid>,
346    user: AuthUser,
347) -> ControllerResult<web::Json<ExamData>> {
348    let mut conn = pool.acquire().await?;
349    let exam = exams::get(&mut conn, *exam_id).await?;
350
351    let starts_at = Utc::now();
352    let ends_at = if let Some(ends_at) = exam.ends_at {
353        ends_at
354    } else {
355        return Err(ControllerError::new(
356            ControllerErrorType::Forbidden,
357            "Cannot fetch exam that has no end time".to_string(),
358            None,
359        ));
360    };
361    let ended = ends_at < Utc::now();
362
363    let enrollment = match exams::get_enrollment(&mut conn, *exam_id, user.id).await? {
364        Some(enrollment) => enrollment,
365        _ => {
366            // user has not started the exam
367            let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
368            let can_enroll =
369                models::library::progressing::user_can_take_exam(&mut conn, *exam_id, user.id)
370                    .await?;
371            return token.authorized_ok(web::Json(ExamData {
372                id: exam.id,
373                name: exam.name,
374                instructions: exam.instructions,
375                starts_at,
376                ends_at,
377                ended,
378                time_minutes: exam.time_minutes,
379                enrollment_data: ExamEnrollmentData::NotEnrolled { can_enroll },
380                language: exam.language,
381            }));
382        }
383    };
384
385    let page = pages::get_page(&mut conn, exam.page_id).await?;
386
387    let token = authorize(&mut conn, Act::Edit, Some(user.id), Res::Exam(*exam_id)).await?;
388    token.authorized_ok(web::Json(ExamData {
389        id: exam.id,
390        name: exam.name,
391        instructions: exam.instructions,
392        starts_at,
393        ends_at,
394        ended,
395        time_minutes: exam.time_minutes,
396        enrollment_data: ExamEnrollmentData::EnrolledAndStarted {
397            page_id: exam.page_id,
398            page: Box::new(page),
399            enrollment,
400        },
401        language: exam.language,
402    }))
403}
404
405#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
406#[cfg_attr(feature = "ts_rs", derive(TS))]
407pub struct ShowExerciseAnswers {
408    pub show_exercise_answers: bool,
409}
410/**
411POST /api/v0/course-material/exams/:id/update-show-exercise-answers
412
413Used for testing an exam, updates wheter exercise answers are shown.
414*/
415#[instrument(skip(pool))]
416pub async fn update_show_exercise_answers(
417    pool: web::Data<PgPool>,
418    exam_id: web::Path<Uuid>,
419    user: AuthUser,
420    payload: web::Json<ShowExerciseAnswers>,
421) -> ControllerResult<web::Json<()>> {
422    let mut conn = pool.acquire().await?;
423    let show_answers = payload.show_exercise_answers;
424    exams::update_show_exercise_answers(&mut conn, *exam_id, user.id, show_answers).await?;
425    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
426    token.authorized_ok(web::Json(()))
427}
428
429/**
430POST /api/v0/course-material/exams/:id/reset-exam-progress
431
432Used for testing an exam, resets exercise submissions and restarts the exam time.
433*/
434#[instrument(skip(pool))]
435pub async fn reset_exam_progress(
436    pool: web::Data<PgPool>,
437    exam_id: web::Path<Uuid>,
438    user: AuthUser,
439) -> ControllerResult<web::Json<()>> {
440    let mut conn = pool.acquire().await?;
441
442    let started_at = Utc::now();
443    exams::update_exam_start_time(&mut conn, *exam_id, user.id, started_at).await?;
444
445    models::exercise_slide_submissions::delete_exercise_submissions_with_exam_id_and_user_id(
446        &mut conn, *exam_id, user.id,
447    )
448    .await?;
449
450    let token = authorize(&mut conn, Act::Teach, Some(user.id), Res::Exam(*exam_id)).await?;
451    token.authorized_ok(web::Json(()))
452}
453
454/**
455POST /api/v0/course-material/exams/:id/end-exam-time
456
457Used for marking the students exam as ended in the exam enrollment
458*/
459#[instrument(skip(pool))]
460pub async fn end_exam_time(
461    pool: web::Data<PgPool>,
462    exam_id: web::Path<Uuid>,
463    user: AuthUser,
464) -> ControllerResult<web::Json<()>> {
465    let mut conn = pool.acquire().await?;
466
467    let ended_at = Utc::now();
468    models::exams::update_exam_ended_at(&mut conn, *exam_id, user.id, ended_at).await?;
469
470    let token = authorize(&mut conn, Act::View, Some(user.id), Res::Exam(*exam_id)).await?;
471    token.authorized_ok(web::Json(()))
472}
473
474/**
475Add a route for each controller in this module.
476
477The name starts with an underline in order to appear before other functions in the module documentation.
478
479We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
480*/
481pub fn _add_routes(cfg: &mut ServiceConfig) {
482    cfg.route("/{id}/enrollment", web::get().to(enrollment))
483        .route("/{id}/enroll", web::post().to(enroll))
484        .route("/{id}", web::get().to(fetch_exam_for_user))
485        .route(
486            "/testexam/{id}/fetch-exam-for-testing",
487            web::get().to(fetch_exam_for_testing),
488        )
489        .route(
490            "/testexam/{id}/update-show-exercise-answers",
491            web::post().to(update_show_exercise_answers),
492        )
493        .route(
494            "/testexam/{id}/reset-exam-progress",
495            web::post().to(reset_exam_progress),
496        )
497        .route("/{id}/end-exam-time", web::post().to(end_exam_time));
498}