headless_lms_server/controllers/exercise_services/
grading.rs1use 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 =
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#[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 #[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 #[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 #[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 #[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 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 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}