Skip to main content

headless_lms_server/controllers/exercise_services/
grading.rs

1use models::exercise_task_gradings::ExerciseTaskGradingResult;
2
3use crate::{domain::models_requests::GradingUpdateClaim, prelude::*};
4
5/**
6POST `/api/v0/exercise-services/grading/grading-update/:submission_id`
7
8Receives a grading update from an exercise service.
9*/
10#[utoipa::path(
11    post,
12    path = "/grading-update/{submission_id}",
13    operation_id = "updateExerciseTaskGrading",
14    tag = "exercise-services-grading",
15    params(
16        ("submission_id" = Uuid, Path, description = "Exercise task submission id")
17    ),
18    request_body = ExerciseTaskGradingResult,
19    responses(
20        (status = 200, description = "Grading update applied")
21    )
22)]
23#[instrument(skip(pool, file_store, app_conf))]
24async fn grading_update(
25    submission_id: web::Path<Uuid>,
26    grading_result: web::Json<ExerciseTaskGradingResult>,
27    grading_update_claim: GradingUpdateClaim,
28    pool: web::Data<PgPool>,
29    file_store: web::Data<dyn FileStore>,
30    app_conf: web::Data<ApplicationConfiguration>,
31) -> ControllerResult<web::Json<()>> {
32    // accessed from exercise services, can't authenticate using login,
33    // the upload claim is used to verify requests instead
34    let token = skip_authorize();
35    let grading_result = grading_result.into_inner();
36
37    // Ensure that the claim is valid for this specific submission
38    verify_claim_matches_submission(*submission_id, grading_update_claim.submission_id())?;
39
40    let mut conn = pool.acquire().await?;
41    apply_grading_update(
42        &mut conn,
43        *submission_id,
44        &grading_result,
45        file_store.as_ref(),
46        app_conf.as_ref(),
47    )
48    .await?;
49
50    token.authorized_ok(web::Json(()))
51}
52
53/// Rejects a grading update whose signed claim authorizes a different submission than the one
54/// addressed by the URL path.
55fn verify_claim_matches_submission(
56    submission_id: Uuid,
57    claim_submission_id: Uuid,
58) -> Result<(), ControllerError> {
59    if submission_id != claim_submission_id {
60        return Err(controller_err!(
61            BadRequest,
62            "Grading upload claim didn't match the submission id".to_string()
63        ));
64    }
65    Ok(())
66}
67
68/// Applies a grading result to the submission's existing grading.
69async fn apply_grading_update(
70    conn: &mut PgConnection,
71    submission_id: Uuid,
72    grading_result: &ExerciseTaskGradingResult,
73    file_store: &dyn FileStore,
74    app_conf: &ApplicationConfiguration,
75) -> Result<(), ControllerError> {
76    let submission = models::exercise_task_submissions::get_submission(
77        conn,
78        submission_id,
79        file_store,
80        app_conf,
81    )
82    .await?;
83    let slide =
84        models::exercise_slides::get_exercise_slide(conn, submission.exercise_slide_id).await?;
85    // The submission's current grading, not any grading of it: a regrading adds a row per run.
86    let grading_id = submission.exercise_task_grading_id.ok_or_else(|| {
87        controller_err!(
88            BadRequest,
89            "No existing grading for the submission found".to_string()
90        )
91    })?;
92    let grading = models::exercise_task_gradings::get_by_id(conn, grading_id).await?;
93    let exercise = models::exercises::get_by_id(conn, slide.exercise_id).await?;
94    let mut tx = conn.begin().await?;
95    models::library::grading::apply_grading_update(
96        &mut tx,
97        &exercise,
98        &submission,
99        &grading,
100        grading_result,
101    )
102    .await?;
103    tx.commit().await?;
104    Ok(())
105}
106
107/**
108Add a route for each controller in this module.
109
110The name starts with an underline in order to appear before other functions in the module documentation.
111
112We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
113*/
114#[doc(hidden)]
115pub fn _add_routes(cfg: &mut ServiceConfig) {
116    cfg.route(
117        "grading-update/{submission_id}",
118        web::post().to(grading_update),
119    );
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::test_helper::*;
126    use actix_web::ResponseError;
127    use actix_web::http::StatusCode;
128    use futures_util::FutureExt;
129    use models::exercise_slide_submissions::NewExerciseSlideSubmission;
130    use models::exercise_task_gradings::UserPointsUpdateStrategy;
131    use models::exercises::GradingProgress;
132
133    fn grading_result() -> ExerciseTaskGradingResult {
134        ExerciseTaskGradingResult {
135            grading_progress: GradingProgress::FullyGraded,
136            score_given: 1.0,
137            score_maximum: 1,
138            feedback_text: Some("well done".to_string()),
139            feedback_json: None,
140            set_user_variables: None,
141        }
142    }
143
144    /// An exercise service must not be able to use a claim signed for one submission to
145    /// overwrite the grading of another; the path id and the claim's id have to agree.
146    #[test]
147    fn claim_for_another_submission_is_rejected() {
148        let submission_id = Uuid::new_v4();
149        let other_submission_id = Uuid::new_v4();
150        let err = verify_claim_matches_submission(submission_id, other_submission_id)
151            .expect_err("a claim for another submission must be rejected");
152        assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
153        let value = error_body(err);
154        assert_eq!(value["type"], "validation_error");
155        assert!(
156            value["message"]
157                .as_str()
158                .unwrap_or_default()
159                .contains("didn't match the submission id"),
160            "unexpected message: {}",
161            value["message"]
162        );
163    }
164
165    #[test]
166    fn claim_for_the_same_submission_is_accepted() {
167        let submission_id = Uuid::new_v4();
168        assert!(verify_claim_matches_submission(submission_id, submission_id).is_ok());
169    }
170
171    /// A grading update for a submission that was never sent out for grading has nothing to
172    /// update; it must be a client error rather than a panic or a silent no-op.
173    #[actix_web::test]
174    async fn update_without_an_existing_grading_is_rejected() {
175        insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, :task);
176        let submission_id =
177            insert_task_submission(&mut tx, user, course, exercise, slide, task).await;
178
179        let err = apply_grading_update(
180            tx.as_mut(),
181            submission_id,
182            &grading_result(),
183            &crate::test_helper::init_file_store(),
184            &crate::test_helper::init_app_conf().expect("app conf"),
185        )
186        .await
187        .expect_err("a submission without a grading row must be rejected");
188        assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
189        let value = error_body(err);
190        assert!(
191            value["message"]
192                .as_str()
193                .unwrap_or_default()
194                .contains("No existing grading for the submission found"),
195            "unexpected message: {}",
196            value["message"]
197        );
198    }
199
200    /// Positive control for the test above: with a grading row present, the same call writes
201    /// the result through.
202    #[actix_web::test]
203    async fn update_with_an_existing_grading_writes_the_result_and_the_points() {
204        insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, :task);
205        let submission_id =
206            insert_task_submission(&mut tx, user, course, exercise, slide, task).await;
207        let grading_id = models::exercise_task_gradings::insert(
208            tx.as_mut(),
209            models::PKeyPolicy::Generate,
210            submission_id,
211            course,
212            exercise,
213            task,
214        )
215        .await
216        .unwrap();
217        models::exercise_task_submissions::set_grading_id(tx.as_mut(), grading_id, submission_id)
218            .await
219            .unwrap();
220
221        apply_grading_update(
222            tx.as_mut(),
223            submission_id,
224            &grading_result(),
225            &crate::test_helper::init_file_store(),
226            &crate::test_helper::init_app_conf().expect("app conf"),
227        )
228        .await
229        .expect("the grading update should be applied");
230
231        let grading = models::exercise_task_gradings::get_by_id(tx.as_mut(), grading_id)
232            .await
233            .unwrap();
234        assert_eq!(grading.grading_progress, GradingProgress::FullyGraded);
235        assert_eq!(grading.unscaled_score_given, Some(1.0));
236        assert_eq!(grading.feedback_text.as_deref(), Some("well done"));
237        assert!(grading.grading_completed_at.is_some());
238
239        let state = models::user_exercise_states::get_or_create_user_exercise_state(
240            tx.as_mut(),
241            user,
242            exercise,
243            Some(course),
244            None,
245        )
246        .await
247        .unwrap();
248        assert_eq!(state.grading_progress, GradingProgress::FullyGraded);
249        assert_eq!(state.score_given, grading.score_given);
250    }
251
252    #[actix_web::test]
253    async fn update_after_a_regrading_writes_the_current_grading() {
254        insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, :task);
255        let submission_id =
256            insert_task_submission(&mut tx, user, course, exercise, slide, task).await;
257        let mut grading_ids = Vec::new();
258        for _ in 0..2 {
259            grading_ids.push(
260                models::exercise_task_gradings::insert(
261                    tx.as_mut(),
262                    models::PKeyPolicy::Generate,
263                    submission_id,
264                    course,
265                    exercise,
266                    task,
267                )
268                .await
269                .unwrap(),
270            );
271        }
272        let (superseded, current) = (grading_ids[0], grading_ids[1]);
273        models::exercise_task_submissions::set_grading_id(tx.as_mut(), current, submission_id)
274            .await
275            .unwrap();
276
277        apply_grading_update(
278            tx.as_mut(),
279            submission_id,
280            &grading_result(),
281            &crate::test_helper::init_file_store(),
282            &crate::test_helper::init_app_conf().expect("app conf"),
283        )
284        .await
285        .expect("the grading update should be applied");
286
287        let progress =
288            |grading: models::exercise_task_gradings::ExerciseTaskGrading| grading.grading_progress;
289        assert_eq!(
290            progress(
291                models::exercise_task_gradings::get_by_id(tx.as_mut(), current)
292                    .await
293                    .unwrap()
294            ),
295            GradingProgress::FullyGraded
296        );
297        assert_ne!(
298            progress(
299                models::exercise_task_gradings::get_by_id(tx.as_mut(), superseded)
300                    .await
301                    .unwrap()
302            ),
303            GradingProgress::FullyGraded
304        );
305    }
306
307    /// A submission id that doesn't exist at all must surface as an error rather than being
308    /// treated as "no grading yet".
309    #[actix_web::test]
310    async fn update_for_an_unknown_submission_is_an_error() {
311        insert_data!(:tx);
312        apply_grading_update(
313            tx.as_mut(),
314            Uuid::new_v4(),
315            &grading_result(),
316            &crate::test_helper::init_file_store(),
317            &crate::test_helper::init_app_conf().expect("app conf"),
318        )
319        .await
320        .expect_err("an unknown submission id must not succeed");
321    }
322
323    /// Inserts a slide submission + task submission and returns the task submission id, which is
324    /// what the grading-update route addresses.
325    async fn insert_task_submission(
326        tx: &mut Tx<'_>,
327        user: Uuid,
328        course: Uuid,
329        exercise: Uuid,
330        slide: Uuid,
331        task: Uuid,
332    ) -> Uuid {
333        let slide_submission =
334            models::exercise_slide_submissions::insert_exercise_slide_submission(
335                tx.as_mut(),
336                NewExerciseSlideSubmission {
337                    exercise_slide_id: slide,
338                    course_id: Some(course),
339                    exam_id: None,
340                    user_id: user,
341                    exercise_id: exercise,
342                    user_points_update_strategy:
343                        UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
344                },
345            )
346            .await
347            .unwrap();
348        models::exercise_task_submissions::insert(
349            tx.as_mut(),
350            models::PKeyPolicy::Generate,
351            slide_submission.id,
352            slide,
353            task,
354            &models::library::grading::SubmittedAnswer::Json {
355                data: serde_json::Value::Null,
356            },
357        )
358        .await
359        .unwrap()
360    }
361
362    /// Decodes a controller error's JSON response body.
363    fn error_body(err: ControllerError) -> serde_json::Value {
364        let response = err.error_response();
365        let bytes = actix_web::body::to_bytes(response.into_body())
366            .now_or_never()
367            .expect("body resolves immediately")
368            .expect("body bytes");
369        serde_json::from_slice(&bytes).expect("json")
370    }
371}