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        && Utc::now() + Duration::seconds(1) >= deadline
99    {
100        return Err(ControllerError::new(
101            ControllerErrorType::BadRequest,
102            "Exercise deadline passed.".to_string(),
103            None,
104        ));
105    }
106
107    Ok(())
108}
109
110/// Submissions for exams are posted from course instances or from exams. Make respective validations
111/// while figuring out which.
112async fn resolve_course_or_exam_id_and_verify_that_user_can_submit(
113    conn: &mut PgConnection,
114    user_id: Uuid,
115    exercise: &Exercise,
116    slide_id: Uuid,
117) -> Result<(CourseOrExamId, bool), ControllerError> {
118    let mut last_try = false;
119    let course_id_or_exam_id: CourseOrExamId = if let Some(course_id) = exercise.course_id {
120        // If submitting for a course, there should be existing course settings that dictate which
121        // instance the user is on.
122        let settings = models::user_course_settings::get_user_course_settings_by_course_id(
123            conn, user_id, course_id,
124        )
125        .await?;
126        if let Some(settings) = settings {
127            let token = authorize(conn, Act::View, Some(user_id), Res::Course(course_id)).await?;
128            token.authorized_ok(CourseOrExamId::Course(settings.current_course_id))
129        } else {
130            Err(ControllerError::new(
131                ControllerErrorType::Unauthorized,
132                "User is not enrolled on this course.".to_string(),
133                None,
134            ))
135        }
136    } else if let Some(exam_id) = exercise.exam_id {
137        // If submitting for an exam, make sure that user's time is not up.
138        if models::exams::verify_exam_submission_can_be_made(conn, exam_id, user_id).await? {
139            let token = authorize(conn, Act::View, Some(user_id), Res::Exam(exam_id)).await?;
140            token.authorized_ok(CourseOrExamId::Exam(exam_id))
141        } else {
142            Err(ControllerError::new(
143                ControllerErrorType::Unauthorized,
144                "Submissions for this exam are no longer accepted.".to_string(),
145                None,
146            ))
147        }
148    } else {
149        // On database level this scenario is impossible.
150        Err(ControllerError::new(
151            ControllerErrorType::InternalServerError,
152            "Exam doesn't belong to either a course nor exam.".to_string(),
153            None,
154        ))
155    }?
156    .data;
157    if exercise.limit_number_of_tries
158        && let Some(max_tries_per_slide) = exercise.max_tries_per_slide
159    {
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    Ok((course_id_or_exam_id, last_try))
193}