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_enrollment =
60            models::exams::get_enrollment(&mut conn, exam_id, user_id.unwrap()).await?;
61
62        if let Some(enrollment) = user_enrollment {
63            if let Some(show_answers) = enrollment.show_exercise_answers {
64                if enrollment.is_teacher_testing && show_answers {
65                    should_clear_grading_information = false;
66                }
67            }
68        }
69    }
70
71    if course_material_exercise.can_post_submission
72        && course_material_exercise.exercise.exam_id.is_some()
73        && should_clear_grading_information
74    {
75        // Explicitely clear grading information from ongoing exam submissions.
76        course_material_exercise.clear_grading_information();
77    }
78
79    let score_given: f32 = if let Some(status) = &course_material_exercise.exercise_status {
80        status.score_given.unwrap_or(0.0)
81    } else {
82        0.0
83    };
84
85    let submission_count = course_material_exercise
86        .exercise_slide_submission_counts
87        .get(&course_material_exercise.current_exercise_slide.id)
88        .unwrap_or(&0);
89
90    let out_of_tries = course_material_exercise.exercise.limit_number_of_tries
91        && *submission_count as i32
92            >= course_material_exercise
93                .exercise
94                .max_tries_per_slide
95                .unwrap_or(i32::MAX);
96
97    // 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.
98    // TODO: this uses points for the whole exercise, change this to slide points when slide grading finalized
99    let has_received_full_points = score_given
100        >= course_material_exercise.exercise.score_maximum as f32
101        || (score_given - course_material_exercise.exercise.score_maximum as f32).abs() < 0.0001;
102    if !has_received_full_points && !out_of_tries {
103        course_material_exercise.clear_model_solution_specs();
104    }
105    let token = skip_authorize();
106    token.authorized_ok(web::Json(course_material_exercise))
107}
108
109/**
110GET `/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
111
112This 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.
113*/
114#[instrument(skip(pool))]
115async fn get_peer_review_for_exercise(
116    pool: web::Data<PgPool>,
117    exercise_id: web::Path<Uuid>,
118    user: AuthUser,
119    jwt_key: web::Data<JwtKey>,
120) -> ControllerResult<web::Json<CourseMaterialPeerOrSelfReviewDataWithToken>> {
121    let mut conn = pool.acquire().await?;
122    let course_material_peer_or_self_review_data =
123        models::peer_or_self_review_configs::get_course_material_peer_or_self_review_data(
124            &mut conn,
125            user.id,
126            *exercise_id,
127            models_requests::fetch_service_info,
128        )
129        .await?;
130    let token = authorize(
131        &mut conn,
132        Act::View,
133        Some(user.id),
134        Res::Exercise(*exercise_id),
135    )
136    .await?;
137    let give_peer_review_claim =
138        if let Some(to_review) = &course_material_peer_or_self_review_data.answer_to_review {
139            Some(
140                GivePeerReviewClaim::expiring_in_1_day(
141                    to_review.exercise_slide_submission_id,
142                    course_material_peer_or_self_review_data
143                        .peer_or_self_review_config
144                        .id,
145                )
146                .sign(&jwt_key),
147            )
148        } else {
149            None
150        };
151
152    let res = CourseMaterialPeerOrSelfReviewDataWithToken {
153        course_material_peer_or_self_review_data,
154        token: give_peer_review_claim,
155    };
156    token.authorized_ok(web::Json(res))
157}
158
159/**
160GET `/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.
161*/
162#[instrument(skip(pool))]
163async fn get_peer_reviews_received(
164    pool: web::Data<PgPool>,
165    params: web::Path<(Uuid, Uuid)>,
166    user: AuthUser,
167) -> ControllerResult<web::Json<PeerOrSelfReviewsReceived>> {
168    let mut conn = pool.acquire().await?;
169    let (exercise_id, exercise_slide_submission_id) = params.into_inner();
170    let peer_review_data = models::exercise_task_submissions::get_peer_reviews_received(
171        &mut conn,
172        exercise_id,
173        exercise_slide_submission_id,
174        user.id,
175    )
176    .await?;
177    let token = skip_authorize();
178    token.authorized_ok(web::Json(peer_review_data))
179}
180
181/**
182POST `/api/v0/course-material/exercises/:exercise_id/submissions` - Post new submission for an
183exercise.
184
185# Example
186```http
187POST /api/v0/course-material/exercises/:exercise_id/submissions HTTP/1.1
188Content-Type: application/json
189
190{
191  "exercise_slide_id": "0125c21b-6afa-4652-89f7-56c48bd8ffe4",
192  "exercise_task_answers": [
193    {
194      "exercise_task_id": "0125c21b-6afa-4652-89f7-56c48bd8ffe4",
195      "data_json": { "selectedOptionId": "8f09e9a0-ac20-486a-ba29-704e7eeaf6af" }
196    }
197  ]
198}
199```
200*/
201#[instrument(skip(pool))]
202async fn post_submission(
203    pool: web::Data<PgPool>,
204    jwt_key: web::Data<JwtKey>,
205    exercise_id: web::Path<Uuid>,
206    payload: web::Json<StudentExerciseSlideSubmission>,
207    user: AuthUser,
208) -> ControllerResult<web::Json<StudentExerciseSlideSubmissionResult>> {
209    let submission = payload.0;
210    let mut conn = pool.acquire().await?;
211    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
212    let token = authorize(
213        &mut conn,
214        Act::View,
215        Some(user.id),
216        Res::Exercise(exercise.id),
217    )
218    .await?;
219    let result = domain::exercises::process_submission(
220        &mut conn,
221        user.id,
222        exercise.clone(),
223        &submission,
224        jwt_key.into_inner(),
225    )
226    .await?;
227    token.authorized_ok(web::Json(result))
228}
229
230/**
231 * POST `/api/v0/course-material/exercises/:exercise_id/peer-or-self-reviews/start` - Post a signal indicating that
232 * the user will start the peer or self reviewing process.
233 *
234 * This operation is only valid for exercises marked for peer reviews. No further submissions will be
235 * accepted after posting to this endpoint.
236 */
237#[instrument(skip(pool))]
238async fn start_peer_or_self_review(
239    pool: web::Data<PgPool>,
240    exercise_id: web::Path<Uuid>,
241    user: AuthUser,
242) -> ControllerResult<web::Json<bool>> {
243    let mut conn = pool.acquire().await?;
244
245    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
246    let user_exercise_state =
247        user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
248    let token = authorize(
249        &mut conn,
250        Act::View,
251        Some(user.id),
252        Res::Exercise(*exercise_id),
253    )
254    .await?;
255    models::library::peer_or_self_reviewing::start_peer_or_self_review_for_user(
256        &mut conn,
257        user_exercise_state,
258        &exercise,
259    )
260    .await?;
261
262    token.authorized_ok(web::Json(true))
263}
264
265/**
266 * POST `/api/v0/course-material/exercises/:exercise_id/peer-or-self-reviews - Post a peer review or a self review for an
267 * exercise submission.
268 */
269#[instrument(skip(pool))]
270async fn submit_peer_or_self_review(
271    pool: web::Data<PgPool>,
272    exercise_id: web::Path<Uuid>,
273    payload: web::Json<CourseMaterialPeerOrSelfReviewSubmission>,
274    user: AuthUser,
275    jwt_key: web::Data<JwtKey>,
276) -> ControllerResult<web::Json<bool>> {
277    let mut conn = pool.acquire().await?;
278    let exercise = models::exercises::get_by_id(&mut conn, *exercise_id).await?;
279    // 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.
280    // The validation prevents users from chaging which answer they peer review.
281    let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
282    if claim.exercise_slide_submission_id != payload.exercise_slide_submission_id
283        || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
284    {
285        return Err(ControllerError::new(
286            ControllerErrorType::BadRequest,
287            "You are not allowed to review this answer.".to_string(),
288            None,
289        ));
290    }
291
292    let giver_user_exercise_state =
293        user_exercise_states::get_users_current_by_exercise(&mut conn, user.id, &exercise).await?;
294    let exercise_slide_submission: models::exercise_slide_submissions::ExerciseSlideSubmission =
295        models::exercise_slide_submissions::get_by_id(
296            &mut conn,
297            payload.exercise_slide_submission_id,
298        )
299        .await?;
300
301    if let Some(receiver_course_id) = exercise_slide_submission.course_id {
302        let receiver_user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
303            &mut conn,
304            exercise_slide_submission.user_id,
305            exercise.id,
306            CourseOrExamId::Course(receiver_course_id),
307        )
308        .await?;
309        if let Some(receiver_user_exercise_state) = receiver_user_exercise_state {
310            let mut tx = conn.begin().await?;
311
312            models::library::peer_or_self_reviewing::create_peer_or_self_review_submission_for_user(
313            &mut tx,
314            &exercise,
315            giver_user_exercise_state,
316            receiver_user_exercise_state,
317            payload.0,
318        )
319        .await?;
320
321            // Get updater receiver state after possible update above
322            let updated_receiver_state = user_exercise_states::get_user_exercise_state_if_exists(
323                &mut tx,
324                exercise_slide_submission.user_id,
325                exercise.id,
326                CourseOrExamId::Course(receiver_course_id),
327            )
328            .await?
329            .ok_or_else(|| {
330                ModelError::new(
331                    ModelErrorType::Generic,
332                    "Receiver exercise state not found".to_string(),
333                    None,
334                )
335            })?;
336
337            let peer_or_self_review_config =
338                peer_or_self_review_configs::get_by_exercise_or_course_id(
339                    &mut tx,
340                    &exercise,
341                    exercise.get_course_id()?,
342                )
343                .await?;
344
345            let _ = models::library::peer_or_self_reviewing::reset_exercise_if_needed_if_zero_points_from_review(
346                &mut tx,
347                &peer_or_self_review_config,
348                &updated_receiver_state,
349            ).await?;
350
351            tx.commit().await?;
352        } else {
353            warn!(
354                "No user exercise state found for receiver's exercise slide submission id: {}",
355                exercise_slide_submission.id
356            );
357            return Err(ControllerError::new(
358                ControllerErrorType::BadRequest,
359                "No user exercise state found for receiver's exercise slide submission."
360                    .to_string(),
361                None,
362            ));
363        }
364    } else {
365        warn!(
366            "No course instance id found for receiver's exercise slide submission id: {}",
367            exercise_slide_submission.id
368        );
369        return Err(ControllerError::new(
370            ControllerErrorType::BadRequest,
371            "No course instance id found for receiver's exercise slide submission.".to_string(),
372            None,
373        ));
374    }
375    let token = skip_authorize();
376    token.authorized_ok(web::Json(true))
377}
378
379/**
380 * 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
381 */
382#[instrument(skip(pool))]
383async fn post_flag_answer_in_peer_review(
384    pool: web::Data<PgPool>,
385    payload: web::Json<NewFlaggedAnswerWithToken>,
386    user: AuthUser,
387    jwt_key: web::Data<JwtKey>,
388) -> ControllerResult<web::Json<FlaggedAnswer>> {
389    let mut conn = pool.acquire().await?;
390
391    let claim = GivePeerReviewClaim::validate(&payload.token, &jwt_key)?;
392    if claim.exercise_slide_submission_id != payload.submission_id
393        || claim.peer_or_self_review_config_id != payload.peer_or_self_review_config_id
394    {
395        return Err(ControllerError::new(
396            ControllerErrorType::BadRequest,
397            "You are not allowed to report this answer.".to_string(),
398            None,
399        ));
400    }
401
402    let insert_result =
403        models::flagged_answers::insert_flagged_answer_and_move_to_manual_review_if_needed(
404            &mut conn,
405            payload.into_inner(),
406            user.id,
407        )
408        .await?;
409
410    let token = skip_authorize();
411    token.authorized_ok(web::Json(insert_result))
412}
413
414/**
415Add a route for each controller in this module.
416
417The name starts with an underline in order to appear before other functions in the module documentation.
418
419We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
420*/
421pub fn _add_routes(cfg: &mut ServiceConfig) {
422    cfg.route("/{exercise_id}", web::get().to(get_exercise))
423        .route(
424            "/{exercise_id}/peer-or-self-reviews",
425            web::post().to(submit_peer_or_self_review),
426        )
427        .route(
428            "/{exercise_id}/peer-or-self-reviews/start",
429            web::post().to(start_peer_or_self_review),
430        )
431        .route(
432            "/{exercise_id}/peer-review",
433            web::get().to(get_peer_review_for_exercise),
434        )
435        .route(
436            "/{exercise_id}/exercise-slide-submission/{exercise_slide_submission_id}/peer-or-self-reviews-received",
437            web::get().to(get_peer_reviews_received),
438        )
439        .route(
440            "/{exercise_id}/submissions",
441            web::post().to(post_submission),
442        ).route(
443            "/{exercise_id}/flag-peer-review-answer",
444            web::post().to(post_flag_answer_in_peer_review),
445        );
446}