headless_lms_server/domain/
exercises.rs

1use std::sync::Arc;
2
3use crate::{
4    domain::models_requests::{self, JwtKey},
5    prelude::*,
6};
7use chrono::{Duration, Utc};
8use futures_util::future::OptionFuture;
9use models::{
10    exercises::Exercise,
11    library::grading::{
12        GradingPolicy, StudentExerciseSlideSubmission, StudentExerciseSlideSubmissionResult,
13    },
14    user_exercise_states::ExerciseWithUserState,
15};
16
17pub async fn process_submission(
18    conn: &mut PgConnection,
19    user_id: Uuid,
20    exercise: Exercise,
21    submission: &StudentExerciseSlideSubmission,
22    jwt_key: Arc<JwtKey>,
23) -> Result<StudentExerciseSlideSubmissionResult, ControllerError> {
24    enforce_deadline(conn, &exercise).await?;
25
26    let (course_or_exam_id, last_try) = resolve_course_or_exam_id_and_verify_that_user_can_submit(
27        conn,
28        user_id,
29        &exercise,
30        submission.exercise_slide_id,
31    )
32    .await?;
33
34    // TODO: Should this be an upsert?
35    let user_exercise_state = models::user_exercise_states::get_user_exercise_state_if_exists(
36        conn,
37        user_id,
38        exercise.id,
39        course_or_exam_id,
40    )
41    .await?
42    .ok_or_else(|| {
43        ControllerError::new(
44            ControllerErrorType::Unauthorized,
45            "Missing exercise state.".to_string(),
46            None,
47        )
48    })?;
49
50    let mut exercise_with_user_state = ExerciseWithUserState::new(exercise, user_exercise_state)?;
51    let mut result = models::library::grading::grade_user_submission(
52        conn,
53        &mut exercise_with_user_state,
54        submission,
55        GradingPolicy::Default,
56        models_requests::fetch_service_info,
57        models_requests::make_grading_request_sender(jwt_key),
58    )
59    .await?;
60
61    if exercise_with_user_state.is_exam_exercise() {
62        // If exam, we don't want to expose model any grading details.
63        result.clear_grading_information();
64    }
65
66    let score_given = if let Some(exercise_status) = &result.exercise_status {
67        exercise_status.score_given.unwrap_or(0.0)
68    } else {
69        0.0
70    };
71
72    // Model solution spec should only be shown when this is the last try for the current slide or they have gotten full points from the current slide.
73    // TODO: this uses points for the whole exercise, change this to slide points when slide grading finalized
74    let has_received_full_points = score_given
75        >= exercise_with_user_state.exercise().score_maximum as f32
76        || (score_given - exercise_with_user_state.exercise().score_maximum as f32).abs() < 0.0001;
77    if !has_received_full_points && !last_try {
78        result.clear_model_solution_specs();
79    }
80    Ok(result)
81}
82
83/// Returns an error if the chapter's or exercise's deadline has passed.
84async fn enforce_deadline(
85    conn: &mut PgConnection,
86    exercise: &Exercise,
87) -> Result<(), ControllerError> {
88    let chapter_option_future: OptionFuture<_> = exercise
89        .chapter_id
90        .map(|id| models::chapters::get_chapter(conn, id))
91        .into();
92    let chapter = chapter_option_future.await.transpose()?;
93
94    // Exercise deadlines takes precedence to chapter deadlines
95    if let Some(deadline) = exercise
96        .deadline
97        .or_else(|| chapter.and_then(|c| c.deadline))
98    {
99        if Utc::now() + Duration::seconds(1) >= deadline {
100            return Err(ControllerError::new(
101                ControllerErrorType::BadRequest,
102                "Exercise deadline passed.".to_string(),
103                None,
104            ));
105        }
106    }
107
108    Ok(())
109}
110
111/// Submissions for exams are posted from course instances or from exams. Make respective validations
112/// while figuring out which.
113async fn resolve_course_or_exam_id_and_verify_that_user_can_submit(
114    conn: &mut PgConnection,
115    user_id: Uuid,
116    exercise: &Exercise,
117    slide_id: Uuid,
118) -> Result<(CourseOrExamId, bool), ControllerError> {
119    let mut last_try = false;
120    let course_id_or_exam_id: CourseOrExamId = if let Some(course_id) = exercise.course_id {
121        // If submitting for a course, there should be existing course settings that dictate which
122        // instance the user is on.
123        let settings = models::user_course_settings::get_user_course_settings_by_course_id(
124            conn, user_id, course_id,
125        )
126        .await?;
127        if let Some(settings) = settings {
128            let token = authorize(conn, Act::View, Some(user_id), Res::Course(course_id)).await?;
129            token.authorized_ok(CourseOrExamId::Course(settings.current_course_id))
130        } else {
131            Err(ControllerError::new(
132                ControllerErrorType::Unauthorized,
133                "User is not enrolled on this course.".to_string(),
134                None,
135            ))
136        }
137    } else if let Some(exam_id) = exercise.exam_id {
138        // If submitting for an exam, make sure that user's time is not up.
139        if models::exams::verify_exam_submission_can_be_made(conn, exam_id, user_id).await? {
140            let token = authorize(conn, Act::View, Some(user_id), Res::Exam(exam_id)).await?;
141            token.authorized_ok(CourseOrExamId::Exam(exam_id))
142        } else {
143            Err(ControllerError::new(
144                ControllerErrorType::Unauthorized,
145                "Submissions for this exam are no longer accepted.".to_string(),
146                None,
147            ))
148        }
149    } else {
150        // On database level this scenario is impossible.
151        Err(ControllerError::new(
152            ControllerErrorType::InternalServerError,
153            "Exam doesn't belong to either a course nor exam.".to_string(),
154            None,
155        ))
156    }?
157    .data;
158    if exercise.limit_number_of_tries {
159        if let Some(max_tries_per_slide) = exercise.max_tries_per_slide {
160            // check if the user has attempts remaining
161            let slide_id_to_submissions_count =
162                models::exercise_slide_submissions::get_exercise_slide_submission_counts_for_exercise_user(
163                    conn,
164                    exercise.id,
165                    course_id_or_exam_id,
166                    user_id,
167                )
168                .await?;
169
170            let count = slide_id_to_submissions_count.get(&slide_id).unwrap_or(&0);
171            if count >= &(max_tries_per_slide as i64) {
172                tracing::error!(
173                    user_id = %user_id,
174                    exercise_id = %exercise.id,
175                    slide_id = %slide_id,
176                    course_or_exam_id = ?course_id_or_exam_id,
177                    current_try_count = %count,
178                    max_tries_per_slide = %max_tries_per_slide,
179                    limit_number_of_tries = %exercise.limit_number_of_tries,
180                    "User has run out of tries for exercise slide submission"
181                );
182                return Err(ControllerError::new(
183                    ControllerErrorType::BadRequest,
184                    "You've ran out of tries.".to_string(),
185                    None,
186                ));
187            }
188            if count + 1 >= (max_tries_per_slide as i64) {
189                last_try = true;
190            }
191        }
192    }
193    Ok((course_id_or_exam_id, last_try))
194}