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