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