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_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    let token = authorize(
220        &mut conn,
221        Act::View,
222        Some(user.id),
223        Res::Exercise(exercise.id),
224    )
225    .await?;
226    let result = domain::exercises::process_submission(
227        &mut conn,
228        user.id,
229        exercise.clone(),
230        &submission,
231        jwt_key.into_inner(),
232    )
233    .await?;
234    token.authorized_ok(web::Json(result))
235}
236
237/**
238 * POST `/api/v0/course-material/exercises/:exercise_id/peer-or-self-reviews/start` - Post a signal indicating that
239 * the user will start the peer or self reviewing process.
240 *
241 * This operation is only valid for exercises marked for peer reviews. No further submissions will be
242 * accepted after posting to this endpoint.
243 */
244#[instrument(skip(pool))]
245async fn start_peer_or_self_review(
246    pool: web::Data<PgPool>,
247    exercise_id: web::Path<Uuid>,
248    user: AuthUser,
249) -> ControllerResult<web::Json<bool>> {
250    let mut conn = pool.acquire().await?;
251
252    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
253    let user_exercise_state =
254        user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
255    let token = authorize(
256        &mut conn,
257        Act::View,
258        Some(user.id),
259        Res::Exercise(*exercise_id),
260    )
261    .await?;
262    models::library::peer_or_self_reviewing::start_peer_or_self_review_for_user(
263        &mut conn,
264        user_exercise_state,
265        &exercise,
266    )
267    .await?;
268
269    token.authorized_ok(web::Json(true))
270}
271
272/**
273 * POST `/api/v0/course-material/exercises/:exercise_id/peer-or-self-reviews - Post a peer review or a self review for an
274 * exercise submission.
275 */
276#[instrument(skip(pool))]
277async fn submit_peer_or_self_review(
278    pool: web::Data<PgPool>,
279    exercise_id: web::Path<Uuid>,
280    payload: web::Json<CourseMaterialPeerOrSelfReviewSubmission>,
281    user: AuthUser,
282    jwt_key: web::Data<JwtKey>,
283) -> ControllerResult<web::Json<bool>> {
284    let mut conn = pool.acquire().await?;
285    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
286    // 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.
287    // The validation prevents users from chaging which answer they peer review.
288    let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
289    if claim.exercise_slide_submission_id != payload.exercise_slide_submission_id
290        || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
291    {
292        return Err(ControllerError::new(
293            ControllerErrorType::BadRequest,
294            "You are not allowed to review this answer.".to_string(),
295            None,
296        ));
297    }
298
299    let giver_user_exercise_state =
300        user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
301    let exercise_slide_submission: models::exercise_slide_submissions::ExerciseSlideSubmission =
302        models::exercise_slide_submissions::get_by_id(
303            &mut conn,
304            payload.exercise_slide_submission_id,
305        )
306        .await?;
307
308    if let Some(receiver_course_id) = exercise_slide_submission.course_id {
309        let receiver_user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
310            &mut conn,
311            exercise_slide_submission.user_id,
312            exercise.id,
313            CourseOrExamId::Course(receiver_course_id),
314        )
315        .await?;
316        if let Some(receiver_user_exercise_state) = receiver_user_exercise_state {
317            let mut tx = conn.begin().await?;
318
319            models::library::peer_or_self_reviewing::create_peer_or_self_review_submission_for_user(
320            &mut tx,
321            &exercise,
322            giver_user_exercise_state,
323            receiver_user_exercise_state,
324            payload.0,
325        )
326        .await?;
327
328            // Get updater receiver state after possible update above
329            let updated_receiver_state = user_exercise_states::get_user_exercise_state_if_exists(
330                &mut tx,
331                exercise_slide_submission.user_id,
332                exercise.id,
333                CourseOrExamId::Course(receiver_course_id),
334            )
335            .await?
336            .ok_or_else(|| {
337                ModelError::new(
338                    ModelErrorType::Generic,
339                    "Receiver exercise state not found".to_string(),
340                    None,
341                )
342            })?;
343
344            let peer_or_self_review_config =
345                peer_or_self_review_configs::get_by_exercise_or_course_id(
346                    &mut tx,
347                    &exercise,
348                    exercise.get_course_id()?,
349                )
350                .await?;
351
352            let _ = models::library::peer_or_self_reviewing::reset_exercise_if_needed_if_zero_points_from_review(
353                &mut tx,
354                &peer_or_self_review_config,
355                &updated_receiver_state,
356            ).await?;
357
358            tx.commit().await?;
359        } else {
360            warn!(
361                "No user exercise state found for receiver's exercise slide submission id: {}",
362                exercise_slide_submission.id
363            );
364            return Err(ControllerError::new(
365                ControllerErrorType::BadRequest,
366                "No user exercise state found for receiver's exercise slide submission."
367                    .to_string(),
368                None,
369            ));
370        }
371    } else {
372        warn!(
373            "No course instance id found for receiver's exercise slide submission id: {}",
374            exercise_slide_submission.id
375        );
376        return Err(ControllerError::new(
377            ControllerErrorType::BadRequest,
378            "No course instance id found for receiver's exercise slide submission.".to_string(),
379            None,
380        ));
381    }
382    let token = skip_authorize();
383    token.authorized_ok(web::Json(true))
384}
385
386/**
387 * 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
388 */
389#[instrument(skip(pool))]
390async fn post_flag_answer_in_peer_review(
391    pool: web::Data<PgPool>,
392    payload: web::Json<NewFlaggedAnswerWithToken>,
393    user: AuthUser,
394    jwt_key: web::Data<JwtKey>,
395) -> ControllerResult<web::Json<FlaggedAnswer>> {
396    let mut conn = pool.acquire().await?;
397
398    let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
399    if claim.exercise_slide_submission_id != payload.submission_id
400        || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
401    {
402        return Err(ControllerError::new(
403            ControllerErrorType::BadRequest,
404            "You are not allowed to report this answer.".to_string(),
405            None,
406        ));
407    }
408
409    let insert_result =
410        models::flagged_answers::insert_flagged_answer_and_move_to_manual_review_if_needed(
411            &mut conn,
412            payload.into_inner(),
413            user.id,
414        )
415        .await?;
416
417    let token = skip_authorize();
418    token.authorized_ok(web::Json(insert_result))
419}
420
421/**
422Add a route for each controller in this module.
423
424The name starts with an underline in order to appear before other functions in the module documentation.
425
426We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
427*/
428pub fn _add_routes(cfg: &mut ServiceConfig) {
429    cfg.route("/{exercise_id}", web::get().to(get_exercise))
430        .route(
431            "/{exercise_id}/peer-or-self-reviews",
432            web::post().to(submit_peer_or_self_review),
433        )
434        .route(
435            "/{exercise_id}/peer-or-self-reviews/start",
436            web::post().to(start_peer_or_self_review),
437        )
438        .route(
439            "/{exercise_id}/peer-review",
440            web::get().to(get_peer_review_for_exercise),
441        )
442        .route(
443            "/{exercise_id}/exercise-slide-submission/{exercise_slide_submission_id}/peer-or-self-reviews-received",
444            web::get().to(get_peer_reviews_received),
445        )
446        .route(
447            "/{exercise_id}/submissions",
448            web::post().to(post_submission),
449        ).route(
450            "/{exercise_id}/flag-peer-review-answer",
451            web::post().to(post_flag_answer_in_peer_review),
452        );
453}