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::{self, ExerciseTaskSubmission},
16    exercise_tasks::{self, CourseMaterialExerciseTask, ExerciseTask},
17    exercises::{self, Exercise, ExerciseStatus, GradingProgress},
18    flagged_answers::{self, FlaggedAnswer},
19    peer_or_self_review_configs::PeerReviewProcessingStrategy,
20    peer_or_self_review_question_submissions::{
21        self, PeerOrSelfReviewQuestionSubmission, PeerReviewWithQuestionsAndAnswers,
22    },
23    prelude::*,
24    regradings,
25    user_course_exercise_service_variables::UserCourseExerciseServiceVariable,
26    user_exercise_slide_states::{self, UserExerciseSlideState},
27    user_exercise_states::{self, ExerciseWithUserState, UserExerciseState},
28    user_exercise_task_states,
29};
30
31use super::user_exercise_state_updater;
32
33/// Contains data sent by the student when they make a submission for an exercise slide.
34#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
35
36pub struct StudentExerciseSlideSubmission {
37    pub exercise_slide_id: Uuid,
38    pub exercise_task_submissions: Vec<StudentExerciseTaskSubmission>,
39}
40
41#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
42
43pub struct StudentExerciseSlideSubmissionResult {
44    pub exercise_status: Option<ExerciseStatus>,
45    pub exercise_task_submission_results: Vec<StudentExerciseTaskSubmissionResult>,
46    pub user_course_instance_exercise_service_variables: Vec<UserCourseExerciseServiceVariable>,
47}
48
49impl StudentExerciseSlideSubmissionResult {
50    pub fn clear_grading_information(&mut self) {
51        self.exercise_status = None;
52        self.exercise_task_submission_results
53            .iter_mut()
54            .for_each(|result| {
55                result.grading = None;
56                result.model_solution_spec = None;
57            });
58    }
59
60    pub fn clear_model_solution_specs(&mut self) {
61        self.exercise_task_submission_results
62            .iter_mut()
63            .for_each(|result| {
64                result.model_solution_spec = None;
65            })
66    }
67}
68
69#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
70
71pub struct StudentExerciseTaskSubmission {
72    pub exercise_task_id: Uuid,
73    pub data_json: serde_json::Value,
74}
75
76#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
77
78pub struct StudentExerciseTaskSubmissionResult {
79    pub submission: ExerciseTaskSubmission,
80    pub grading: Option<ExerciseTaskGrading>,
81    pub model_solution_spec: Option<serde_json::Value>,
82    pub exercise_task_exercise_service_slug: String,
83}
84
85#[derive(Debug)]
86pub struct ExerciseSlideSubmissionWithTasks {
87    pub exercise_slide_submission: ExerciseSlideSubmission,
88    pub exercise_slide_submission_tasks: Vec<ExerciseTaskSubmission>,
89}
90
91/// If passed to to an exercise state update, it will update the peer review status with the given information
92#[derive(Debug)]
93pub struct ExerciseStateUpdateNeedToUpdatePeerReviewStatusWithThis {
94    pub given_enough_peer_reviews: bool,
95    pub received_enough_peer_reviews: bool,
96    pub peer_review_processing_strategy: PeerReviewProcessingStrategy,
97    pub peer_review_accepting_threshold: f32,
98    /// Used to for calculating averages when acting on PeerReviewProcessingStrategy
99    pub received_peer_or_self_review_question_submissions: Vec<PeerOrSelfReviewQuestionSubmission>,
100}
101
102/// Inserts user submission to database. Tasks within submission are validated to make sure that
103/// they belong to the correct exercise slide.
104pub async fn create_user_exercise_slide_submission(
105    conn: &mut PgConnection,
106    exercise_with_user_state: &ExerciseWithUserState,
107    user_exercise_slide_submission: &StudentExerciseSlideSubmission,
108) -> ModelResult<ExerciseSlideSubmissionWithTasks> {
109    let selected_exercise_slide_id = exercise_with_user_state
110        .user_exercise_state()
111        .selected_exercise_slide_id
112        .ok_or_else(|| {
113            ModelError::new(
114                ModelErrorType::PreconditionFailed,
115                "Exercise slide not selected for the student.".to_string(),
116                None,
117            )
118        })?;
119    let exercise_tasks: HashMap<Uuid, ExerciseTask> =
120        exercise_tasks::get_exercise_tasks_by_exercise_slide_id(conn, &selected_exercise_slide_id)
121            .await?;
122    let user_points_update_strategy = if exercise_with_user_state.is_exam_exercise() {
123        UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints
124    } else {
125        UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints
126    };
127
128    let mut tx = conn.begin().await?;
129
130    let exercise_slide_submission = exercise_slide_submissions::insert_exercise_slide_submission(
131        &mut tx,
132        NewExerciseSlideSubmission {
133            exercise_slide_id: selected_exercise_slide_id,
134            course_id: exercise_with_user_state.exercise().course_id,
135            exam_id: exercise_with_user_state.exercise().exam_id,
136            exercise_id: exercise_with_user_state.exercise().id,
137            user_id: exercise_with_user_state.user_exercise_state().user_id,
138            user_points_update_strategy,
139        },
140    )
141    .await?;
142    let user_exercise_task_submissions = &user_exercise_slide_submission.exercise_task_submissions;
143    let mut exercise_slide_submission_tasks =
144        Vec::with_capacity(user_exercise_task_submissions.len());
145    for task_submission in user_exercise_task_submissions {
146        let exercise_task = exercise_tasks
147            .get(&task_submission.exercise_task_id)
148            .ok_or_else(|| {
149                ModelError::new(
150                    ModelErrorType::PreconditionFailed,
151                    "Attempting to submit exercise for illegal exercise_task_id.".to_string(),
152                    None,
153                )
154            })?;
155        let submission_id = exercise_task_submissions::insert(
156            &mut tx,
157            PKeyPolicy::Generate,
158            exercise_slide_submission.id,
159            exercise_task.exercise_slide_id,
160            exercise_task.id,
161            &task_submission.data_json,
162        )
163        .await?;
164        let submission = exercise_task_submissions::get_by_id(&mut tx, submission_id).await?;
165        exercise_slide_submission_tasks.push(submission)
166    }
167
168    tx.commit().await?;
169    Ok(ExerciseSlideSubmissionWithTasks {
170        exercise_slide_submission,
171        exercise_slide_submission_tasks,
172    })
173}
174
175// Relocated regrading logic to condensate score update logic in a single place.
176// Needs better separation of concerns in the far future.
177pub async fn update_grading_with_single_regrading_result(
178    conn: &mut PgConnection,
179    exercise: &Exercise,
180    regrading_submission: &ExerciseTaskRegradingSubmission,
181    exercise_task_grading: &ExerciseTaskGrading,
182    exercise_task_grading_result: &ExerciseTaskGradingResult,
183) -> ModelResult<()> {
184    let task_submission = exercise_task_submissions::get_by_id(
185        &mut *conn,
186        regrading_submission.exercise_task_submission_id,
187    )
188    .await?;
189    let slide_submission = exercise_slide_submissions::get_by_id(
190        &mut *conn,
191        task_submission.exercise_slide_submission_id,
192    )
193    .await?;
194    let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
195        conn,
196        slide_submission.user_id,
197        exercise.id,
198        slide_submission.course_id,
199        slide_submission.exam_id,
200    )
201    .await?;
202    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
203        &mut *conn,
204        user_exercise_state.id,
205        slide_submission.exercise_slide_id,
206    )
207    .await?;
208    let regrading = regradings::get_by_id(&mut *conn, regrading_submission.regrading_id).await?;
209    propagate_user_exercise_state_update_from_exercise_task_grading_result(
210        conn,
211        exercise,
212        exercise_task_grading,
213        exercise_task_grading_result,
214        user_exercise_slide_state,
215        regrading.user_points_update_strategy,
216    )
217    .await?;
218    Ok(())
219}
220
221pub enum GradingPolicy {
222    /// Grades exercise tasks by sending a request to their respective services.
223    Default,
224    /// Intended for test purposes only.
225    Fixed(HashMap<Uuid, ExerciseTaskGradingResult>),
226}
227
228pub async fn grade_user_submission(
229    conn: &mut PgConnection,
230    exercise_with_user_state: &mut ExerciseWithUserState,
231    user_exercise_slide_submission: &StudentExerciseSlideSubmission,
232    grading_policy: GradingPolicy,
233    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
234    send_grading_request: impl Fn(
235        Url,
236        &ExerciseTask,
237        &ExerciseTaskSubmission,
238    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
239) -> ModelResult<StudentExerciseSlideSubmissionResult> {
240    let mut tx = conn.begin().await?;
241
242    let ExerciseSlideSubmissionWithTasks {
243        exercise_slide_submission,
244        exercise_slide_submission_tasks,
245    } = create_user_exercise_slide_submission(
246        &mut tx,
247        exercise_with_user_state,
248        user_exercise_slide_submission,
249    )
250    .await?;
251    let user_exercise_state = exercise_with_user_state.user_exercise_state();
252    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
253        &mut tx,
254        user_exercise_state.id,
255        exercise_slide_submission.exercise_slide_id,
256    )
257    .await?;
258    let results = match grading_policy {
259        GradingPolicy::Default => {
260            let mut results = Vec::with_capacity(exercise_slide_submission_tasks.len());
261            for task_submission in exercise_slide_submission_tasks {
262                let submission = grade_user_submission_task(
263                    &mut tx,
264                    &task_submission,
265                    exercise_with_user_state.exercise(),
266                    user_exercise_slide_state.id,
267                    user_exercise_state,
268                    &fetch_service_info,
269                    &send_grading_request,
270                )
271                .await;
272                match submission {
273                    Ok(submission) => results.push(submission),
274                    Err(err) => {
275                        // Store rejected submission when HTTP call fails
276                        let (http_status_code, error_message, response_body) =
277                            match err.error_type() {
278                                crate::error::ModelErrorType::HttpRequest {
279                                    status_code,
280                                    response_body,
281                                } => (
282                                    Some(*status_code as i32),
283                                    Some(response_body.clone()),
284                                    Some(response_body.clone()),
285                                ),
286                                crate::error::ModelErrorType::HttpError {
287                                    error_type: crate::error::HttpErrorType::ResponseDecodeFailed,
288                                    response_body,
289                                    status_code,
290                                    ..
291                                } => (
292                                    status_code.map(|s| s as i32),
293                                    Some(err.to_string()),
294                                    response_body.clone(),
295                                ),
296                                _ => (None, Some(err.to_string()), None),
297                            };
298
299                        // We want to save the rejected submission to the database
300                        // But don't want to keep the rest of the stuff we have inserted into the database
301                        tx.rollback().await?;
302                        let mut tx = conn.begin().await?;
303
304                        let _ = crate::rejected_exercise_slide_submissions::insert_rejected_exercise_slide_submission(
305                            &mut tx,
306                            user_exercise_slide_submission,
307                            user_exercise_state.user_id,
308                            http_status_code,
309                            error_message,
310                            response_body,
311                        ).await;
312
313                        tx.commit().await?;
314
315                        return Err(err);
316                    }
317                }
318            }
319            results
320        }
321        GradingPolicy::Fixed(fixed_results) => {
322            let mut results = Vec::with_capacity(exercise_slide_submission_tasks.len());
323            for task_submission in exercise_slide_submission_tasks {
324                let fixed_result = fixed_results
325                    .get(&task_submission.exercise_task_id)
326                    .ok_or_else(|| {
327                        ModelError::new(
328                            ModelErrorType::Generic,
329                            "Could not find fixed test result for testing".to_string(),
330                            None,
331                        )
332                    })?
333                    .clone();
334                let submission = create_fixed_grading_for_submission_task(
335                    &mut tx,
336                    &task_submission,
337                    exercise_with_user_state.exercise(),
338                    user_exercise_slide_state.id,
339                    &fixed_result,
340                )
341                .await?;
342                results.push(submission);
343            }
344            results
345        }
346    };
347    let user_exercise_state = update_user_exercise_slide_state_and_user_exercise_state(
348        &mut tx,
349        user_exercise_slide_state,
350        exercise_slide_submission.user_points_update_strategy,
351    )
352    .await?;
353
354    let course_or_exam_id = CourseOrExamId::from_course_and_exam_ids(
355        user_exercise_state.course_id,
356        user_exercise_state.exam_id,
357    )?;
358
359    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?;
360
361    let result = StudentExerciseSlideSubmissionResult {
362        exercise_status: Some(ExerciseStatus {
363            score_given: user_exercise_state.score_given,
364            activity_progress: user_exercise_state.activity_progress,
365            grading_progress: user_exercise_state.grading_progress,
366            reviewing_stage: user_exercise_state.reviewing_stage,
367        }),
368        exercise_task_submission_results: results,
369        user_course_instance_exercise_service_variables,
370    };
371    exercise_with_user_state.set_user_exercise_state(user_exercise_state)?;
372    tx.commit().await?;
373    Ok(result)
374}
375
376async fn grade_user_submission_task(
377    conn: &mut PgConnection,
378    submission: &ExerciseTaskSubmission,
379    exercise: &Exercise,
380    user_exercise_slide_state_id: Uuid,
381    user_exercise_state: &UserExerciseState,
382    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
383    send_grading_request: impl Fn(
384        Url,
385        &ExerciseTask,
386        &ExerciseTaskSubmission,
387    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
388) -> ModelResult<StudentExerciseTaskSubmissionResult> {
389    let grading = exercise_task_gradings::new_grading(conn, exercise, submission).await?;
390    let updated_submission =
391        exercise_task_submissions::set_grading_id(conn, grading.id, submission.id).await?;
392    let exercise_task =
393        exercise_tasks::get_exercise_task_by_id(conn, submission.exercise_task_id).await?;
394    let grading = exercise_task_gradings::grade_submission(
395        conn,
396        submission,
397        &exercise_task,
398        exercise,
399        &grading,
400        user_exercise_state,
401        fetch_service_info,
402        send_grading_request,
403    )
404    .await?;
405    user_exercise_task_states::upsert_with_grading(conn, user_exercise_slide_state_id, &grading)
406        .await?;
407    let model_solution_spec = exercise_tasks::get_exercise_task_model_solution_spec_by_id(
408        conn,
409        submission.exercise_task_id,
410    )
411    .await?;
412
413    Ok(StudentExerciseTaskSubmissionResult {
414        submission: updated_submission,
415        grading: Some(grading),
416        model_solution_spec,
417        exercise_task_exercise_service_slug: exercise_task.exercise_type,
418    })
419}
420
421async fn create_fixed_grading_for_submission_task(
422    conn: &mut PgConnection,
423    submission: &ExerciseTaskSubmission,
424    exercise: &Exercise,
425    user_exercise_slide_state_id: Uuid,
426    fixed_result: &ExerciseTaskGradingResult,
427) -> ModelResult<StudentExerciseTaskSubmissionResult> {
428    let grading = exercise_task_gradings::new_grading(conn, exercise, submission).await?;
429    let updated_submission =
430        exercise_task_submissions::set_grading_id(conn, grading.id, submission.id).await?;
431    let updated_grading =
432        exercise_task_gradings::update_grading(conn, &grading, fixed_result, exercise).await?;
433    user_exercise_task_states::upsert_with_grading(
434        conn,
435        user_exercise_slide_state_id,
436        &updated_grading,
437    )
438    .await?;
439    let exercise_task =
440        exercise_tasks::get_exercise_task_by_id(conn, submission.exercise_task_id).await?;
441    let model_solution_spec = exercise_task.model_solution_spec;
442
443    Ok(StudentExerciseTaskSubmissionResult {
444        submission: updated_submission,
445        grading: Some(grading),
446        model_solution_spec,
447        exercise_task_exercise_service_slug: exercise_task.exercise_type,
448    })
449}
450
451/// Updates the user exercise state starting from a slide state, and propagates the update up to the
452/// whole user exercise state.
453async fn update_user_exercise_slide_state_and_user_exercise_state(
454    conn: &mut PgConnection,
455    user_exercise_slide_state: UserExerciseSlideState,
456    user_points_update_strategy: UserPointsUpdateStrategy,
457) -> ModelResult<UserExerciseState> {
458    update_user_exercise_slide_state(
459        conn,
460        &user_exercise_slide_state,
461        user_points_update_strategy,
462    )
463    .await?;
464    let user_exercise_state = user_exercise_state_updater::update_user_exercise_state(
465        conn,
466        user_exercise_slide_state.user_exercise_state_id,
467    )
468    .await?;
469
470    Ok(user_exercise_state)
471}
472
473async fn update_user_exercise_slide_state(
474    conn: &mut PgConnection,
475    user_exercise_slide_state: &UserExerciseSlideState,
476    user_points_update_strategy: UserPointsUpdateStrategy,
477) -> ModelResult<()> {
478    let (points_from_tasks, grading_progress) =
479        user_exercise_task_states::get_grading_summary_by_user_exercise_slide_state_id(
480            conn,
481            user_exercise_slide_state.id,
482        )
483        .await?;
484    let new_score_given = user_exercise_task_states::figure_out_new_score_given(
485        user_exercise_slide_state.score_given,
486        points_from_tasks,
487        user_points_update_strategy,
488    );
489    let changes = user_exercise_slide_states::update(
490        conn,
491        user_exercise_slide_state.id,
492        new_score_given,
493        grading_progress,
494    )
495    .await?;
496    info!(
497        "Updating user exercise slide state {} affected {} rows.",
498        user_exercise_slide_state.id, changes
499    );
500    Ok(())
501}
502
503/// Updates the user exercise state starting from a single task, and propagates the update up to the
504/// whole user exercise state.
505pub async fn propagate_user_exercise_state_update_from_exercise_task_grading_result(
506    conn: &mut PgConnection,
507    exercise: &Exercise,
508    exercise_task_grading: &ExerciseTaskGrading,
509    exercise_task_grading_result: &ExerciseTaskGradingResult,
510    user_exercise_slide_state: UserExerciseSlideState,
511    user_points_update_strategy: UserPointsUpdateStrategy,
512) -> ModelResult<UserExerciseState> {
513    let updated_exercise_task_grading = exercise_task_gradings::update_grading(
514        conn,
515        exercise_task_grading,
516        exercise_task_grading_result,
517        exercise,
518    )
519    .await?;
520    exercise_task_submissions::set_grading_id(
521        conn,
522        updated_exercise_task_grading.id,
523        updated_exercise_task_grading.exercise_task_submission_id,
524    )
525    .await?;
526    let user_exercise_task_state = user_exercise_task_states::upsert_with_grading(
527        conn,
528        user_exercise_slide_state.id,
529        &updated_exercise_task_grading,
530    )
531    .await?;
532    let user_exercise_slide_state = user_exercise_slide_states::get_by_id(
533        conn,
534        user_exercise_task_state.user_exercise_slide_state_id,
535    )
536    .await?;
537    let user_exercise_state = update_user_exercise_slide_state_and_user_exercise_state(
538        conn,
539        user_exercise_slide_state,
540        user_points_update_strategy,
541    )
542    .await?;
543    Ok(user_exercise_state)
544}
545
546#[derive(Debug, Serialize, ToSchema)]
547
548pub struct AnswersRequiringAttention {
549    pub exercise_max_points: i32,
550    pub data: Vec<AnswerRequiringAttentionWithTasks>,
551    pub total_pages: u32,
552}
553
554#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
555
556pub struct AnswerRequiringAttentionWithTasks {
557    pub id: Uuid,
558    pub user_id: Uuid,
559    pub created_at: DateTime<Utc>,
560    pub updated_at: DateTime<Utc>,
561    pub deleted_at: Option<DateTime<Utc>>,
562    pub data_json: Option<serde_json::Value>,
563    pub grading_progress: GradingProgress,
564    pub score_given: Option<f32>,
565    pub submission_id: Uuid,
566    pub exercise_id: Uuid,
567    pub tasks: Vec<CourseMaterialExerciseTask>,
568    pub given_peer_reviews: Vec<PeerReviewWithQuestionsAndAnswers>,
569    pub received_peer_or_self_reviews: Vec<PeerReviewWithQuestionsAndAnswers>,
570    pub received_peer_review_flagging_reports: Vec<FlaggedAnswer>,
571}
572
573/// Gets submissions that require input from the teacher to continue processing.
574pub async fn get_paginated_answers_requiring_attention_for_exercise(
575    conn: &mut PgConnection,
576    exercise_id: Uuid,
577    pagination: Pagination,
578    viewer_user_id: Uuid,
579    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
580) -> ModelResult<AnswersRequiringAttention> {
581    let exercise = exercises::get_exercise_by_id(conn, exercise_id).await?;
582    let answer_requiring_attention_count =
583        exercise_slide_submissions::answer_requiring_attention_count(conn, exercise_id).await?;
584    let data = exercise_slide_submissions::get_all_answers_requiring_attention(
585        conn,
586        exercise.id,
587        pagination,
588    )
589    .await?;
590    let mut answers = Vec::with_capacity(data.len());
591    for answer in &data {
592        let tasks = exercise_task_submissions::get_exercise_task_submission_info_by_exercise_slide_submission_id(
593            conn,
594            answer.submission_id,
595            viewer_user_id,
596            &fetch_service_info,
597            false,
598        )
599        .await?;
600        let given_peer_reviews = peer_or_self_review_question_submissions::get_given_peer_reviews(
601            conn,
602            answer.user_id,
603            answer.exercise_id,
604        )
605        .await?;
606
607        let received_peer_or_self_reviews =
608            peer_or_self_review_question_submissions::get_questions_and_answers_by_submission_id(
609                conn,
610                answer.submission_id,
611            )
612            .await?;
613        let received_peer_review_flagging_reports: Vec<FlaggedAnswer> =
614            flagged_answers::get_flagged_answers_by_submission_id(conn, answer.submission_id)
615                .await?;
616        let new_answer = AnswerRequiringAttentionWithTasks {
617            id: answer.id,
618            user_id: answer.user_id,
619            created_at: answer.created_at,
620            updated_at: answer.updated_at,
621            deleted_at: answer.deleted_at,
622            data_json: answer.data_json.to_owned(),
623            grading_progress: answer.grading_progress,
624            score_given: answer.score_given,
625            submission_id: answer.submission_id,
626            exercise_id: answer.exercise_id,
627            tasks,
628            given_peer_reviews,
629            received_peer_or_self_reviews,
630            received_peer_review_flagging_reports,
631        };
632        answers.push(new_answer);
633    }
634    Ok(AnswersRequiringAttention {
635        exercise_max_points: exercise.score_maximum,
636        data: answers,
637        total_pages: pagination.total_pages(answer_requiring_attention_count),
638    })
639}