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