1use std::{
2 collections::{HashMap, HashSet},
3 convert::TryFrom,
4 future::Future,
5 pin::Pin,
6};
7
8use futures::{
9 future::{BoxFuture, FutureExt},
10 stream::{FuturesUnordered, StreamExt},
11};
12use itertools::Itertools;
13use sqlx::PgConnection;
14use url::Url;
15
16use crate::{
17 self as models, ModelResult,
18 exercise_service_info::ExerciseServiceInfo,
19 exercise_services::{ExerciseService, get_internal_grade_url},
20 exercise_task_gradings::{ExerciseTaskGrading, ExerciseTaskGradingResult},
21 exercise_task_regrading_submissions::ExerciseTaskRegradingSubmission,
22 exercise_task_submissions::ExerciseTaskSubmission,
23 exercise_tasks::ExerciseTask,
24 exercises::{Exercise, GradingProgress},
25 prelude::*,
26};
27
28type GradingFutures =
29 HashMap<String, Vec<Pin<Box<dyn Future<Output = GradingData> + Send + 'static>>>>;
30
31pub async fn regrade(
32 conn: &mut PgConnection,
33 exercise_services_by_type: &HashMap<String, (ExerciseService, ExerciseServiceInfo)>,
34 send_grading_request: impl Fn(
35 Url,
36 &ExerciseTask,
37 &ExerciseTaskSubmission,
38 ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
39 file_store: &dyn FileStore,
40 app_conf: &ApplicationConfiguration,
41) -> ModelResult<()> {
42 let mut grading_futures = GradingFutures::new();
44 let mut incomplete_regradings = HashSet::new();
46
47 tracing::debug!("fetching uncompleted regradings");
48 let regrading_ids =
49 models::regradings::get_uncompleted_regradings_and_mark_as_started(&mut *conn).await?;
50 for regrading_id in regrading_ids.iter().copied() {
51 models::regradings::set_total_grading_progress(
53 &mut *conn,
54 regrading_id,
55 GradingProgress::Pending,
56 )
57 .await?;
58 match do_single_regrading(
59 conn,
60 exercise_services_by_type,
61 regrading_id,
62 &mut grading_futures,
63 &send_grading_request,
64 file_store,
65 app_conf,
66 )
67 .await
68 {
69 Ok(regrading_status) => {
70 if !regrading_status.missing_exercise_services.is_empty() {
71 let msg = format!(
72 "Regrading {} failed: no exercise service found for exercise types [{}]",
73 regrading_id,
74 regrading_status.missing_exercise_services.iter().join(", ")
75 );
76 tracing::error!("{}", msg);
77 models::regradings::set_error_message(conn, regrading_id, &msg).await?;
78 models::regradings::set_total_grading_progress(
79 conn,
80 regrading_id,
81 GradingProgress::Failed,
82 )
83 .await?;
84 incomplete_regradings.insert(regrading_id);
85 } else if regrading_status.exercise_services_full {
86 incomplete_regradings.insert(regrading_id);
87 }
88 }
89 Err(err) => {
90 tracing::error!("Regrading {} failed: {}", regrading_id, err);
91 let backtrace: Option<&backtrace::Backtrace> = err.backtrace();
92 if let Some(bt) = backtrace {
93 tracing::error!("Backtrace:\n{:?}", bt);
94 }
95
96 let error_message = if let Some(bt) = backtrace {
97 format!("{}\n\nBacktrace:\n{:?}", err, bt)
98 } else {
99 err.to_string()
100 };
101 models::regradings::set_error_message(conn, regrading_id, &error_message).await?;
102 models::regradings::set_total_grading_progress(
103 conn,
104 regrading_id,
105 GradingProgress::Failed,
106 )
107 .await?;
108 incomplete_regradings.insert(regrading_id);
109 }
110 }
111 }
112
113 let mut grading_futures = grading_futures
115 .into_iter()
116 .flat_map(|v| v.1)
117 .collect::<FuturesUnordered<_>>();
118 while let Some(GradingData {
119 exercise_service_name,
120 regrading_submission,
121 grading,
122 exercise,
123 exercise_service_result,
124 }) = grading_futures.next().await
125 {
126 let grading_result = match exercise_service_result {
127 Ok(grading_result) => grading_result,
128 Err(err) => {
129 tracing::error!(
130 "Failed to get grading from exercise service {}: {}",
131 exercise_service_name,
132 err
133 );
134 models::exercise_task_gradings::set_grading_progress(
135 &mut *conn,
136 grading.id,
137 GradingProgress::Failed,
138 )
139 .await?;
140 continue;
141 }
142 };
143 models::library::grading::update_grading_with_single_regrading_result(
144 conn,
145 &exercise,
146 ®rading_submission,
147 &grading,
148 &grading_result,
149 file_store,
150 app_conf,
151 )
152 .await?;
153 }
154 for regrading_id in regrading_ids {
156 if !incomplete_regradings.contains(®rading_id) {
157 models::regradings::complete_regrading(conn, regrading_id).await?;
158 }
159 }
160 Ok(())
161}
162
163struct RegradingStatus {
164 exercise_services_full: bool,
165 missing_exercise_services: HashSet<String>,
166}
167
168async fn do_single_regrading(
169 conn: &mut PgConnection,
170 exercise_services_by_type: &HashMap<String, (ExerciseService, ExerciseServiceInfo)>,
171 regrading_id: Uuid,
172 grading_futures: &mut GradingFutures,
173 send_grading_request: impl Fn(
174 Url,
175 &ExerciseTask,
176 &ExerciseTaskSubmission,
177 ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
178 file_store: &dyn FileStore,
179 app_conf: &ApplicationConfiguration,
180) -> ModelResult<RegradingStatus> {
181 let mut regrading_status = RegradingStatus {
182 exercise_services_full: false,
183 missing_exercise_services: HashSet::new(),
184 };
185
186 let regrading_submissions =
188 models::exercise_task_regrading_submissions::get_regrading_submissions(
189 &mut *conn,
190 regrading_id,
191 )
192 .await?;
193 tracing::info!(
194 "found {} submissions for regrading {}",
195 regrading_submissions.len(),
196 regrading_id
197 );
198 for regrading_submission in regrading_submissions {
199 if let Some(grading_id) = regrading_submission.grading_after_regrading {
202 let grading = models::exercise_task_gradings::get_by_id(&mut *conn, grading_id).await?;
204 if grading.grading_progress == GradingProgress::FullyGraded {
205 continue;
207 }
208 }
210
211 let submission = models::exercise_task_submissions::get_submission(
213 &mut *conn,
214 regrading_submission.exercise_task_submission_id,
215 file_store,
216 app_conf,
217 )
218 .await?;
219 let exercise_slide =
220 models::exercise_slides::get_exercise_slide(&mut *conn, submission.exercise_slide_id)
221 .await?;
222 let exercise = models::exercises::get_by_id(&mut *conn, exercise_slide.exercise_id).await?;
223 if exercise.exam_id.is_some() {
224 info!(
225 "Submission being regraded is from an exam, making sure we only give points from the last submission."
226 );
227 let exercise_slide_submission = models::exercise_slide_submissions::get_by_id(
228 &mut *conn,
229 submission.exercise_slide_submission_id,
230 )
231 .await?;
232 let latest_submission =
233 models::exercise_slide_submissions::get_users_latest_exercise_slide_submission(
234 &mut *conn,
235 submission.exercise_slide_id,
236 exercise_slide_submission.user_id,
237 )
238 .await?;
239 if exercise_slide_submission.id != latest_submission.id {
240 info!(
241 "Exam submission being regraded is not the latest submission, refusing to grade it."
242 );
243 models::exercise_task_gradings::set_grading_progress(
244 &mut *conn,
245 regrading_submission.id,
246 GradingProgress::Failed,
247 )
248 .await?;
249 continue;
250 }
251 }
252 let not_ready_grading =
253 models::exercise_task_gradings::new_grading(&mut *conn, &exercise, &submission).await?;
254 models::exercise_task_regrading_submissions::set_grading_after_regrading(
255 conn,
256 regrading_submission.id,
257 not_ready_grading.id,
258 )
259 .await?;
260 let exercise_task = models::exercise_tasks::get_exercise_task_by_id(
262 &mut *conn,
263 submission.exercise_task_id,
264 )
265 .await?;
266 if let Some((exercise_service, exercise_service_info)) =
267 exercise_services_by_type.get(&exercise_task.exercise_type)
268 {
269 models::exercise_task_gradings::set_grading_progress(
271 &mut *conn,
272 not_ready_grading.id,
273 GradingProgress::Pending,
274 )
275 .await?;
276
277 let entry = grading_futures
278 .entry(exercise_task.exercise_type.clone())
279 .or_default();
280
281 let limit = usize::try_from(exercise_service.max_reprocessing_submissions_at_once)
283 .unwrap_or_else(|_e| {
284 tracing::error!(
285 "{}: invalid max_reprocessing_submissions_at_once {}",
286 exercise_service.name,
287 exercise_service.max_reprocessing_submissions_at_once
288 );
289 usize::MAX
290 });
291 if entry.len() < limit {
292 let exercise =
293 models::exercises::get_by_id(&mut *conn, exercise_slide.exercise_id).await?;
294 let grade_url =
295 get_internal_grade_url(exercise_service, exercise_service_info).await?;
296
297 let exercise_service_name = exercise_service.name.clone();
298 let grading_future = send_grading_request(grade_url, &exercise_task, &submission)
299 .map(move |exercise_service_result| GradingData {
300 exercise_service_name,
301 regrading_submission,
302 grading: not_ready_grading,
303 exercise,
304 exercise_service_result,
305 });
306 entry.push(Box::pin(grading_future));
307 } else {
308 regrading_status.exercise_services_full = true;
310 }
311 } else {
312 let msg = format!(
313 "No exercise services found for type {}",
314 exercise_task.exercise_type,
315 );
316 tracing::error!("{}", msg);
317 models::exercise_task_gradings::set_grading_progress(
318 &mut *conn,
319 not_ready_grading.id,
320 GradingProgress::Failed,
321 )
322 .await?;
323 regrading_status
324 .missing_exercise_services
325 .insert(exercise_task.exercise_type);
326 }
327 }
328 Ok(regrading_status)
329}
330
331struct GradingData {
332 exercise_service_name: String,
333 regrading_submission: ExerciseTaskRegradingSubmission,
334 grading: ExerciseTaskGrading,
335 exercise: Exercise,
336 exercise_service_result: ModelResult<ExerciseTaskGradingResult>,
337}
338
339#[cfg(test)]
340mod test {
341 use headless_lms_utils::numbers::f32_approx_eq;
342 use mockito::{Matcher, ServerGuard};
343 use models::{
344 exercise_services,
345 exercise_task_gradings::{ExerciseTaskGradingResult, UserPointsUpdateStrategy},
346 exercise_tasks::NewExerciseTask,
347 exercises::{self, GradingProgress},
348 library::grading::{
349 GradingPolicy, StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult,
350 StudentExerciseTaskSubmission,
351 },
352 user_exercise_states::{self, ExerciseWithUserState},
353 };
354 use serde_json::Value;
355
356 use super::*;
357 use crate::test_helper::*;
358
359 #[tokio::test]
360 async fn regrades_submission() {
361 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide);
362 let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
363 let task = models::exercise_tasks::insert(
364 tx.as_mut(),
365 PKeyPolicy::Generate,
366 NewExerciseTask {
367 exercise_slide_id: slide,
368 exercise_type: "test-exercise".to_string(),
369 assignment: vec![],
370 public_spec: Some(Value::Null),
371 private_spec: Some(Value::Null),
372 model_solution_spec: Some(Value::Null),
373 order_number: 0,
374 },
375 )
376 .await
377 .unwrap();
378 let grading_result = ExerciseTaskGradingResult {
379 grading_progress: models::exercises::GradingProgress::FullyGraded,
380 score_given: 0.0,
381 score_maximum: 100,
382 feedback_text: None,
383 feedback_json: None,
384 set_user_variables: Some(HashMap::new()),
385 };
386 let original_grading = create_initial_submission(
387 tx.as_mut(),
388 user,
389 &exercise,
390 course,
391 slide,
392 StudentExerciseSlideSubmission {
393 exercise_slide_id: slide,
394 exercise_task_submissions: vec![StudentExerciseTaskSubmission::json(
395 task,
396 Value::Null,
397 )],
398 },
399 HashMap::from([(task, grading_result.clone())]),
400 )
401 .await
402 .unwrap();
403
404 let regrading = models::regradings::insert(
405 tx.as_mut(),
406 UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
407 )
408 .await
409 .unwrap();
410 let exercise_task_submission_result = original_grading
411 .exercise_task_submission_results
412 .first()
413 .unwrap();
414 let regrading_submission_id = models::exercise_task_regrading_submissions::insert(
415 tx.as_mut(),
416 PKeyPolicy::Generate,
417 regrading,
418 exercise_task_submission_result.submission.id,
419 exercise_task_submission_result.grading.as_ref().unwrap().id,
420 )
421 .await
422 .unwrap();
423 let mut server = mockito::Server::new_async().await;
424 let _m = server
425 .mock("POST", Matcher::Any)
426 .with_body(serde_json::to_string(&grading_result).unwrap())
427 .create();
428 let service = create_mock_service(tx.as_mut(), "test-exercise".to_string(), 1, &server)
429 .await
430 .unwrap();
431 let services = HashMap::from([("test-exercise".to_string(), service)]);
432
433 let regrading_submission =
434 models::exercise_task_regrading_submissions::get_regrading_submission(
435 tx.as_mut(),
436 regrading_submission_id,
437 )
438 .await
439 .unwrap();
440 assert!(regrading_submission.grading_after_regrading.is_none());
441
442 regrade(
443 tx.as_mut(),
444 &services,
445 |_, _, _| {
446 async {
447 Ok(ExerciseTaskGradingResult {
448 grading_progress: GradingProgress::FullyGraded,
449 score_given: 0.0,
450 score_maximum: 1,
451 feedback_text: None,
452 feedback_json: None,
453 set_user_variables: None,
454 })
455 }
456 .boxed()
457 },
458 &init_file_store(),
459 &init_app_conf().expect("app conf"),
460 )
461 .await
462 .unwrap();
463
464 let regrading_submission =
465 models::exercise_task_regrading_submissions::get_regrading_submission(
466 tx.as_mut(),
467 regrading_submission_id,
468 )
469 .await
470 .unwrap();
471 let new_grading = regrading_submission.grading_after_regrading.unwrap();
472 let grading = models::exercise_task_gradings::get_by_id(tx.as_mut(), new_grading)
473 .await
474 .unwrap();
475 assert_eq!(grading.score_given, Some(0.0))
476 }
477
478 #[tokio::test]
479 async fn regrades_complete() {
480 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide);
481 let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
482 let task = models::exercise_tasks::insert(
483 tx.as_mut(),
484 PKeyPolicy::Generate,
485 NewExerciseTask {
486 exercise_slide_id: slide,
487 exercise_type: "test-exercise".to_string(),
488 assignment: vec![],
489 public_spec: Some(Value::Null),
490 private_spec: Some(Value::Null),
491 model_solution_spec: Some(Value::Null),
492 order_number: 0,
493 },
494 )
495 .await
496 .unwrap();
497 let grading_result = ExerciseTaskGradingResult {
498 grading_progress: models::exercises::GradingProgress::FullyGraded,
499 score_given: 0.0,
500 score_maximum: 100,
501 feedback_text: None,
502 feedback_json: None,
503 set_user_variables: Some(HashMap::new()),
504 };
505 let original_grading = create_initial_submission(
506 tx.as_mut(),
507 user,
508 &exercise,
509 course,
510 slide,
511 StudentExerciseSlideSubmission {
512 exercise_slide_id: slide,
513 exercise_task_submissions: vec![StudentExerciseTaskSubmission::json(
514 task,
515 Value::Null,
516 )],
517 },
518 HashMap::from([(task, grading_result.clone())]),
519 )
520 .await
521 .unwrap();
522 let mut server = mockito::Server::new_async().await;
523 let _m = server
524 .mock("POST", Matcher::Any)
525 .with_body(serde_json::to_string(&grading_result).unwrap())
526 .create();
527 let service = create_mock_service(tx.as_mut(), "test-exercise".to_string(), 1, &server)
528 .await
529 .unwrap();
530 let services = HashMap::from([("test-exercise".to_string(), service)]);
531
532 let regrading = models::regradings::insert(
533 tx.as_mut(),
534 UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
535 )
536 .await
537 .unwrap();
538 let exercise_task_submission_result = original_grading
539 .exercise_task_submission_results
540 .first()
541 .unwrap();
542 let _regrading_submission_id = models::exercise_task_regrading_submissions::insert(
543 tx.as_mut(),
544 PKeyPolicy::Generate,
545 regrading,
546 exercise_task_submission_result.submission.id,
547 exercise_task_submission_result.grading.as_ref().unwrap().id,
548 )
549 .await
550 .unwrap();
551
552 let regrading = models::regradings::get_by_id(tx.as_mut(), regrading)
553 .await
554 .unwrap();
555 assert_eq!(regrading.total_grading_progress, GradingProgress::NotReady);
556 assert!(regrading.regrading_started_at.is_none());
557 assert!(regrading.regrading_completed_at.is_none());
558
559 regrade(
560 tx.as_mut(),
561 &services,
562 |_, _, _| {
563 async {
564 Ok(ExerciseTaskGradingResult {
565 grading_progress: GradingProgress::FullyGraded,
566 score_given: 1.0,
567 score_maximum: 1,
568 feedback_text: None,
569 feedback_json: None,
570 set_user_variables: None,
571 })
572 }
573 .boxed()
574 },
575 &init_file_store(),
576 &init_app_conf().expect("app conf"),
577 )
578 .await
579 .unwrap();
580
581 let regrading_1 = models::regradings::get_by_id(tx.as_mut(), regrading.id)
582 .await
583 .unwrap();
584 assert_eq!(
585 regrading_1.total_grading_progress,
586 GradingProgress::FullyGraded
587 );
588 assert!(regrading_1.regrading_started_at.is_some());
589 assert!(regrading_1.regrading_completed_at.is_some());
590 }
591
592 #[tokio::test]
593 async fn regrades_partial() {
594 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, slide: slide_1);
595 let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
596 let grading_result = ExerciseTaskGradingResult {
597 grading_progress: models::exercises::GradingProgress::FullyGraded,
598 score_given: 0.0,
599 score_maximum: 100,
600 feedback_text: None,
601 feedback_json: None,
602 set_user_variables: Some(HashMap::new()),
603 };
604
605 let task_1 = models::exercise_tasks::insert(
606 tx.as_mut(),
607 PKeyPolicy::Generate,
608 NewExerciseTask {
609 exercise_slide_id: slide_1,
610 exercise_type: "test-exercise-1".to_string(),
611 assignment: vec![],
612 public_spec: Some(Value::Null),
613 private_spec: Some(Value::Null),
614 model_solution_spec: Some(Value::Null),
615 order_number: 0,
616 },
617 )
618 .await
619 .unwrap();
620 let original_grading_1 = create_initial_submission(
621 tx.as_mut(),
622 user,
623 &exercise,
624 course,
625 slide_1,
626 StudentExerciseSlideSubmission {
627 exercise_slide_id: slide_1,
628 exercise_task_submissions: vec![StudentExerciseTaskSubmission::json(
629 task_1,
630 Value::Null,
631 )],
632 },
633 HashMap::from([(task_1, grading_result.clone())]),
634 )
635 .await
636 .unwrap();
637 let task_submission_result_1 = original_grading_1
638 .exercise_task_submission_results
639 .first()
640 .unwrap();
641
642 let slide_2 =
643 models::exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, exercise.id, 1)
644 .await
645 .unwrap();
646 let task_2 = models::exercise_tasks::insert(
647 tx.as_mut(),
648 PKeyPolicy::Generate,
649 NewExerciseTask {
650 exercise_slide_id: slide_2,
651 exercise_type: "test-exercise-2".to_string(),
652 assignment: vec![],
653 public_spec: Some(Value::Null),
654 private_spec: Some(Value::Null),
655 model_solution_spec: Some(Value::Null),
656 order_number: 0,
657 },
658 )
659 .await
660 .unwrap();
661 let original_grading_2 = create_initial_submission(
662 tx.as_mut(),
663 user,
664 &exercise,
665 course,
666 slide_2,
667 StudentExerciseSlideSubmission {
668 exercise_slide_id: slide_2,
669 exercise_task_submissions: vec![StudentExerciseTaskSubmission::json(
670 task_2,
671 Value::Null,
672 )],
673 },
674 HashMap::from([(task_2, grading_result.clone())]),
675 )
676 .await
677 .unwrap();
678 user_exercise_states::upsert_selected_exercise_slide_id(
679 tx.as_mut(),
680 user,
681 exercise.id,
682 Some(course),
683 None,
684 Some(slide_2),
685 )
686 .await
687 .unwrap();
688 let task_submission_result_2 = original_grading_2
689 .exercise_task_submission_results
690 .first()
691 .unwrap();
692 let mut server = mockito::Server::new_async().await;
693 let _m = server
694 .mock("POST", Matcher::Any)
695 .with_body(serde_json::to_string(&grading_result).unwrap())
696 .create();
697 let service_1 = create_mock_service(tx.as_mut(), "test-exercise-1".to_string(), 1, &server)
698 .await
699 .unwrap();
700 let service_2 = create_mock_service(tx.as_mut(), "test-exercise-2".to_string(), 0, &server)
701 .await
702 .unwrap();
703 let services = HashMap::from([
704 ("test-exercise-1".to_string(), service_1),
705 ("test-exercise-2".to_string(), service_2),
706 ]);
707
708 let regrading = models::regradings::insert(
709 tx.as_mut(),
710 UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
711 )
712 .await
713 .unwrap();
714 let _regrading_submission_1 = models::exercise_task_regrading_submissions::insert(
715 tx.as_mut(),
716 PKeyPolicy::Generate,
717 regrading,
718 task_submission_result_1.submission.id,
719 task_submission_result_1.grading.as_ref().unwrap().id,
720 )
721 .await
722 .unwrap();
723 let _regrading_submission_2 = models::exercise_task_regrading_submissions::insert(
724 tx.as_mut(),
725 PKeyPolicy::Generate,
726 regrading,
727 task_submission_result_2.submission.id,
728 task_submission_result_2.grading.as_ref().unwrap().id,
729 )
730 .await
731 .unwrap();
732
733 let regrading_2 = models::regradings::get_by_id(tx.as_mut(), regrading)
734 .await
735 .unwrap();
736 assert_eq!(
737 regrading_2.total_grading_progress,
738 GradingProgress::NotReady
739 );
740 assert!(regrading_2.regrading_started_at.is_none());
741
742 regrade(
743 tx.as_mut(),
744 &services,
745 |_, _, _| {
746 async {
747 Ok(ExerciseTaskGradingResult {
748 grading_progress: GradingProgress::Pending,
749 score_given: 0.0,
750 score_maximum: 1,
751 feedback_text: None,
752 feedback_json: None,
753 set_user_variables: None,
754 })
755 }
756 .boxed()
757 },
758 &init_file_store(),
759 &init_app_conf().expect("app conf"),
760 )
761 .await
762 .unwrap();
763
764 let regrading_2 = models::regradings::get_by_id(tx.as_mut(), regrading)
765 .await
766 .unwrap();
767 assert_eq!(regrading_2.total_grading_progress, GradingProgress::Pending);
768 assert!(regrading_2.regrading_started_at.is_some());
769 assert!(regrading_2.regrading_completed_at.is_none());
770 }
771
772 #[tokio::test]
773 async fn updates_exercise_state() {
774 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide);
775 let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
776 let task = models::exercise_tasks::insert(
777 tx.as_mut(),
778 PKeyPolicy::Generate,
779 NewExerciseTask {
780 exercise_slide_id: slide,
781 exercise_type: "test-exercise".to_string(),
782 assignment: vec![],
783 public_spec: Some(Value::Null),
784 private_spec: Some(Value::Null),
785 model_solution_spec: Some(Value::Null),
786 order_number: 0,
787 },
788 )
789 .await
790 .unwrap();
791 let original_grading = create_initial_submission(
792 tx.as_mut(),
793 user,
794 &exercise,
795 course,
796 slide,
797 StudentExerciseSlideSubmission {
798 exercise_slide_id: slide,
799 exercise_task_submissions: vec![StudentExerciseTaskSubmission::json(
800 task,
801 Value::Null,
802 )],
803 },
804 HashMap::from([(
805 task,
806 ExerciseTaskGradingResult {
807 grading_progress: models::exercises::GradingProgress::FullyGraded,
808 score_given: 0.0,
809 score_maximum: 100,
810 feedback_text: None,
811 feedback_json: None,
812 set_user_variables: Some(HashMap::new()),
813 },
814 )]),
815 )
816 .await
817 .unwrap();
818 let mut server = mockito::Server::new_async().await;
819 let _m = server
820 .mock("POST", Matcher::Any)
821 .with_body(
822 serde_json::to_string(&ExerciseTaskGradingResult {
823 grading_progress: models::exercises::GradingProgress::FullyGraded,
824 score_given: 100.0,
825 score_maximum: 100,
826 feedback_text: None,
827 feedback_json: None,
828 set_user_variables: Some(HashMap::new()),
829 })
830 .unwrap(),
831 )
832 .create();
833 let service = create_mock_service(tx.as_mut(), "test-exercise".to_string(), 1, &server)
834 .await
835 .unwrap();
836 let services = HashMap::from([("test-exercise".to_string(), service)]);
837
838 let regrading = models::regradings::insert(
839 tx.as_mut(),
840 UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
841 )
842 .await
843 .unwrap();
844 let exercise_task_submission_result = original_grading
845 .exercise_task_submission_results
846 .first()
847 .unwrap();
848 let _regrading_submission_id = models::exercise_task_regrading_submissions::insert(
849 tx.as_mut(),
850 PKeyPolicy::Generate,
851 regrading,
852 exercise_task_submission_result.submission.id,
853 exercise_task_submission_result.grading.as_ref().unwrap().id,
854 )
855 .await
856 .unwrap();
857
858 let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
859 tx.as_mut(),
860 user,
861 exercise.id,
862 Some(course),
863 None,
864 )
865 .await
866 .unwrap();
867 assert!(
868 f32_approx_eq(user_exercise_state.score_given.unwrap(), 0.0),
869 "{} != {}",
870 user_exercise_state.score_given.unwrap(),
871 0.0
872 );
873
874 regrade(
875 tx.as_mut(),
876 &services,
877 |_, _, _| {
878 async {
879 Ok(ExerciseTaskGradingResult {
880 grading_progress: GradingProgress::FullyGraded,
881 score_given: 1.0,
882 score_maximum: 1,
883 feedback_text: None,
884 feedback_json: None,
885 set_user_variables: None,
886 })
887 }
888 .boxed()
889 },
890 &init_file_store(),
891 &init_app_conf().expect("app conf"),
892 )
893 .await
894 .unwrap();
895
896 let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
897 tx.as_mut(),
898 user,
899 exercise.id,
900 Some(course),
901 None,
902 )
903 .await
904 .unwrap();
905 assert!(
906 f32_approx_eq(user_exercise_state.score_given.unwrap(), 1.0),
907 "{} != {}",
908 user_exercise_state.score_given.unwrap(),
909 1.0
910 );
911 }
912
913 #[tokio::test]
914 async fn fail_on_missing_service() {
915 insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter, :page, :exercise, :slide, :task);
916 let exercise = exercises::get_by_id(tx.as_mut(), exercise).await.unwrap();
917 let grading_result = ExerciseTaskGradingResult {
918 grading_progress: models::exercises::GradingProgress::FullyGraded,
919 score_given: 0.0,
920 score_maximum: 100,
921 feedback_text: None,
922 feedback_json: None,
923 set_user_variables: Some(HashMap::new()),
924 };
925 let original_grading = create_initial_submission(
926 tx.as_mut(),
927 user,
928 &exercise,
929 course,
930 slide,
931 StudentExerciseSlideSubmission {
932 exercise_slide_id: slide,
933 exercise_task_submissions: vec![StudentExerciseTaskSubmission::json(
934 task,
935 Value::Null,
936 )],
937 },
938 HashMap::from([(task, grading_result.clone())]),
939 )
940 .await
941 .unwrap();
942 let exercise_task_submission_result = original_grading
943 .exercise_task_submission_results
944 .first()
945 .unwrap();
946
947 let regrading = models::regradings::insert(
948 tx.as_mut(),
949 UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
950 )
951 .await
952 .unwrap();
953 let _regrading_submission_id = models::exercise_task_regrading_submissions::insert(
954 tx.as_mut(),
955 PKeyPolicy::Generate,
956 regrading,
957 exercise_task_submission_result.submission.id,
958 exercise_task_submission_result.grading.as_ref().unwrap().id,
959 )
960 .await
961 .unwrap();
962
963 let services = HashMap::new();
964 regrade(
965 tx.as_mut(),
966 &services,
967 |_, _, _| unimplemented!(),
968 &init_file_store(),
969 &init_app_conf().expect("app conf"),
970 )
971 .await
972 .unwrap();
973
974 let regrading = models::regradings::get_by_id(tx.as_mut(), regrading)
975 .await
976 .unwrap();
977 assert_eq!(regrading.total_grading_progress, GradingProgress::Failed);
978 }
979
980 async fn create_initial_submission(
981 conn: &mut PgConnection,
982 user_id: Uuid,
983 exercise: &Exercise,
984 course_id: Uuid,
985 exercise_slide_id: Uuid,
986 submission: StudentExerciseSlideSubmission,
987 mock_results: HashMap<Uuid, ExerciseTaskGradingResult>,
988 ) -> ModelResult<StudentExerciseSlideSubmissionResult> {
989 user_exercise_states::upsert_selected_exercise_slide_id(
990 conn,
991 user_id,
992 exercise.id,
993 Some(course_id),
994 None,
995 Some(exercise_slide_id),
996 )
997 .await?;
998 let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
999 conn,
1000 user_id,
1001 exercise.id,
1002 Some(course_id),
1003 None,
1004 )
1005 .await?;
1006 let mut exercise_with_user_state =
1007 ExerciseWithUserState::new(exercise.clone(), user_exercise_state).unwrap();
1008 let grading = crate::library::grading::grade_user_submission(
1009 conn,
1010 &mut exercise_with_user_state,
1011 &submission,
1012 GradingPolicy::Fixed(mock_results),
1013 |_| unimplemented!(),
1014 |_, _, _| unimplemented!(),
1015 &init_file_store(),
1016 &init_app_conf().expect("app conf"),
1017 )
1018 .await
1019 .unwrap();
1020 Ok(grading)
1021 }
1022
1023 async fn create_mock_service(
1024 conn: &mut PgConnection,
1025 service_slug: String,
1026 max_reprocessing_submissions_at_once: i32,
1027 server: &ServerGuard,
1028 ) -> ModelResult<(ExerciseService, ExerciseServiceInfo)> {
1029 let exercise_service = models::exercise_services::insert_exercise_service(
1030 conn,
1031 &exercise_services::ExerciseServiceNewOrUpdate {
1032 name: "".to_string(),
1033 slug: service_slug,
1034 public_url: "".to_string(),
1035 internal_url: Some(server.url()),
1036 max_reprocessing_submissions_at_once,
1037 },
1038 )
1039 .await?;
1040 let info = models::exercise_service_info::insert(
1041 conn,
1042 &models::exercise_service_info::PathInfo {
1043 exercise_service_id: exercise_service.id,
1044 user_interface_iframe_path: "/iframe".to_string(),
1045 grade_endpoint_path: "/grade".to_string(),
1046 public_spec_endpoint_path: "/public-spec".to_string(),
1047 model_solution_spec_endpoint_path: "/model-solution".to_string(),
1048 has_custom_view: false,
1049 supports_native_client: false,
1050 produces_file_answers: false,
1051 declares_spec_files: false,
1052 },
1053 )
1054 .await?;
1055
1056 Ok((exercise_service, info))
1057 }
1058}