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