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