Skip to main content

headless_lms_models/library/
grading.rs

1//! Collection of functions used for processing and evaluating user submissions for exercises.
2
3use futures::future::BoxFuture;
4use std::collections::HashMap;
5use url::Url;
6use utoipa::ToSchema;
7
8use crate::{
9    exercise_service_info::ExerciseServiceInfoApi,
10    exercise_slide_submissions::{self, ExerciseSlideSubmission, NewExerciseSlideSubmission},
11    exercise_task_gradings::{
12        self, ExerciseTaskGrading, ExerciseTaskGradingResult, UserPointsUpdateStrategy,
13    },
14    exercise_task_regrading_submissions::ExerciseTaskRegradingSubmission,
15    exercise_task_submissions::{
16        self, AnswerFields, AnswerFile, AnswerKind, ExerciseTaskSubmission,
17    },
18    exercise_tasks::{self, CourseMaterialExerciseTask, ExerciseTask},
19    exercises::{self, Exercise, ExerciseStatus, GradingProgress},
20    flagged_answers::{self, FlaggedAnswer},
21    peer_or_self_review_configs::PeerReviewProcessingStrategy,
22    peer_or_self_review_question_submissions::{
23        self, PeerOrSelfReviewQuestionSubmission, PeerReviewWithQuestionsAndAnswers,
24    },
25    prelude::*,
26    regradings,
27    user_course_exercise_service_variables::UserCourseExerciseServiceVariable,
28    user_exercise_slide_states::{self, UserExerciseSlideState},
29    user_exercise_states::{self, ExerciseWithUserState, UserExerciseState},
30    user_exercise_task_states,
31};
32
33use super::user_exercise_state_updater;
34
35/// Contains data sent by the student when they make a submission for an exercise slide.
36#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
37
38pub struct StudentExerciseSlideSubmission {
39    pub exercise_slide_id: Uuid,
40    pub exercise_task_submissions: Vec<StudentExerciseTaskSubmission>,
41}
42
43#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
44
45pub struct StudentExerciseSlideSubmissionResult {
46    pub exercise_status: Option<ExerciseStatus>,
47    pub exercise_task_submission_results: Vec<StudentExerciseTaskSubmissionResult>,
48    pub user_course_instance_exercise_service_variables: Vec<UserCourseExerciseServiceVariable>,
49}
50
51impl StudentExerciseSlideSubmissionResult {
52    pub fn clear_grading_information(&mut self) {
53        self.exercise_status = None;
54        self.exercise_task_submission_results
55            .iter_mut()
56            .for_each(|result| {
57                result.grading = None;
58                result.model_solution_spec = None;
59            });
60    }
61
62    pub fn clear_model_solution_specs(&mut self) {
63        self.exercise_task_submission_results
64            .iter_mut()
65            .for_each(|result| {
66                result.model_solution_spec = None;
67            })
68    }
69}
70
71#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
72
73pub struct StudentExerciseTaskSubmission {
74    pub exercise_task_id: Uuid,
75    /// Absent means `json`, so a client that only ever answers with JSON never sends it.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub answer_kind: Option<AnswerKind>,
78    /// The plugin's own JSON: the whole answer for a `json` answer, the plugin's metadata about the
79    /// files for a `file` one.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub data_json: Option<serde_json::Value>,
82    /// The uploads that are the answer, in the order they are to be graded and displayed. Every id
83    /// must be an upload this user made for this exercise.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub data_files: Option<Vec<Uuid>>,
86}
87
88impl StudentExerciseTaskSubmission {
89    /// A JSON answer, the shape a submit takes when no files are involved.
90    pub fn json(exercise_task_id: Uuid, data: serde_json::Value) -> Self {
91        Self {
92            exercise_task_id,
93            answer_kind: Some(AnswerKind::Json),
94            data_json: Some(data),
95            data_files: None,
96        }
97    }
98
99    /// A file answer naming host-stored uploads, in the order they are to be graded and displayed.
100    pub fn files(
101        exercise_task_id: Uuid,
102        data_files: Vec<Uuid>,
103        data_json: Option<serde_json::Value>,
104    ) -> Self {
105        Self {
106            exercise_task_id,
107            answer_kind: Some(AnswerKind::File),
108            data_json,
109            data_files: Some(data_files),
110        }
111    }
112
113    /// The uploads this answer names, empty unless it is a file answer.
114    pub fn named_file_ids(&self) -> &[Uuid] {
115        match self.answer_kind {
116            Some(AnswerKind::File) => self.data_files.as_deref().unwrap_or_default(),
117            _ => &[],
118        }
119    }
120
121    /// The internal form of the answer, rejecting the one combination the flat fields allow but the
122    /// answer model does not: a JSON answer that also names files, which would silently drop them.
123    pub fn to_submitted_answer(&self) -> ModelResult<SubmittedAnswer> {
124        match self.answer_kind.unwrap_or(AnswerKind::Json) {
125            AnswerKind::Json => {
126                if self.data_files.as_ref().is_some_and(|ids| !ids.is_empty()) {
127                    return Err(model_err!(
128                        InvalidRequest,
129                        "A json answer cannot name uploaded files. Send answer_kind 'file' to submit files.".to_string()
130                    ));
131                }
132                Ok(SubmittedAnswer::Json {
133                    data: self.data_json.clone().unwrap_or(serde_json::Value::Null),
134                })
135            }
136            AnswerKind::File => Ok(SubmittedAnswer::File {
137                file_upload_ids: self.data_files.clone().unwrap_or_default(),
138                metadata: self.data_json.clone(),
139            }),
140        }
141    }
142}
143
144/// The answer a student submits for one exercise task, as either JSON or a set of host-stored
145/// files.
146///
147/// Internal: [`StudentExerciseTaskSubmission`] is what a client sends, and converting it here is
148/// what validates the combination of its flat fields.
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(tag = "kind", rename_all = "snake_case")]
151pub enum SubmittedAnswer {
152    Json {
153        data: serde_json::Value,
154    },
155    File {
156        /// Ordered; the order is part of the answer (exercise-file-submission grades by position).
157        /// Every id must be an upload this user made for this exercise.
158        file_upload_ids: Vec<Uuid>,
159        /// The plugin's own JSON about the files. `None` for a plugin whose answer is the files.
160        metadata: Option<serde_json::Value>,
161    },
162}
163
164#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
165
166pub struct StudentExerciseTaskSubmissionResult {
167    pub submission: ExerciseTaskSubmission,
168    pub grading: Option<ExerciseTaskGrading>,
169    pub model_solution_spec: Option<serde_json::Value>,
170    pub exercise_task_exercise_service_slug: String,
171}
172
173#[derive(Debug)]
174pub struct ExerciseSlideSubmissionWithTasks {
175    pub exercise_slide_submission: ExerciseSlideSubmission,
176    pub exercise_slide_submission_tasks: Vec<ExerciseTaskSubmission>,
177}
178
179/// If passed to to an exercise state update, it will update the peer review status with the given information
180#[derive(Debug)]
181pub struct ExerciseStateUpdateNeedToUpdatePeerReviewStatusWithThis {
182    pub given_enough_peer_reviews: bool,
183    pub received_enough_peer_reviews: bool,
184    pub peer_review_processing_strategy: PeerReviewProcessingStrategy,
185    pub peer_review_accepting_threshold: f32,
186    /// Used to for calculating averages when acting on PeerReviewProcessingStrategy
187    pub received_peer_or_self_review_question_submissions: Vec<PeerOrSelfReviewQuestionSubmission>,
188}
189
190/// Inserts user submission to database. Tasks within submission are validated to make sure that
191/// they belong to the correct exercise slide.
192pub async fn create_user_exercise_slide_submission(
193    conn: &mut PgConnection,
194    exercise_with_user_state: &ExerciseWithUserState,
195    user_exercise_slide_submission: &StudentExerciseSlideSubmission,
196    file_store: &dyn FileStore,
197    app_conf: &ApplicationConfiguration,
198) -> ModelResult<ExerciseSlideSubmissionWithTasks> {
199    let selected_exercise_slide_id = exercise_with_user_state
200        .user_exercise_state()
201        .selected_exercise_slide_id
202        .ok_or_else(|| {
203            ModelError::new(
204                ModelErrorType::PreconditionFailed,
205                "Exercise slide not selected for the student.".to_string(),
206                None,
207            )
208        })?;
209    let exercise_tasks: HashMap<Uuid, ExerciseTask> =
210        exercise_tasks::get_exercise_tasks_by_exercise_slide_id(conn, &selected_exercise_slide_id)
211            .await?;
212    let user_points_update_strategy = if exercise_with_user_state.is_exam_exercise() {
213        UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints
214    } else {
215        UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints
216    };
217
218    let mut tx = conn.begin().await?;
219
220    let exercise_slide_submission = exercise_slide_submissions::insert_exercise_slide_submission(
221        &mut tx,
222        NewExerciseSlideSubmission {
223            exercise_slide_id: selected_exercise_slide_id,
224            course_id: exercise_with_user_state.exercise().course_id,
225            exam_id: exercise_with_user_state.exercise().exam_id,
226            exercise_id: exercise_with_user_state.exercise().id,
227            user_id: exercise_with_user_state.user_exercise_state().user_id,
228            user_points_update_strategy,
229        },
230    )
231    .await?;
232    let user_exercise_task_submissions = &user_exercise_slide_submission.exercise_task_submissions;
233    let mut exercise_slide_submission_tasks =
234        Vec::with_capacity(user_exercise_task_submissions.len());
235    for task_submission in user_exercise_task_submissions {
236        let exercise_task = exercise_tasks
237            .get(&task_submission.exercise_task_id)
238            .ok_or_else(|| {
239                ModelError::new(
240                    ModelErrorType::PreconditionFailed,
241                    "Attempting to submit exercise for illegal exercise_task_id.".to_string(),
242                    None,
243                )
244            })?;
245        let submission_id = exercise_task_submissions::insert(
246            &mut tx,
247            PKeyPolicy::Generate,
248            exercise_slide_submission.id,
249            exercise_task.exercise_slide_id,
250            exercise_task.id,
251            &task_submission.to_submitted_answer()?,
252        )
253        .await?;
254        let submission =
255            exercise_task_submissions::get_by_id(&mut tx, submission_id, file_store, app_conf)
256                .await?;
257        exercise_slide_submission_tasks.push(submission)
258    }
259
260    tx.commit().await?;
261    Ok(ExerciseSlideSubmissionWithTasks {
262        exercise_slide_submission,
263        exercise_slide_submission_tasks,
264    })
265}
266
267// Relocated regrading logic to condensate score update logic in a single place.
268// Needs better separation of concerns in the far future.
269pub async fn update_grading_with_single_regrading_result(
270    conn: &mut PgConnection,
271    exercise: &Exercise,
272    regrading_submission: &ExerciseTaskRegradingSubmission,
273    exercise_task_grading: &ExerciseTaskGrading,
274    exercise_task_grading_result: &ExerciseTaskGradingResult,
275    file_store: &dyn FileStore,
276    app_conf: &ApplicationConfiguration,
277) -> ModelResult<()> {
278    let task_submission = exercise_task_submissions::get_by_id(
279        &mut *conn,
280        regrading_submission.exercise_task_submission_id,
281        file_store,
282        app_conf,
283    )
284    .await?;
285    let slide_submission = exercise_slide_submissions::get_by_id(
286        &mut *conn,
287        task_submission.exercise_slide_submission_id,
288    )
289    .await?;
290    let regrading = regradings::get_by_id(&mut *conn, regrading_submission.regrading_id).await?;
291    apply_grading_result_to_user_state(
292        conn,
293        exercise,
294        &slide_submission,
295        exercise_task_grading,
296        exercise_task_grading_result,
297        regrading.user_points_update_strategy,
298    )
299    .await?;
300    Ok(())
301}
302
303/// Applies a grading result that an exercise service sent after answering the grading request, to
304/// both the grading and the student's exercise state.
305///
306/// Points follow the submission's own [`UserPointsUpdateStrategy`], as they would have had the
307/// service graded synchronously. For a regrading, use [`update_grading_with_single_regrading_result`].
308pub async fn apply_grading_update(
309    conn: &mut PgConnection,
310    exercise: &Exercise,
311    task_submission: &ExerciseTaskSubmission,
312    exercise_task_grading: &ExerciseTaskGrading,
313    exercise_task_grading_result: &ExerciseTaskGradingResult,
314) -> ModelResult<UserExerciseState> {
315    let slide_submission = exercise_slide_submissions::get_by_id(
316        &mut *conn,
317        task_submission.exercise_slide_submission_id,
318    )
319    .await?;
320    apply_grading_result_to_user_state(
321        conn,
322        exercise,
323        &slide_submission,
324        exercise_task_grading,
325        exercise_task_grading_result,
326        slide_submission.user_points_update_strategy,
327    )
328    .await
329}
330
331async fn apply_grading_result_to_user_state(
332    conn: &mut PgConnection,
333    exercise: &Exercise,
334    slide_submission: &ExerciseSlideSubmission,
335    exercise_task_grading: &ExerciseTaskGrading,
336    exercise_task_grading_result: &ExerciseTaskGradingResult,
337    user_points_update_strategy: UserPointsUpdateStrategy,
338) -> ModelResult<UserExerciseState> {
339    let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
340        conn,
341        slide_submission.user_id,
342        exercise.id,
343        slide_submission.course_id,
344        slide_submission.exam_id,
345    )
346    .await?;
347    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
348        &mut *conn,
349        user_exercise_state.id,
350        slide_submission.exercise_slide_id,
351    )
352    .await?;
353    propagate_user_exercise_state_update_from_exercise_task_grading_result(
354        conn,
355        exercise,
356        exercise_task_grading,
357        exercise_task_grading_result,
358        user_exercise_slide_state,
359        user_points_update_strategy,
360    )
361    .await
362}
363
364pub enum GradingPolicy {
365    /// Grades exercise tasks by sending a request to their respective services.
366    Default,
367    /// Intended for test purposes only.
368    Fixed(HashMap<Uuid, ExerciseTaskGradingResult>),
369}
370
371#[allow(clippy::too_many_arguments)]
372pub async fn grade_user_submission(
373    conn: &mut PgConnection,
374    exercise_with_user_state: &mut ExerciseWithUserState,
375    user_exercise_slide_submission: &StudentExerciseSlideSubmission,
376    grading_policy: GradingPolicy,
377    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
378    send_grading_request: impl Fn(
379        Url,
380        &ExerciseTask,
381        &ExerciseTaskSubmission,
382    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
383    file_store: &dyn FileStore,
384    app_conf: &ApplicationConfiguration,
385) -> ModelResult<StudentExerciseSlideSubmissionResult> {
386    let mut tx = conn.begin().await?;
387
388    let ExerciseSlideSubmissionWithTasks {
389        exercise_slide_submission,
390        exercise_slide_submission_tasks,
391    } = create_user_exercise_slide_submission(
392        &mut tx,
393        exercise_with_user_state,
394        user_exercise_slide_submission,
395        file_store,
396        app_conf,
397    )
398    .await?;
399    let user_exercise_state = exercise_with_user_state.user_exercise_state();
400    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
401        &mut tx,
402        user_exercise_state.id,
403        exercise_slide_submission.exercise_slide_id,
404    )
405    .await?;
406    let results = match grading_policy {
407        GradingPolicy::Default => {
408            let mut results = Vec::with_capacity(exercise_slide_submission_tasks.len());
409            for task_submission in exercise_slide_submission_tasks {
410                let submission = grade_user_submission_task(
411                    &mut tx,
412                    &task_submission,
413                    exercise_with_user_state.exercise(),
414                    user_exercise_slide_state.id,
415                    user_exercise_state,
416                    &fetch_service_info,
417                    &send_grading_request,
418                    file_store,
419                    app_conf,
420                )
421                .await;
422                match submission {
423                    Ok(submission) => results.push(submission),
424                    Err(err) => {
425                        // Store rejected submission when HTTP call fails
426                        let (http_status_code, error_message, response_body) =
427                            match err.error_type() {
428                                crate::error::ModelErrorType::HttpRequest {
429                                    status_code,
430                                    response_body,
431                                } => (
432                                    Some(*status_code as i32),
433                                    Some(response_body.clone()),
434                                    Some(response_body.clone()),
435                                ),
436                                crate::error::ModelErrorType::HttpError {
437                                    error_type: crate::error::HttpErrorType::ResponseDecodeFailed,
438                                    response_body,
439                                    status_code,
440                                    ..
441                                } => (
442                                    status_code.map(|s| s as i32),
443                                    Some(err.to_string()),
444                                    response_body.clone(),
445                                ),
446                                _ => (None, Some(err.to_string()), None),
447                            };
448
449                        // We want to save the rejected submission to the database
450                        // But don't want to keep the rest of the stuff we have inserted into the database
451                        tx.rollback().await?;
452                        let mut tx = conn.begin().await?;
453
454                        let _ = crate::rejected_exercise_slide_submissions::insert_rejected_exercise_slide_submission(
455                            &mut tx,
456                            user_exercise_slide_submission,
457                            user_exercise_state.user_id,
458                            http_status_code,
459                            error_message,
460                            response_body,
461                        ).await;
462
463                        tx.commit().await?;
464
465                        return Err(err);
466                    }
467                }
468            }
469            results
470        }
471        GradingPolicy::Fixed(fixed_results) => {
472            let mut results = Vec::with_capacity(exercise_slide_submission_tasks.len());
473            for task_submission in exercise_slide_submission_tasks {
474                let fixed_result = fixed_results
475                    .get(&task_submission.exercise_task_id)
476                    .ok_or_else(|| {
477                        ModelError::new(
478                            ModelErrorType::Generic,
479                            "Could not find fixed test result for testing".to_string(),
480                            None,
481                        )
482                    })?
483                    .clone();
484                let submission = create_fixed_grading_for_submission_task(
485                    &mut tx,
486                    &task_submission,
487                    exercise_with_user_state.exercise(),
488                    user_exercise_slide_state.id,
489                    &fixed_result,
490                    file_store,
491                    app_conf,
492                )
493                .await?;
494                results.push(submission);
495            }
496            results
497        }
498    };
499    let user_exercise_state = update_user_exercise_slide_state_and_user_exercise_state(
500        &mut tx,
501        user_exercise_slide_state,
502        exercise_slide_submission.user_points_update_strategy,
503    )
504    .await?;
505
506    let course_or_exam_id = CourseOrExamId::from_course_and_exam_ids(
507        user_exercise_state.course_id,
508        user_exercise_state.exam_id,
509    )?;
510
511    let user_course_instance_exercise_service_variables  = crate::user_course_exercise_service_variables::get_all_variables_for_user_and_course_or_exam(&mut tx, user_exercise_state.user_id, course_or_exam_id).await?;
512
513    let result = StudentExerciseSlideSubmissionResult {
514        exercise_status: Some(ExerciseStatus {
515            score_given: user_exercise_state.score_given,
516            activity_progress: user_exercise_state.activity_progress,
517            grading_progress: user_exercise_state.grading_progress,
518            reviewing_stage: user_exercise_state.reviewing_stage,
519        }),
520        exercise_task_submission_results: results,
521        user_course_instance_exercise_service_variables,
522    };
523    exercise_with_user_state.set_user_exercise_state(user_exercise_state)?;
524    tx.commit().await?;
525    Ok(result)
526}
527
528#[allow(clippy::too_many_arguments)]
529async fn grade_user_submission_task(
530    conn: &mut PgConnection,
531    submission: &ExerciseTaskSubmission,
532    exercise: &Exercise,
533    user_exercise_slide_state_id: Uuid,
534    user_exercise_state: &UserExerciseState,
535    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
536    send_grading_request: impl Fn(
537        Url,
538        &ExerciseTask,
539        &ExerciseTaskSubmission,
540    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
541    file_store: &dyn FileStore,
542    app_conf: &ApplicationConfiguration,
543) -> ModelResult<StudentExerciseTaskSubmissionResult> {
544    let grading = exercise_task_gradings::new_grading(conn, exercise, submission).await?;
545    exercise_task_submissions::set_grading_id(conn, grading.id, submission.id).await?;
546    let updated_submission =
547        exercise_task_submissions::get_by_id(conn, submission.id, file_store, app_conf).await?;
548    let exercise_task =
549        exercise_tasks::get_exercise_task_by_id(conn, submission.exercise_task_id).await?;
550    let grading = exercise_task_gradings::grade_submission(
551        conn,
552        submission,
553        &exercise_task,
554        exercise,
555        &grading,
556        user_exercise_state,
557        fetch_service_info,
558        send_grading_request,
559    )
560    .await?;
561    user_exercise_task_states::upsert_with_grading(conn, user_exercise_slide_state_id, &grading)
562        .await?;
563    let model_solution_spec = exercise_tasks::get_exercise_task_model_solution_spec_by_id(
564        conn,
565        submission.exercise_task_id,
566    )
567    .await?;
568
569    Ok(StudentExerciseTaskSubmissionResult {
570        submission: updated_submission,
571        grading: Some(grading),
572        model_solution_spec,
573        exercise_task_exercise_service_slug: exercise_task.exercise_type,
574    })
575}
576
577async fn create_fixed_grading_for_submission_task(
578    conn: &mut PgConnection,
579    submission: &ExerciseTaskSubmission,
580    exercise: &Exercise,
581    user_exercise_slide_state_id: Uuid,
582    fixed_result: &ExerciseTaskGradingResult,
583    file_store: &dyn FileStore,
584    app_conf: &ApplicationConfiguration,
585) -> ModelResult<StudentExerciseTaskSubmissionResult> {
586    let grading = exercise_task_gradings::new_grading(conn, exercise, submission).await?;
587    exercise_task_submissions::set_grading_id(conn, grading.id, submission.id).await?;
588    let updated_submission =
589        exercise_task_submissions::get_by_id(conn, submission.id, file_store, app_conf).await?;
590    let updated_grading =
591        exercise_task_gradings::update_grading(conn, &grading, fixed_result, exercise).await?;
592    user_exercise_task_states::upsert_with_grading(
593        conn,
594        user_exercise_slide_state_id,
595        &updated_grading,
596    )
597    .await?;
598    let exercise_task =
599        exercise_tasks::get_exercise_task_by_id(conn, submission.exercise_task_id).await?;
600    let model_solution_spec = exercise_task.model_solution_spec;
601
602    Ok(StudentExerciseTaskSubmissionResult {
603        submission: updated_submission,
604        grading: Some(grading),
605        model_solution_spec,
606        exercise_task_exercise_service_slug: exercise_task.exercise_type,
607    })
608}
609
610/// Updates the user exercise state starting from a slide state, and propagates the update up to the
611/// whole user exercise state.
612async fn update_user_exercise_slide_state_and_user_exercise_state(
613    conn: &mut PgConnection,
614    user_exercise_slide_state: UserExerciseSlideState,
615    user_points_update_strategy: UserPointsUpdateStrategy,
616) -> ModelResult<UserExerciseState> {
617    update_user_exercise_slide_state(
618        conn,
619        &user_exercise_slide_state,
620        user_points_update_strategy,
621    )
622    .await?;
623    let user_exercise_state = user_exercise_state_updater::update_user_exercise_state(
624        conn,
625        user_exercise_slide_state.user_exercise_state_id,
626    )
627    .await?;
628
629    Ok(user_exercise_state)
630}
631
632async fn update_user_exercise_slide_state(
633    conn: &mut PgConnection,
634    user_exercise_slide_state: &UserExerciseSlideState,
635    user_points_update_strategy: UserPointsUpdateStrategy,
636) -> ModelResult<()> {
637    let (points_from_tasks, grading_progress) =
638        user_exercise_task_states::get_grading_summary_by_user_exercise_slide_state_id(
639            conn,
640            user_exercise_slide_state.id,
641        )
642        .await?;
643    let new_score_given = user_exercise_task_states::figure_out_new_score_given(
644        user_exercise_slide_state.score_given,
645        points_from_tasks,
646        user_points_update_strategy,
647    );
648    let changes = user_exercise_slide_states::update(
649        conn,
650        user_exercise_slide_state.id,
651        new_score_given,
652        grading_progress,
653    )
654    .await?;
655    info!(
656        "Updating user exercise slide state {} affected {} rows.",
657        user_exercise_slide_state.id, changes
658    );
659    Ok(())
660}
661
662/// Updates the user exercise state starting from a single task, and propagates the update up to the
663/// whole user exercise state.
664pub async fn propagate_user_exercise_state_update_from_exercise_task_grading_result(
665    conn: &mut PgConnection,
666    exercise: &Exercise,
667    exercise_task_grading: &ExerciseTaskGrading,
668    exercise_task_grading_result: &ExerciseTaskGradingResult,
669    user_exercise_slide_state: UserExerciseSlideState,
670    user_points_update_strategy: UserPointsUpdateStrategy,
671) -> ModelResult<UserExerciseState> {
672    let updated_exercise_task_grading = exercise_task_gradings::update_grading(
673        conn,
674        exercise_task_grading,
675        exercise_task_grading_result,
676        exercise,
677    )
678    .await?;
679    exercise_task_submissions::set_grading_id(
680        conn,
681        updated_exercise_task_grading.id,
682        updated_exercise_task_grading.exercise_task_submission_id,
683    )
684    .await?;
685    let user_exercise_task_state = user_exercise_task_states::upsert_with_grading(
686        conn,
687        user_exercise_slide_state.id,
688        &updated_exercise_task_grading,
689    )
690    .await?;
691    let user_exercise_slide_state = user_exercise_slide_states::get_by_id(
692        conn,
693        user_exercise_task_state.user_exercise_slide_state_id,
694    )
695    .await?;
696    let user_exercise_state = update_user_exercise_slide_state_and_user_exercise_state(
697        conn,
698        user_exercise_slide_state,
699        user_points_update_strategy,
700    )
701    .await?;
702    Ok(user_exercise_state)
703}
704
705#[derive(Debug, Serialize, ToSchema)]
706
707pub struct AnswersRequiringAttention {
708    pub exercise_max_points: i32,
709    pub data: Vec<AnswerRequiringAttentionWithTasks>,
710    pub total_pages: u32,
711}
712
713#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
714
715pub struct AnswerRequiringAttentionWithTasks {
716    pub id: Uuid,
717    pub user_id: Uuid,
718    pub created_at: DateTime<Utc>,
719    pub updated_at: DateTime<Utc>,
720    pub deleted_at: Option<DateTime<Utc>>,
721    pub answer_kind: AnswerKind,
722    /// The plugin's own JSON: the whole answer for a `json` answer, the plugin's metadata about the
723    /// files for a `file` one.
724    pub data_json: Option<serde_json::Value>,
725    /// The files the answer consists of, in grading order. Omitted when it has none.
726    #[serde(skip_serializing_if = "Option::is_none")]
727    pub data_files: Option<Vec<AnswerFile>>,
728    pub grading_progress: GradingProgress,
729    pub score_given: Option<f32>,
730    pub submission_id: Uuid,
731    pub exercise_id: Uuid,
732    pub tasks: Vec<CourseMaterialExerciseTask>,
733    pub given_peer_reviews: Vec<PeerReviewWithQuestionsAndAnswers>,
734    pub received_peer_or_self_reviews: Vec<PeerReviewWithQuestionsAndAnswers>,
735    pub received_peer_review_flagging_reports: Vec<FlaggedAnswer>,
736}
737
738/// Gets submissions that require input from the teacher to continue processing.
739pub async fn get_paginated_answers_requiring_attention_for_exercise(
740    conn: &mut PgConnection,
741    exercise_id: Uuid,
742    pagination: Pagination,
743    viewer_user_id: Uuid,
744    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
745    file_store: &dyn FileStore,
746    app_conf: &ApplicationConfiguration,
747) -> ModelResult<AnswersRequiringAttention> {
748    let exercise = exercises::get_exercise_by_id(conn, exercise_id).await?;
749    let answer_requiring_attention_count =
750        exercise_slide_submissions::answer_requiring_attention_count(conn, exercise_id).await?;
751    let data = exercise_slide_submissions::get_all_answers_requiring_attention(
752        conn,
753        exercise.id,
754        pagination,
755        file_store,
756        app_conf,
757    )
758    .await?;
759    let mut answers = Vec::with_capacity(data.len());
760    for answer in &data {
761        let tasks = exercise_task_submissions::get_exercise_task_submission_info_by_exercise_slide_submission_id(
762            conn,
763            answer.submission_id,
764            viewer_user_id,
765            &fetch_service_info,
766            false,
767            file_store,
768            app_conf,
769        )
770        .await?;
771        let given_peer_reviews = peer_or_self_review_question_submissions::get_given_peer_reviews(
772            conn,
773            answer.user_id,
774            answer.exercise_id,
775        )
776        .await?;
777
778        let received_peer_or_self_reviews =
779            peer_or_self_review_question_submissions::get_questions_and_answers_by_submission_id(
780                conn,
781                answer.submission_id,
782            )
783            .await?;
784        let received_peer_review_flagging_reports: Vec<FlaggedAnswer> =
785            flagged_answers::get_flagged_answers_by_submission_id(conn, answer.submission_id)
786                .await?;
787        let answer_fields = AnswerFields::from(answer.answer.to_owned());
788        let new_answer = AnswerRequiringAttentionWithTasks {
789            id: answer.id,
790            user_id: answer.user_id,
791            created_at: answer.created_at,
792            updated_at: answer.updated_at,
793            deleted_at: answer.deleted_at,
794            answer_kind: answer_fields.answer_kind,
795            data_json: answer_fields.data_json,
796            data_files: answer_fields.data_files,
797            grading_progress: answer.grading_progress,
798            score_given: answer.score_given,
799            submission_id: answer.submission_id,
800            exercise_id: answer.exercise_id,
801            tasks,
802            given_peer_reviews,
803            received_peer_or_self_reviews,
804            received_peer_review_flagging_reports,
805        };
806        answers.push(new_answer);
807    }
808    Ok(AnswersRequiringAttention {
809        exercise_max_points: exercise.score_maximum,
810        data: answers,
811        total_pages: pagination.total_pages(answer_requiring_attention_count),
812    })
813}