Skip to main content

headless_lms_server/controllers/course_material/
exercises.rs

1//! Controllers for requests starting with `/api/v0/course-material/exercises`.
2
3use crate::{
4    domain::{
5        authorization::skip_authorize,
6        exercises::process_submission,
7        models_requests::{self, GivePeerReviewClaim, JwtKey},
8    },
9    prelude::*,
10};
11use headless_lms_models::{
12    ModelError, ModelErrorType, flagged_answers::FlaggedAnswer, peer_or_self_review_configs,
13};
14use models::{
15    exercise_task_submissions::PeerOrSelfReviewsReceived,
16    exercises::CourseMaterialExercise,
17    flagged_answers::NewFlaggedAnswerWithToken,
18    library::{
19        grading::{StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult},
20        peer_or_self_reviewing::{
21            CourseMaterialPeerOrSelfReviewData, CourseMaterialPeerOrSelfReviewSubmission,
22        },
23    },
24    user_chapter_locking_statuses, user_exercise_states,
25};
26use utoipa::OpenApi;
27
28#[derive(OpenApi)]
29#[openapi(paths(
30    get_exercise,
31    get_peer_review_for_exercise,
32    get_peer_reviews_received,
33    post_submission,
34    start_peer_or_self_review,
35    submit_peer_or_self_review,
36    post_flag_answer_in_peer_review
37))]
38pub(crate) struct CourseMaterialExercisesApiDoc;
39
40#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, utoipa::ToSchema)]
41
42pub struct CourseMaterialPeerOrSelfReviewDataWithToken {
43    pub course_material_peer_or_self_review_data: CourseMaterialPeerOrSelfReviewData,
44    pub token: Option<String>,
45}
46
47/**
48GET `/api/v0/course-material/exercises/:exercise_id` - Get exercise by id. Includes
49relevant context so that doing the exercise is possible based on the response.
50
51This endpoint does not expose exercise's private spec because it would
52expose the correct answers to the user.
53*/
54#[utoipa::path(
55    get,
56    path = "/{exercise_id}",
57    operation_id = "getCourseMaterialExercise",
58    tag = "course-material-exercises",
59    params(
60        ("exercise_id" = Uuid, Path, description = "Exercise id")
61    ),
62    responses(
63        (status = 200, description = "Course material exercise", body = CourseMaterialExercise)
64    )
65)]
66#[instrument(skip(pool, file_store, app_conf))]
67async fn get_exercise(
68    pool: web::Data<PgPool>,
69    exercise_id: web::Path<Uuid>,
70    user: Option<AuthUser>,
71    file_store: web::Data<dyn FileStore>,
72    app_conf: web::Data<ApplicationConfiguration>,
73) -> ControllerResult<web::Json<CourseMaterialExercise>> {
74    let mut conn = pool.acquire().await?;
75    let user_id = user.map(|u| u.id);
76    let mut course_material_exercise = models::exercises::get_course_material_exercise(
77        &mut conn,
78        user_id,
79        *exercise_id,
80        models_requests::fetch_service_info,
81        file_store.as_ref(),
82        app_conf.as_ref(),
83    )
84    .await?;
85
86    let mut should_clear_grading_information = true;
87    // Check if teacher is testing an exam and wants to see the exercise answers
88    if let Some(exam_id) = course_material_exercise.exercise.exam_id {
89        let user_id_for_exam = user_id.ok_or_else(|| {
90            ControllerError::new(
91                ControllerErrorType::UnauthorizedWithReason(
92                    crate::domain::error::UnauthorizedReason::AuthenticationRequiredForExamExercise,
93                ),
94                "User must be authenticated to view exam exercises".to_string(),
95                None,
96            )
97        })?;
98        let user_enrollment =
99            models::exams::get_enrollment(&mut conn, exam_id, user_id_for_exam).await?;
100
101        if let Some(enrollment) = user_enrollment
102            && let Some(show_answers) = enrollment.show_exercise_answers
103            && enrollment.is_teacher_testing
104            && show_answers
105        {
106            should_clear_grading_information = false;
107        }
108    }
109
110    if course_material_exercise.can_post_submission
111        && course_material_exercise.exercise.exam_id.is_some()
112        && should_clear_grading_information
113    {
114        // Explicitely clear grading information from ongoing exam submissions.
115        course_material_exercise.clear_grading_information();
116    }
117
118    let score_given: f32 = if let Some(status) = &course_material_exercise.exercise_status {
119        status.score_given.unwrap_or(0.0)
120    } else {
121        0.0
122    };
123
124    let submission_count = course_material_exercise
125        .exercise_slide_submission_counts
126        .get(&course_material_exercise.current_exercise_slide.id)
127        .unwrap_or(&0);
128
129    let out_of_tries = course_material_exercise.exercise.limit_number_of_tries
130        && *submission_count as i32
131            >= course_material_exercise
132                .exercise
133                .max_tries_per_slide
134                .unwrap_or(i32::MAX);
135
136    // Model solution spec should only be shown when this is the last try for the current slide or they have gotten full points from the current slide.
137    // TODO: this uses points for the whole exercise, change this to slide points when slide grading finalized
138    let has_received_full_points = score_given
139        >= course_material_exercise.exercise.score_maximum as f32
140        || (score_given - course_material_exercise.exercise.score_maximum as f32).abs() < 0.0001;
141    if !has_received_full_points && !out_of_tries {
142        course_material_exercise.clear_model_solution_specs();
143    }
144    let token = skip_authorize();
145    token.authorized_ok(web::Json(course_material_exercise))
146}
147
148/**
149GET `/api/v0/course-material/exercises/:exercise_id/peer-review` - Get peer review for an exercise. This includes the submission to peer review and the questions the user is supposed to answer.ALTER
150
151This request will fail if the user is not in the peer review stage yet because the information included in the peer review often exposes the correct solution to the exercise.
152*/
153#[utoipa::path(
154    get,
155    path = "/{exercise_id}/peer-review",
156    operation_id = "fetchPeerOrSelfReviewDataByExerciseId",
157    tag = "course-material-exercises",
158    params(
159        ("exercise_id" = Uuid, Path, description = "Exercise id")
160    ),
161    responses(
162        (
163            status = 200,
164            description = "Peer or self review data",
165            body = CourseMaterialPeerOrSelfReviewDataWithToken
166        )
167    )
168)]
169#[instrument(skip(pool, file_store, app_conf))]
170async fn get_peer_review_for_exercise(
171    pool: web::Data<PgPool>,
172    exercise_id: web::Path<Uuid>,
173    user: AuthUser,
174    jwt_key: web::Data<JwtKey>,
175    file_store: web::Data<dyn FileStore>,
176    app_conf: web::Data<ApplicationConfiguration>,
177) -> ControllerResult<web::Json<CourseMaterialPeerOrSelfReviewDataWithToken>> {
178    let mut conn = pool.acquire().await?;
179    let course_material_peer_or_self_review_data =
180        models::peer_or_self_review_configs::get_course_material_peer_or_self_review_data(
181            &mut conn,
182            user.id,
183            *exercise_id,
184            models_requests::fetch_service_info,
185            file_store.as_ref(),
186            app_conf.as_ref(),
187        )
188        .await?;
189    let token = authorize(
190        &mut conn,
191        Act::View,
192        Some(user.id),
193        Res::Exercise(*exercise_id),
194    )
195    .await?;
196    let give_peer_review_claim =
197        if let Some(to_review) = &course_material_peer_or_self_review_data.answer_to_review {
198            Some(
199                GivePeerReviewClaim::expiring_in_1_day(
200                    to_review.exercise_slide_submission_id,
201                    course_material_peer_or_self_review_data
202                        .peer_or_self_review_config
203                        .id,
204                )
205                .sign(&jwt_key)?,
206            )
207        } else {
208            None
209        };
210
211    let res = CourseMaterialPeerOrSelfReviewDataWithToken {
212        course_material_peer_or_self_review_data,
213        token: give_peer_review_claim,
214    };
215    token.authorized_ok(web::Json(res))
216}
217
218/**
219GET `/api/v0/course-material/exercises/:exercise_id/peer-review-received` - Get peer review recieved from other student for an exercise. This includes peer review submitted and the question asociated with it.
220*/
221#[utoipa::path(
222    get,
223    path = "/{exercise_id}/exercise-slide-submission/{exercise_slide_submission_id}/peer-or-self-reviews-received",
224    operation_id = "fetchPeerReviewDataReceivedByExerciseId",
225    tag = "course-material-exercises",
226    params(
227        ("exercise_id" = Uuid, Path, description = "Exercise id"),
228        ("exercise_slide_submission_id" = Uuid, Path, description = "Exercise slide submission id")
229    ),
230    responses(
231        (status = 200, description = "Peer reviews received", body = PeerOrSelfReviewsReceived)
232    )
233)]
234#[instrument(skip(pool))]
235async fn get_peer_reviews_received(
236    pool: web::Data<PgPool>,
237    params: web::Path<(Uuid, Uuid)>,
238    user: AuthUser,
239) -> ControllerResult<web::Json<PeerOrSelfReviewsReceived>> {
240    let mut conn = pool.acquire().await?;
241    let (exercise_id, exercise_slide_submission_id) = params.into_inner();
242    let peer_review_data = models::exercise_task_submissions::get_peer_reviews_received(
243        &mut conn,
244        exercise_id,
245        exercise_slide_submission_id,
246        user.id,
247    )
248    .await?;
249    let token = skip_authorize();
250    token.authorized_ok(web::Json(peer_review_data))
251}
252
253/**
254POST `/api/v0/course-material/exercises/:exercise_id/submissions` - Post new submission for an
255exercise.
256
257# Example
258```http
259POST /api/v0/course-material/exercises/:exercise_id/submissions HTTP/1.1
260Content-Type: application/json
261
262{
263  "exercise_slide_id": "0125c21b-6afa-4652-89f7-56c48bd8ffe4",
264  "exercise_task_answers": [
265    {
266      "exercise_task_id": "0125c21b-6afa-4652-89f7-56c48bd8ffe4",
267      "answer": {
268        "kind": "json",
269        "data": { "selectedOptionId": "8f09e9a0-ac20-486a-ba29-704e7eeaf6af" }
270      }
271    }
272  ]
273}
274```
275*/
276#[utoipa::path(
277    post,
278    path = "/{exercise_id}/submissions",
279    operation_id = "postSubmission",
280    tag = "course-material-exercises",
281    params(
282        ("exercise_id" = Uuid, Path, description = "Exercise id")
283    ),
284    request_body = StudentExerciseSlideSubmission,
285    responses(
286        (
287            status = 200,
288            description = "Submission result",
289            body = StudentExerciseSlideSubmissionResult
290        )
291    )
292)]
293#[instrument(skip(pool, file_store, jwt_key, app_conf))]
294async fn post_submission(
295    pool: web::Data<PgPool>,
296    file_store: web::Data<dyn FileStore>,
297    jwt_key: web::Data<JwtKey>,
298    exercise_id: web::Path<Uuid>,
299    payload: web::Json<StudentExerciseSlideSubmission>,
300    user: AuthUser,
301    app_conf: web::Data<ApplicationConfiguration>,
302) -> ControllerResult<web::Json<StudentExerciseSlideSubmissionResult>> {
303    let submission = payload.0;
304    let mut conn = pool.acquire().await?;
305    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
306
307    if let Some(chapter_id) = exercise.chapter_id {
308        let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
309        let is_accessible = user_chapter_locking_statuses::is_chapter_accessible(
310            &mut conn, user.id, chapter_id, course_id,
311        )
312        .await?;
313        if !is_accessible {
314            return Err(ControllerError::new(
315                ControllerErrorType::Forbidden,
316                "Complete and lock the previous chapter to unlock exercises in this chapter."
317                    .to_string(),
318                None,
319            ));
320        }
321        let exercises_locked = user_chapter_locking_statuses::is_chapter_exercises_locked(
322            &mut conn, user.id, chapter_id, course_id,
323        )
324        .await?;
325        if exercises_locked {
326            return Err(ControllerError::new(
327                ControllerErrorType::Forbidden,
328                "The current chapter is locked, and you can no longer submit exercises."
329                    .to_string(),
330                None,
331            ));
332        }
333    }
334
335    let token = authorize(
336        &mut conn,
337        Act::View,
338        Some(user.id),
339        Res::Exercise(exercise.id),
340    )
341    .await?;
342    let result = process_submission(
343        &mut conn,
344        user.id,
345        exercise,
346        &submission,
347        jwt_key.into_inner(),
348        file_store.as_ref(),
349        app_conf.as_ref(),
350    )
351    .await?;
352    token.authorized_ok(web::Json(result))
353}
354
355/**
356 * POST `/api/v0/course-material/exercises/:exercise_id/peer-or-self-reviews/start` - Post a signal indicating that
357 * the user will start the peer or self reviewing process.
358 *
359 * This operation is only valid for exercises marked for peer reviews. No further submissions will be
360 * accepted after posting to this endpoint.
361 */
362#[utoipa::path(
363    post,
364    path = "/{exercise_id}/peer-or-self-reviews/start",
365    operation_id = "postStartPeerOrSelfReview",
366    tag = "course-material-exercises",
367    params(
368        ("exercise_id" = Uuid, Path, description = "Exercise id")
369    ),
370    responses(
371        (status = 200, description = "Peer or self review started", body = bool)
372    )
373)]
374#[instrument(skip(pool))]
375async fn start_peer_or_self_review(
376    pool: web::Data<PgPool>,
377    exercise_id: web::Path<Uuid>,
378    user: AuthUser,
379) -> ControllerResult<web::Json<bool>> {
380    let mut conn = pool.acquire().await?;
381
382    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
383
384    if let Some(chapter_id) = exercise.chapter_id {
385        let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
386        let is_accessible = user_chapter_locking_statuses::is_chapter_accessible(
387            &mut conn, user.id, chapter_id, course_id,
388        )
389        .await?;
390        if !is_accessible {
391            return Err(ControllerError::new(
392                ControllerErrorType::Forbidden,
393                "Complete and lock the previous chapter to unlock exercises in this chapter."
394                    .to_string(),
395                None,
396            ));
397        }
398        let exercises_locked = user_chapter_locking_statuses::is_chapter_exercises_locked(
399            &mut conn, user.id, chapter_id, course_id,
400        )
401        .await?;
402        if exercises_locked {
403            return Err(ControllerError::new(
404                ControllerErrorType::Forbidden,
405                "The current chapter is locked, and you can no longer submit exercises."
406                    .to_string(),
407                None,
408            ));
409        }
410    }
411
412    let user_exercise_state =
413        user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
414    let token = authorize(
415        &mut conn,
416        Act::View,
417        Some(user.id),
418        Res::Exercise(*exercise_id),
419    )
420    .await?;
421    models::library::peer_or_self_reviewing::start_peer_or_self_review_for_user(
422        &mut conn,
423        user_exercise_state,
424        &exercise,
425    )
426    .await?;
427
428    token.authorized_ok(web::Json(true))
429}
430
431/**
432 * POST `/api/v0/course-material/exercises/:exercise_id/peer-or-self-reviews - Post a peer review or a self review for an
433 * exercise submission.
434 */
435#[utoipa::path(
436    post,
437    path = "/{exercise_id}/peer-or-self-reviews",
438    operation_id = "postPeerOrSelfReviewSubmission",
439    tag = "course-material-exercises",
440    params(
441        ("exercise_id" = Uuid, Path, description = "Exercise id")
442    ),
443    request_body = CourseMaterialPeerOrSelfReviewSubmission,
444    responses(
445        (status = 200, description = "Peer or self review submitted", body = bool)
446    )
447)]
448#[instrument(skip(pool))]
449async fn submit_peer_or_self_review(
450    pool: web::Data<PgPool>,
451    exercise_id: web::Path<Uuid>,
452    payload: web::Json<CourseMaterialPeerOrSelfReviewSubmission>,
453    user: AuthUser,
454    jwt_key: web::Data<JwtKey>,
455) -> ControllerResult<web::Json<bool>> {
456    let mut conn = pool.acquire().await?;
457    let payload = payload.into_inner();
458    let exercise = models::exercises::get_non_deleted_by_id(&mut conn, *exercise_id).await?;
459
460    if let Some(chapter_id) = exercise.chapter_id {
461        let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
462        let is_accessible = user_chapter_locking_statuses::is_chapter_accessible(
463            &mut conn, user.id, chapter_id, course_id,
464        )
465        .await?;
466        if !is_accessible {
467            return Err(ControllerError::new(
468                ControllerErrorType::Forbidden,
469                "Complete and lock the previous chapter to unlock exercises in this chapter."
470                    .to_string(),
471                None,
472            ));
473        }
474        let exercises_locked = user_chapter_locking_statuses::is_chapter_exercises_locked(
475            &mut conn, user.id, chapter_id, course_id,
476        )
477        .await?;
478        if exercises_locked {
479            return Err(ControllerError::new(
480                ControllerErrorType::Forbidden,
481                "The current chapter is locked, and you can no longer submit exercises."
482                    .to_string(),
483                None,
484            ));
485        }
486    }
487
488    // If the claim in the token validates, we can be sure that the user submitting this peer review got the peer review candidate from the backend.
489    // The validation prevents users from chaging which answer they peer review.
490    let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
491    if claim.exercise_slide_submission_id != payload.exercise_slide_submission_id
492        || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
493    {
494        return Err(ControllerError::new(
495            ControllerErrorType::BadRequest,
496            "You are not allowed to review this answer.".to_string(),
497            None,
498        ));
499    }
500
501    let giver_user_exercise_state =
502        user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
503    let exercise_slide_submission: models::exercise_slide_submissions::ExerciseSlideSubmission =
504        models::exercise_slide_submissions::get_by_id(
505            &mut conn,
506            payload.exercise_slide_submission_id,
507        )
508        .await?;
509    if exercise_slide_submission.exercise_id != exercise.id
510        || exercise_slide_submission.course_id != exercise.course_id
511    {
512        return Err(controller_err!(
513            Forbidden,
514            "Reviewed submission does not belong to the requested exercise".to_string()
515        ));
516    }
517
518    let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
519        &mut conn,
520        &exercise,
521        exercise.get_course_id()?,
522    )
523    .await?;
524    if peer_or_self_review_config.id != payload.peer_or_self_review_config_id {
525        return Err(controller_err!(
526            Forbidden,
527            "Peer review configuration does not belong to the requested exercise".to_string()
528        ));
529    }
530
531    if let Some(receiver_course_id) = exercise_slide_submission.course_id {
532        let receiver_user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
533            &mut conn,
534            exercise_slide_submission.user_id,
535            exercise.id,
536            CourseOrExamId::Course(receiver_course_id),
537        )
538        .await?;
539        if let Some(receiver_user_exercise_state) = receiver_user_exercise_state {
540            let mut tx = conn.begin().await?;
541
542            models::library::peer_or_self_reviewing::create_peer_or_self_review_submission_for_user(
543                &mut tx,
544                &exercise,
545                giver_user_exercise_state,
546                receiver_user_exercise_state,
547                payload,
548            )
549            .await?;
550
551            // Get updater receiver state after possible update above
552            let updated_receiver_state = user_exercise_states::get_user_exercise_state_if_exists(
553                &mut tx,
554                exercise_slide_submission.user_id,
555                exercise.id,
556                CourseOrExamId::Course(receiver_course_id),
557            )
558            .await?
559            .ok_or_else(|| {
560                ModelError::new(
561                    ModelErrorType::Generic,
562                    "Receiver exercise state not found".to_string(),
563                    None,
564                )
565            })?;
566
567            let _ = models::library::peer_or_self_reviewing::reset_exercise_if_needed_if_zero_points_from_review(
568                &mut tx,
569                &peer_or_self_review_config,
570                &updated_receiver_state,
571            ).await?;
572
573            tx.commit().await?;
574        } else {
575            warn!(
576                "No user exercise state found for receiver's exercise slide submission id: {}",
577                exercise_slide_submission.id
578            );
579            return Err(ControllerError::new(
580                ControllerErrorType::BadRequest,
581                "No user exercise state found for receiver's exercise slide submission."
582                    .to_string(),
583                None,
584            ));
585        }
586    } else {
587        warn!(
588            "No course instance id found for receiver's exercise slide submission id: {}",
589            exercise_slide_submission.id
590        );
591        return Err(ControllerError::new(
592            ControllerErrorType::BadRequest,
593            "No course instance id found for receiver's exercise slide submission.".to_string(),
594            None,
595        ));
596    }
597    let token = skip_authorize();
598    token.authorized_ok(web::Json(true))
599}
600
601/**
602 * POST `/api/v0/course-material/exercises/:exercise_id/flag-peer-review-answer - Post a report of an answer in peer review made by a student
603 */
604#[utoipa::path(
605    post,
606    path = "/{exercise_id}/flag-peer-review-answer",
607    operation_id = "postFlagAnswerInPeerReview",
608    tag = "course-material-exercises",
609    params(
610        ("exercise_id" = Uuid, Path, description = "Exercise id")
611    ),
612    request_body = NewFlaggedAnswerWithToken,
613    responses(
614        (status = 200, description = "Created flagged answer", body = FlaggedAnswer)
615    )
616)]
617#[instrument(skip(pool))]
618async fn post_flag_answer_in_peer_review(
619    pool: web::Data<PgPool>,
620    payload: web::Json<NewFlaggedAnswerWithToken>,
621    user: AuthUser,
622    jwt_key: web::Data<JwtKey>,
623) -> ControllerResult<web::Json<FlaggedAnswer>> {
624    let mut conn = pool.acquire().await?;
625
626    let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
627    if claim.exercise_slide_submission_id != payload.submission_id
628        || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
629    {
630        return Err(ControllerError::new(
631            ControllerErrorType::BadRequest,
632            "You are not allowed to report this answer.".to_string(),
633            None,
634        ));
635    }
636
637    let insert_result =
638        models::flagged_answers::insert_flagged_answer_and_move_to_manual_review_if_needed(
639            &mut conn,
640            payload.into_inner(),
641            user.id,
642        )
643        .await?;
644
645    let token = skip_authorize();
646    token.authorized_ok(web::Json(insert_result))
647}
648
649/**
650Add a route for each controller in this module.
651
652The name starts with an underline in order to appear before other functions in the module documentation.
653
654We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
655*/
656pub fn _add_routes(cfg: &mut ServiceConfig) {
657    cfg.route("/{exercise_id}", web::get().to(get_exercise))
658        .route(
659            "/{exercise_id}/peer-or-self-reviews",
660            web::post().to(submit_peer_or_self_review),
661        )
662        .route(
663            "/{exercise_id}/peer-or-self-reviews/start",
664            web::post().to(start_peer_or_self_review),
665        )
666        .route(
667            "/{exercise_id}/peer-review",
668            web::get().to(get_peer_review_for_exercise),
669        )
670        .route(
671            "/{exercise_id}/exercise-slide-submission/{exercise_slide_submission_id}/peer-or-self-reviews-received",
672            web::get().to(get_peer_reviews_received),
673        )
674        .route(
675            "/{exercise_id}/submissions",
676            web::post().to(post_submission),
677        ).route(
678            "/{exercise_id}/flag-peer-review-answer",
679            web::post().to(post_flag_answer_in_peer_review),
680        );
681}