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