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