1use models::exercise_task_gradings::ExerciseTaskGradingResult;
2
3use crate::{domain::models_requests::GradingUpdateClaim, prelude::*};
4
5#[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 let token = skip_authorize();
35 let grading_result = grading_result.into_inner();
36
37 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
53fn 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
68async 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_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#[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 #[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 #[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 #[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 #[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 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 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}