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    let grading =
86        models::exercise_task_gradings::get_by_exercise_task_submission_id(conn, submission_id)
87            .await?
88            .ok_or_else(|| {
89                controller_err!(
90                    BadRequest,
91                    "No existing grading for the submission found".to_string()
92                )
93            })?;
94    let exercise = models::exercises::get_by_id(conn, slide.exercise_id).await?;
95    models::exercise_task_gradings::update_grading(conn, &grading, grading_result, &exercise)
96        .await?;
97    Ok(())
98}
99
100/**
101Add a route for each controller in this module.
102
103The name starts with an underline in order to appear before other functions in the module documentation.
104
105We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
106*/
107#[doc(hidden)]
108pub fn _add_routes(cfg: &mut ServiceConfig) {
109    cfg.route(
110        "grading-update/{submission_id}",
111        web::post().to(grading_update),
112    );
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::test_helper::*;
119    use actix_web::ResponseError;
120    use actix_web::http::StatusCode;
121    use futures_util::FutureExt;
122    use models::exercise_slide_submissions::NewExerciseSlideSubmission;
123    use models::exercise_task_gradings::UserPointsUpdateStrategy;
124    use models::exercises::GradingProgress;
125
126    fn grading_result() -> ExerciseTaskGradingResult {
127        ExerciseTaskGradingResult {
128            grading_progress: GradingProgress::FullyGraded,
129            score_given: 1.0,
130            score_maximum: 1,
131            feedback_text: Some("well done".to_string()),
132            feedback_json: None,
133            set_user_variables: None,
134        }
135    }
136
137    /// An exercise service must not be able to use a claim signed for one submission to
138    /// overwrite the grading of another; the path id and the claim's id have to agree.
139    #[test]
140    fn claim_for_another_submission_is_rejected() {
141        let submission_id = Uuid::new_v4();
142        let other_submission_id = Uuid::new_v4();
143        let err = verify_claim_matches_submission(submission_id, other_submission_id)
144            .expect_err("a claim for another submission must be rejected");
145        assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
146        let value = error_body(err);
147        assert_eq!(value["type"], "validation_error");
148        assert!(
149            value["message"]
150                .as_str()
151                .unwrap_or_default()
152                .contains("didn't match the submission id"),
153            "unexpected message: {}",
154            value["message"]
155        );
156    }
157
158    #[test]
159    fn claim_for_the_same_submission_is_accepted() {
160        let submission_id = Uuid::new_v4();
161        assert!(verify_claim_matches_submission(submission_id, submission_id).is_ok());
162    }
163
164    /// A grading update for a submission that was never sent out for grading has nothing to
165    /// update; it must be a client error rather than a panic or a silent no-op.
166    #[actix_web::test]
167    async fn update_without_an_existing_grading_is_rejected() {
168        insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, :task);
169        let submission_id =
170            insert_task_submission(&mut tx, user, course, exercise, slide, task).await;
171
172        let err = apply_grading_update(
173            tx.as_mut(),
174            submission_id,
175            &grading_result(),
176            &crate::test_helper::init_file_store(),
177            &crate::test_helper::init_app_conf().expect("app conf"),
178        )
179        .await
180        .expect_err("a submission without a grading row must be rejected");
181        assert_eq!(err.status_code(), StatusCode::UNPROCESSABLE_ENTITY);
182        let value = error_body(err);
183        assert!(
184            value["message"]
185                .as_str()
186                .unwrap_or_default()
187                .contains("No existing grading for the submission found"),
188            "unexpected message: {}",
189            value["message"]
190        );
191    }
192
193    /// Positive control for the test above: with a grading row present, the same call writes
194    /// the result through.
195    #[actix_web::test]
196    async fn update_with_an_existing_grading_writes_the_result() {
197        insert_data!(:tx, user: user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, :task);
198        let submission_id =
199            insert_task_submission(&mut tx, user, course, exercise, slide, task).await;
200        let grading_id = models::exercise_task_gradings::insert(
201            tx.as_mut(),
202            models::PKeyPolicy::Generate,
203            submission_id,
204            course,
205            exercise,
206            task,
207        )
208        .await
209        .unwrap();
210
211        apply_grading_update(
212            tx.as_mut(),
213            submission_id,
214            &grading_result(),
215            &crate::test_helper::init_file_store(),
216            &crate::test_helper::init_app_conf().expect("app conf"),
217        )
218        .await
219        .expect("the grading update should be applied");
220
221        let grading = models::exercise_task_gradings::get_by_id(tx.as_mut(), grading_id)
222            .await
223            .unwrap();
224        assert_eq!(grading.grading_progress, GradingProgress::FullyGraded);
225        assert_eq!(grading.unscaled_score_given, Some(1.0));
226        assert_eq!(grading.feedback_text.as_deref(), Some("well done"));
227        assert!(grading.grading_completed_at.is_some());
228    }
229
230    /// A submission id that doesn't exist at all must surface as an error rather than being
231    /// treated as "no grading yet".
232    #[actix_web::test]
233    async fn update_for_an_unknown_submission_is_an_error() {
234        insert_data!(:tx);
235        apply_grading_update(
236            tx.as_mut(),
237            Uuid::new_v4(),
238            &grading_result(),
239            &crate::test_helper::init_file_store(),
240            &crate::test_helper::init_app_conf().expect("app conf"),
241        )
242        .await
243        .expect_err("an unknown submission id must not succeed");
244    }
245
246    /// Inserts a slide submission + task submission and returns the task submission id, which is
247    /// what the grading-update route addresses.
248    async fn insert_task_submission(
249        tx: &mut Tx<'_>,
250        user: Uuid,
251        course: Uuid,
252        exercise: Uuid,
253        slide: Uuid,
254        task: Uuid,
255    ) -> Uuid {
256        let slide_submission =
257            models::exercise_slide_submissions::insert_exercise_slide_submission(
258                tx.as_mut(),
259                NewExerciseSlideSubmission {
260                    exercise_slide_id: slide,
261                    course_id: Some(course),
262                    exam_id: None,
263                    user_id: user,
264                    exercise_id: exercise,
265                    user_points_update_strategy:
266                        UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
267                },
268            )
269            .await
270            .unwrap();
271        models::exercise_task_submissions::insert(
272            tx.as_mut(),
273            models::PKeyPolicy::Generate,
274            slide_submission.id,
275            slide,
276            task,
277            &models::library::grading::SubmittedAnswer::Json {
278                data: serde_json::Value::Null,
279            },
280        )
281        .await
282        .unwrap()
283    }
284
285    /// Decodes a controller error's JSON response body.
286    fn error_body(err: ControllerError) -> serde_json::Value {
287        let response = err.error_response();
288        let bytes = actix_web::body::to_bytes(response.into_body())
289            .now_or_never()
290            .expect("body resolves immediately")
291            .expect("body bytes");
292        serde_json::from_slice(&bytes).expect("json")
293    }
294}