headless_lms_server/controllers/course_material/
exams.rs

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