headless_lms_models/library/
regrading.rs

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