Skip to main content

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        Uuid,
39    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
40    file_store: &dyn FileStore,
41    app_conf: &ApplicationConfiguration,
42) -> ModelResult<()> {
43    // stores all the futures which will resolve into new gradings
44    let mut grading_futures = GradingFutures::new();
45    // set of regradings that should not be marked as completed by the end
46    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        // set regrading progress to pending
53        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    // wait for all the submissions to be completed
115    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            &regrading_submission,
148            &grading,
149            &grading_result,
150            file_store,
151            app_conf,
152        )
153        .await?;
154    }
155    // update completed regradings
156    for regrading_id in regrading_ids {
157        if !incomplete_regradings.contains(&regrading_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    // for each regrading, process all related submissions
189    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        // for each submission, send to exercise service to be graded and store the future
202
203        if let Some(grading_id) = regrading_submission.grading_after_regrading {
204            // this submission has previously been at least partially regraded
205            let grading = models::exercise_task_gradings::get_by_id(&mut *conn, grading_id).await?;
206            if grading.grading_progress == GradingProgress::FullyGraded {
207                // already fully graded, continue to the next one
208                continue;
209            }
210            // otherwise, attempt grading again
211        }
212
213        // create new grading for the submission
214        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        // get the corresponding exercise service
263        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            // mark the grading as pending
272            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            // make sure we aren't sending too many requests
284            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                // we can't send this submission right now
319                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}