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        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 payload = payload.into_inner();
442    let exercise = models::exercises::get_non_deleted_by_id(&mut conn, *exercise_id).await?;
443
444    if let Some(chapter_id) = exercise.chapter_id {
445        let course_id = models::chapters::get_course_id(&mut conn, chapter_id).await?;
446        let is_accessible = user_chapter_locking_statuses::is_chapter_accessible(
447            &mut conn, user.id, chapter_id, course_id,
448        )
449        .await?;
450        if !is_accessible {
451            return Err(ControllerError::new(
452                ControllerErrorType::Forbidden,
453                "Complete and lock the previous chapter to unlock exercises in this chapter."
454                    .to_string(),
455                None,
456            ));
457        }
458        let exercises_locked = user_chapter_locking_statuses::is_chapter_exercises_locked(
459            &mut conn, user.id, chapter_id, course_id,
460        )
461        .await?;
462        if exercises_locked {
463            return Err(ControllerError::new(
464                ControllerErrorType::Forbidden,
465                "The current chapter is locked, and you can no longer submit exercises."
466                    .to_string(),
467                None,
468            ));
469        }
470    }
471
472    // 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.
473    // The validation prevents users from chaging which answer they peer review.
474    let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
475    if claim.exercise_slide_submission_id != payload.exercise_slide_submission_id
476        || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
477    {
478        return Err(ControllerError::new(
479            ControllerErrorType::BadRequest,
480            "You are not allowed to review this answer.".to_string(),
481            None,
482        ));
483    }
484
485    let giver_user_exercise_state =
486        user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
487    let exercise_slide_submission: models::exercise_slide_submissions::ExerciseSlideSubmission =
488        models::exercise_slide_submissions::get_by_id(
489            &mut conn,
490            payload.exercise_slide_submission_id,
491        )
492        .await?;
493    if exercise_slide_submission.exercise_id != exercise.id
494        || exercise_slide_submission.course_id != exercise.course_id
495    {
496        return Err(controller_err!(
497            Forbidden,
498            "Reviewed submission does not belong to the requested exercise".to_string()
499        ));
500    }
501
502    let peer_or_self_review_config = peer_or_self_review_configs::get_by_exercise_or_course_id(
503        &mut conn,
504        &exercise,
505        exercise.get_course_id()?,
506    )
507    .await?;
508    if peer_or_self_review_config.id != payload.peer_or_self_review_config_id {
509        return Err(controller_err!(
510            Forbidden,
511            "Peer review configuration does not belong to the requested exercise".to_string()
512        ));
513    }
514
515    if let Some(receiver_course_id) = exercise_slide_submission.course_id {
516        let receiver_user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
517            &mut conn,
518            exercise_slide_submission.user_id,
519            exercise.id,
520            CourseOrExamId::Course(receiver_course_id),
521        )
522        .await?;
523        if let Some(receiver_user_exercise_state) = receiver_user_exercise_state {
524            let mut tx = conn.begin().await?;
525
526            models::library::peer_or_self_reviewing::create_peer_or_self_review_submission_for_user(
527                &mut tx,
528                &exercise,
529                giver_user_exercise_state,
530                receiver_user_exercise_state,
531                payload,
532            )
533            .await?;
534
535            // Get updater receiver state after possible update above
536            let updated_receiver_state = user_exercise_states::get_user_exercise_state_if_exists(
537                &mut tx,
538                exercise_slide_submission.user_id,
539                exercise.id,
540                CourseOrExamId::Course(receiver_course_id),
541            )
542            .await?
543            .ok_or_else(|| {
544                ModelError::new(
545                    ModelErrorType::Generic,
546                    "Receiver exercise state not found".to_string(),
547                    None,
548                )
549            })?;
550
551            let _ = models::library::peer_or_self_reviewing::reset_exercise_if_needed_if_zero_points_from_review(
552                &mut tx,
553                &peer_or_self_review_config,
554                &updated_receiver_state,
555            ).await?;
556
557            tx.commit().await?;
558        } else {
559            warn!(
560                "No user exercise state found for receiver's exercise slide submission id: {}",
561                exercise_slide_submission.id
562            );
563            return Err(ControllerError::new(
564                ControllerErrorType::BadRequest,
565                "No user exercise state found for receiver's exercise slide submission."
566                    .to_string(),
567                None,
568            ));
569        }
570    } else {
571        warn!(
572            "No course instance id found for receiver's exercise slide submission id: {}",
573            exercise_slide_submission.id
574        );
575        return Err(ControllerError::new(
576            ControllerErrorType::BadRequest,
577            "No course instance id found for receiver's exercise slide submission.".to_string(),
578            None,
579        ));
580    }
581    let token = skip_authorize();
582    token.authorized_ok(web::Json(true))
583}
584
585/**
586 * 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
587 */
588#[utoipa::path(
589    post,
590    path = "/{exercise_id}/flag-peer-review-answer",
591    operation_id = "postFlagAnswerInPeerReview",
592    tag = "course-material-exercises",
593    params(
594        ("exercise_id" = Uuid, Path, description = "Exercise id")
595    ),
596    request_body = NewFlaggedAnswerWithToken,
597    responses(
598        (status = 200, description = "Created flagged answer", body = FlaggedAnswer)
599    )
600)]
601#[instrument(skip(pool))]
602async fn post_flag_answer_in_peer_review(
603    pool: web::Data<PgPool>,
604    payload: web::Json<NewFlaggedAnswerWithToken>,
605    user: AuthUser,
606    jwt_key: web::Data<JwtKey>,
607) -> ControllerResult<web::Json<FlaggedAnswer>> {
608    let mut conn = pool.acquire().await?;
609
610    let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
611    if claim.exercise_slide_submission_id != payload.submission_id
612        || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
613    {
614        return Err(ControllerError::new(
615            ControllerErrorType::BadRequest,
616            "You are not allowed to report this answer.".to_string(),
617            None,
618        ));
619    }
620
621    let insert_result =
622        models::flagged_answers::insert_flagged_answer_and_move_to_manual_review_if_needed(
623            &mut conn,
624            payload.into_inner(),
625            user.id,
626        )
627        .await?;
628
629    let token = skip_authorize();
630    token.authorized_ok(web::Json(insert_result))
631}
632
633/**
634Add a route for each controller in this module.
635
636The name starts with an underline in order to appear before other functions in the module documentation.
637
638We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
639*/
640pub fn _add_routes(cfg: &mut ServiceConfig) {
641    cfg.route("/{exercise_id}", web::get().to(get_exercise))
642        .route(
643            "/{exercise_id}/peer-or-self-reviews",
644            web::post().to(submit_peer_or_self_review),
645        )
646        .route(
647            "/{exercise_id}/peer-or-self-reviews/start",
648            web::post().to(start_peer_or_self_review),
649        )
650        .route(
651            "/{exercise_id}/peer-review",
652            web::get().to(get_peer_review_for_exercise),
653        )
654        .route(
655            "/{exercise_id}/exercise-slide-submission/{exercise_slide_submission_id}/peer-or-self-reviews-received",
656            web::get().to(get_peer_reviews_received),
657        )
658        .route(
659            "/{exercise_id}/submissions",
660            web::post().to(post_submission),
661        ).route(
662            "/{exercise_id}/flag-peer-review-answer",
663            web::post().to(post_flag_answer_in_peer_review),
664        );
665}