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