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 user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
291        conn,
292        slide_submission.user_id,
293        exercise.id,
294        slide_submission.course_id,
295        slide_submission.exam_id,
296    )
297    .await?;
298    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
299        &mut *conn,
300        user_exercise_state.id,
301        slide_submission.exercise_slide_id,
302    )
303    .await?;
304    let regrading = regradings::get_by_id(&mut *conn, regrading_submission.regrading_id).await?;
305    propagate_user_exercise_state_update_from_exercise_task_grading_result(
306        conn,
307        exercise,
308        exercise_task_grading,
309        exercise_task_grading_result,
310        user_exercise_slide_state,
311        regrading.user_points_update_strategy,
312    )
313    .await?;
314    Ok(())
315}
316
317pub enum GradingPolicy {
318    /// Grades exercise tasks by sending a request to their respective services.
319    Default,
320    /// Intended for test purposes only.
321    Fixed(HashMap<Uuid, ExerciseTaskGradingResult>),
322}
323
324#[allow(clippy::too_many_arguments)]
325pub async fn grade_user_submission(
326    conn: &mut PgConnection,
327    exercise_with_user_state: &mut ExerciseWithUserState,
328    user_exercise_slide_submission: &StudentExerciseSlideSubmission,
329    grading_policy: GradingPolicy,
330    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
331    send_grading_request: impl Fn(
332        Url,
333        &ExerciseTask,
334        &ExerciseTaskSubmission,
335    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
336    file_store: &dyn FileStore,
337    app_conf: &ApplicationConfiguration,
338) -> ModelResult<StudentExerciseSlideSubmissionResult> {
339    let mut tx = conn.begin().await?;
340
341    let ExerciseSlideSubmissionWithTasks {
342        exercise_slide_submission,
343        exercise_slide_submission_tasks,
344    } = create_user_exercise_slide_submission(
345        &mut tx,
346        exercise_with_user_state,
347        user_exercise_slide_submission,
348        file_store,
349        app_conf,
350    )
351    .await?;
352    let user_exercise_state = exercise_with_user_state.user_exercise_state();
353    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
354        &mut tx,
355        user_exercise_state.id,
356        exercise_slide_submission.exercise_slide_id,
357    )
358    .await?;
359    let results = match grading_policy {
360        GradingPolicy::Default => {
361            let mut results = Vec::with_capacity(exercise_slide_submission_tasks.len());
362            for task_submission in exercise_slide_submission_tasks {
363                let submission = grade_user_submission_task(
364                    &mut tx,
365                    &task_submission,
366                    exercise_with_user_state.exercise(),
367                    user_exercise_slide_state.id,
368                    user_exercise_state,
369                    &fetch_service_info,
370                    &send_grading_request,
371                    file_store,
372                    app_conf,
373                )
374                .await;
375                match submission {
376                    Ok(submission) => results.push(submission),
377                    Err(err) => {
378                        // Store rejected submission when HTTP call fails
379                        let (http_status_code, error_message, response_body) =
380                            match err.error_type() {
381                                crate::error::ModelErrorType::HttpRequest {
382                                    status_code,
383                                    response_body,
384                                } => (
385                                    Some(*status_code as i32),
386                                    Some(response_body.clone()),
387                                    Some(response_body.clone()),
388                                ),
389                                crate::error::ModelErrorType::HttpError {
390                                    error_type: crate::error::HttpErrorType::ResponseDecodeFailed,
391                                    response_body,
392                                    status_code,
393                                    ..
394                                } => (
395                                    status_code.map(|s| s as i32),
396                                    Some(err.to_string()),
397                                    response_body.clone(),
398                                ),
399                                _ => (None, Some(err.to_string()), None),
400                            };
401
402                        // We want to save the rejected submission to the database
403                        // But don't want to keep the rest of the stuff we have inserted into the database
404                        tx.rollback().await?;
405                        let mut tx = conn.begin().await?;
406
407                        let _ = crate::rejected_exercise_slide_submissions::insert_rejected_exercise_slide_submission(
408                            &mut tx,
409                            user_exercise_slide_submission,
410                            user_exercise_state.user_id,
411                            http_status_code,
412                            error_message,
413                            response_body,
414                        ).await;
415
416                        tx.commit().await?;
417
418                        return Err(err);
419                    }
420                }
421            }
422            results
423        }
424        GradingPolicy::Fixed(fixed_results) => {
425            let mut results = Vec::with_capacity(exercise_slide_submission_tasks.len());
426            for task_submission in exercise_slide_submission_tasks {
427                let fixed_result = fixed_results
428                    .get(&task_submission.exercise_task_id)
429                    .ok_or_else(|| {
430                        ModelError::new(
431                            ModelErrorType::Generic,
432                            "Could not find fixed test result for testing".to_string(),
433                            None,
434                        )
435                    })?
436                    .clone();
437                let submission = create_fixed_grading_for_submission_task(
438                    &mut tx,
439                    &task_submission,
440                    exercise_with_user_state.exercise(),
441                    user_exercise_slide_state.id,
442                    &fixed_result,
443                    file_store,
444                    app_conf,
445                )
446                .await?;
447                results.push(submission);
448            }
449            results
450        }
451    };
452    let user_exercise_state = update_user_exercise_slide_state_and_user_exercise_state(
453        &mut tx,
454        user_exercise_slide_state,
455        exercise_slide_submission.user_points_update_strategy,
456    )
457    .await?;
458
459    let course_or_exam_id = CourseOrExamId::from_course_and_exam_ids(
460        user_exercise_state.course_id,
461        user_exercise_state.exam_id,
462    )?;
463
464    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?;
465
466    let result = StudentExerciseSlideSubmissionResult {
467        exercise_status: Some(ExerciseStatus {
468            score_given: user_exercise_state.score_given,
469            activity_progress: user_exercise_state.activity_progress,
470            grading_progress: user_exercise_state.grading_progress,
471            reviewing_stage: user_exercise_state.reviewing_stage,
472        }),
473        exercise_task_submission_results: results,
474        user_course_instance_exercise_service_variables,
475    };
476    exercise_with_user_state.set_user_exercise_state(user_exercise_state)?;
477    tx.commit().await?;
478    Ok(result)
479}
480
481#[allow(clippy::too_many_arguments)]
482async fn grade_user_submission_task(
483    conn: &mut PgConnection,
484    submission: &ExerciseTaskSubmission,
485    exercise: &Exercise,
486    user_exercise_slide_state_id: Uuid,
487    user_exercise_state: &UserExerciseState,
488    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
489    send_grading_request: impl Fn(
490        Url,
491        &ExerciseTask,
492        &ExerciseTaskSubmission,
493    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
494    file_store: &dyn FileStore,
495    app_conf: &ApplicationConfiguration,
496) -> ModelResult<StudentExerciseTaskSubmissionResult> {
497    let grading = exercise_task_gradings::new_grading(conn, exercise, submission).await?;
498    exercise_task_submissions::set_grading_id(conn, grading.id, submission.id).await?;
499    let updated_submission =
500        exercise_task_submissions::get_by_id(conn, submission.id, file_store, app_conf).await?;
501    let exercise_task =
502        exercise_tasks::get_exercise_task_by_id(conn, submission.exercise_task_id).await?;
503    let grading = exercise_task_gradings::grade_submission(
504        conn,
505        submission,
506        &exercise_task,
507        exercise,
508        &grading,
509        user_exercise_state,
510        fetch_service_info,
511        send_grading_request,
512    )
513    .await?;
514    user_exercise_task_states::upsert_with_grading(conn, user_exercise_slide_state_id, &grading)
515        .await?;
516    let model_solution_spec = exercise_tasks::get_exercise_task_model_solution_spec_by_id(
517        conn,
518        submission.exercise_task_id,
519    )
520    .await?;
521
522    Ok(StudentExerciseTaskSubmissionResult {
523        submission: updated_submission,
524        grading: Some(grading),
525        model_solution_spec,
526        exercise_task_exercise_service_slug: exercise_task.exercise_type,
527    })
528}
529
530async fn create_fixed_grading_for_submission_task(
531    conn: &mut PgConnection,
532    submission: &ExerciseTaskSubmission,
533    exercise: &Exercise,
534    user_exercise_slide_state_id: Uuid,
535    fixed_result: &ExerciseTaskGradingResult,
536    file_store: &dyn FileStore,
537    app_conf: &ApplicationConfiguration,
538) -> ModelResult<StudentExerciseTaskSubmissionResult> {
539    let grading = exercise_task_gradings::new_grading(conn, exercise, submission).await?;
540    exercise_task_submissions::set_grading_id(conn, grading.id, submission.id).await?;
541    let updated_submission =
542        exercise_task_submissions::get_by_id(conn, submission.id, file_store, app_conf).await?;
543    let updated_grading =
544        exercise_task_gradings::update_grading(conn, &grading, fixed_result, exercise).await?;
545    user_exercise_task_states::upsert_with_grading(
546        conn,
547        user_exercise_slide_state_id,
548        &updated_grading,
549    )
550    .await?;
551    let exercise_task =
552        exercise_tasks::get_exercise_task_by_id(conn, submission.exercise_task_id).await?;
553    let model_solution_spec = exercise_task.model_solution_spec;
554
555    Ok(StudentExerciseTaskSubmissionResult {
556        submission: updated_submission,
557        grading: Some(grading),
558        model_solution_spec,
559        exercise_task_exercise_service_slug: exercise_task.exercise_type,
560    })
561}
562
563/// Updates the user exercise state starting from a slide state, and propagates the update up to the
564/// whole user exercise state.
565async fn update_user_exercise_slide_state_and_user_exercise_state(
566    conn: &mut PgConnection,
567    user_exercise_slide_state: UserExerciseSlideState,
568    user_points_update_strategy: UserPointsUpdateStrategy,
569) -> ModelResult<UserExerciseState> {
570    update_user_exercise_slide_state(
571        conn,
572        &user_exercise_slide_state,
573        user_points_update_strategy,
574    )
575    .await?;
576    let user_exercise_state = user_exercise_state_updater::update_user_exercise_state(
577        conn,
578        user_exercise_slide_state.user_exercise_state_id,
579    )
580    .await?;
581
582    Ok(user_exercise_state)
583}
584
585async fn update_user_exercise_slide_state(
586    conn: &mut PgConnection,
587    user_exercise_slide_state: &UserExerciseSlideState,
588    user_points_update_strategy: UserPointsUpdateStrategy,
589) -> ModelResult<()> {
590    let (points_from_tasks, grading_progress) =
591        user_exercise_task_states::get_grading_summary_by_user_exercise_slide_state_id(
592            conn,
593            user_exercise_slide_state.id,
594        )
595        .await?;
596    let new_score_given = user_exercise_task_states::figure_out_new_score_given(
597        user_exercise_slide_state.score_given,
598        points_from_tasks,
599        user_points_update_strategy,
600    );
601    let changes = user_exercise_slide_states::update(
602        conn,
603        user_exercise_slide_state.id,
604        new_score_given,
605        grading_progress,
606    )
607    .await?;
608    info!(
609        "Updating user exercise slide state {} affected {} rows.",
610        user_exercise_slide_state.id, changes
611    );
612    Ok(())
613}
614
615/// Updates the user exercise state starting from a single task, and propagates the update up to the
616/// whole user exercise state.
617pub async fn propagate_user_exercise_state_update_from_exercise_task_grading_result(
618    conn: &mut PgConnection,
619    exercise: &Exercise,
620    exercise_task_grading: &ExerciseTaskGrading,
621    exercise_task_grading_result: &ExerciseTaskGradingResult,
622    user_exercise_slide_state: UserExerciseSlideState,
623    user_points_update_strategy: UserPointsUpdateStrategy,
624) -> ModelResult<UserExerciseState> {
625    let updated_exercise_task_grading = exercise_task_gradings::update_grading(
626        conn,
627        exercise_task_grading,
628        exercise_task_grading_result,
629        exercise,
630    )
631    .await?;
632    exercise_task_submissions::set_grading_id(
633        conn,
634        updated_exercise_task_grading.id,
635        updated_exercise_task_grading.exercise_task_submission_id,
636    )
637    .await?;
638    let user_exercise_task_state = user_exercise_task_states::upsert_with_grading(
639        conn,
640        user_exercise_slide_state.id,
641        &updated_exercise_task_grading,
642    )
643    .await?;
644    let user_exercise_slide_state = user_exercise_slide_states::get_by_id(
645        conn,
646        user_exercise_task_state.user_exercise_slide_state_id,
647    )
648    .await?;
649    let user_exercise_state = update_user_exercise_slide_state_and_user_exercise_state(
650        conn,
651        user_exercise_slide_state,
652        user_points_update_strategy,
653    )
654    .await?;
655    Ok(user_exercise_state)
656}
657
658#[derive(Debug, Serialize, ToSchema)]
659
660pub struct AnswersRequiringAttention {
661    pub exercise_max_points: i32,
662    pub data: Vec<AnswerRequiringAttentionWithTasks>,
663    pub total_pages: u32,
664}
665
666#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
667
668pub struct AnswerRequiringAttentionWithTasks {
669    pub id: Uuid,
670    pub user_id: Uuid,
671    pub created_at: DateTime<Utc>,
672    pub updated_at: DateTime<Utc>,
673    pub deleted_at: Option<DateTime<Utc>>,
674    pub answer_kind: AnswerKind,
675    /// The plugin's own JSON: the whole answer for a `json` answer, the plugin's metadata about the
676    /// files for a `file` one.
677    pub data_json: Option<serde_json::Value>,
678    /// The files the answer consists of, in grading order. Omitted when it has none.
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub data_files: Option<Vec<AnswerFile>>,
681    pub grading_progress: GradingProgress,
682    pub score_given: Option<f32>,
683    pub submission_id: Uuid,
684    pub exercise_id: Uuid,
685    pub tasks: Vec<CourseMaterialExerciseTask>,
686    pub given_peer_reviews: Vec<PeerReviewWithQuestionsAndAnswers>,
687    pub received_peer_or_self_reviews: Vec<PeerReviewWithQuestionsAndAnswers>,
688    pub received_peer_review_flagging_reports: Vec<FlaggedAnswer>,
689}
690
691/// Gets submissions that require input from the teacher to continue processing.
692pub async fn get_paginated_answers_requiring_attention_for_exercise(
693    conn: &mut PgConnection,
694    exercise_id: Uuid,
695    pagination: Pagination,
696    viewer_user_id: Uuid,
697    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
698    file_store: &dyn FileStore,
699    app_conf: &ApplicationConfiguration,
700) -> ModelResult<AnswersRequiringAttention> {
701    let exercise = exercises::get_exercise_by_id(conn, exercise_id).await?;
702    let answer_requiring_attention_count =
703        exercise_slide_submissions::answer_requiring_attention_count(conn, exercise_id).await?;
704    let data = exercise_slide_submissions::get_all_answers_requiring_attention(
705        conn,
706        exercise.id,
707        pagination,
708        file_store,
709        app_conf,
710    )
711    .await?;
712    let mut answers = Vec::with_capacity(data.len());
713    for answer in &data {
714        let tasks = exercise_task_submissions::get_exercise_task_submission_info_by_exercise_slide_submission_id(
715            conn,
716            answer.submission_id,
717            viewer_user_id,
718            &fetch_service_info,
719            false,
720            file_store,
721            app_conf,
722        )
723        .await?;
724        let given_peer_reviews = peer_or_self_review_question_submissions::get_given_peer_reviews(
725            conn,
726            answer.user_id,
727            answer.exercise_id,
728        )
729        .await?;
730
731        let received_peer_or_self_reviews =
732            peer_or_self_review_question_submissions::get_questions_and_answers_by_submission_id(
733                conn,
734                answer.submission_id,
735            )
736            .await?;
737        let received_peer_review_flagging_reports: Vec<FlaggedAnswer> =
738            flagged_answers::get_flagged_answers_by_submission_id(conn, answer.submission_id)
739                .await?;
740        let answer_fields = AnswerFields::from(answer.answer.to_owned());
741        let new_answer = AnswerRequiringAttentionWithTasks {
742            id: answer.id,
743            user_id: answer.user_id,
744            created_at: answer.created_at,
745            updated_at: answer.updated_at,
746            deleted_at: answer.deleted_at,
747            answer_kind: answer_fields.answer_kind,
748            data_json: answer_fields.data_json,
749            data_files: answer_fields.data_files,
750            grading_progress: answer.grading_progress,
751            score_given: answer.score_given,
752            submission_id: answer.submission_id,
753            exercise_id: answer.exercise_id,
754            tasks,
755            given_peer_reviews,
756            received_peer_or_self_reviews,
757            received_peer_review_flagging_reports,
758        };
759        answers.push(new_answer);
760    }
761    Ok(AnswersRequiringAttention {
762        exercise_max_points: exercise.score_maximum,
763        data: answers,
764        total_pages: pagination.total_pages(answer_requiring_attention_count),
765    })
766}