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