Skip to main content

headless_lms_models/
exercise_task_gradings.rs

1use std::collections::HashMap;
2
3use futures::future::BoxFuture;
4use headless_lms_utils::numbers::f32_to_three_decimals;
5use url::Url;
6use utoipa::ToSchema;
7
8use crate::{
9    CourseOrExamId, exams,
10    exercise_service_info::{ExerciseServiceInfoApi, get_service_info_by_exercise_type},
11    exercise_services::{get_exercise_service_by_exercise_type, get_internal_grade_url},
12    exercise_task_submissions::ExerciseTaskSubmission,
13    exercise_tasks::{self, ExerciseTask},
14    exercises::{Exercise, GradingProgress},
15    library::custom_view_exercises::CustomViewExerciseTaskGrading,
16    prelude::*,
17    user_exercise_states::UserExerciseState,
18};
19
20#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
21
22pub struct ExerciseTaskGrading {
23    pub id: Uuid,
24    pub created_at: DateTime<Utc>,
25    pub updated_at: DateTime<Utc>,
26    pub exercise_task_submission_id: Uuid,
27    pub course_id: Option<Uuid>,
28    pub exam_id: Option<Uuid>,
29    pub exercise_id: Uuid,
30    pub exercise_task_id: Uuid,
31    pub grading_priority: i32,
32    pub score_given: Option<f32>,
33    pub grading_progress: GradingProgress,
34    pub unscaled_score_given: Option<f32>,
35    pub unscaled_score_maximum: Option<i32>,
36    pub grading_started_at: Option<DateTime<Utc>>,
37    pub grading_completed_at: Option<DateTime<Utc>>,
38    pub feedback_json: Option<serde_json::Value>,
39    pub feedback_text: Option<String>,
40    pub deleted_at: Option<DateTime<Utc>>,
41}
42
43#[derive(Debug, Serialize, PartialEq, Eq, Clone)]
44pub struct ExerciseTaskGradingRequest<'a> {
45    pub grading_update_url: &'a str,
46    pub exercise_spec: &'a Option<serde_json::Value>,
47    pub submission_data: &'a Option<serde_json::Value>,
48}
49
50#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
51
52pub struct ExerciseTaskGradingResult {
53    pub grading_progress: GradingProgress,
54    pub score_given: f32,
55    pub score_maximum: i32,
56    pub feedback_text: Option<String>,
57    pub feedback_json: Option<serde_json::Value>,
58    #[serde(skip_serializing_if = "Option::is_none")] // Allows us to omit the field in typescript
59    pub set_user_variables: Option<HashMap<String, serde_json::Value>>,
60}
61
62#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type, ToSchema)]
63#[sqlx(type_name = "user_points_update_strategy", rename_all = "kebab-case")]
64pub enum UserPointsUpdateStrategy {
65    CanAddPointsButCannotRemovePoints,
66    CanAddPointsAndCanRemovePoints,
67}
68
69pub async fn insert(
70    conn: &mut PgConnection,
71    pkey_policy: PKeyPolicy<Uuid>,
72    submission_id: Uuid,
73    course_id: Uuid,
74    exercise_id: Uuid,
75    exercise_task_id: Uuid,
76) -> ModelResult<Uuid> {
77    let res = sqlx::query!(
78        "
79INSERT INTO exercise_task_gradings (
80    id,
81    exercise_task_submission_id,
82    course_id,
83    exercise_id,
84    exercise_task_id
85  )
86VALUES ($1, $2, $3, $4, $5)
87RETURNING *
88        ",
89        pkey_policy.into_uuid(),
90        submission_id,
91        course_id,
92        exercise_id,
93        exercise_task_id
94    )
95    .fetch_one(conn)
96    .await?;
97    Ok(res.id)
98}
99
100pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ExerciseTaskGrading> {
101    let res = sqlx::query_as!(
102        ExerciseTaskGrading,
103        r#"
104SELECT *
105FROM exercise_task_gradings
106WHERE id = $1
107"#,
108        id
109    )
110    .fetch_one(conn)
111    .await?;
112    Ok(res)
113}
114
115pub async fn get_by_exercise_task_submission_id(
116    conn: &mut PgConnection,
117    exercise_task_submission_id: Uuid,
118) -> ModelResult<Option<ExerciseTaskGrading>> {
119    let res = sqlx::query_as!(
120        ExerciseTaskGrading,
121        r#"
122SELECT *
123FROM exercise_task_gradings
124WHERE exercise_task_submission_id = $1
125  AND deleted_at IS NULL
126        "#,
127        exercise_task_submission_id,
128    )
129    .fetch_optional(conn)
130    .await?;
131    Ok(res)
132}
133
134pub async fn get_by_exercise_task_submission_ids(
135    conn: &mut PgConnection,
136    exercise_task_submission_ids: &[Uuid],
137) -> ModelResult<HashMap<Uuid, ExerciseTaskGrading>> {
138    if exercise_task_submission_ids.is_empty() {
139        return Ok(HashMap::new());
140    }
141
142    let gradings = sqlx::query_as!(
143        ExerciseTaskGrading,
144        r#"
145SELECT etg.id,
146  etg.created_at,
147  etg.updated_at,
148  etg.exercise_task_submission_id,
149  etg.course_id,
150  etg.exam_id,
151  etg.exercise_id,
152  etg.exercise_task_id,
153  etg.grading_priority,
154  etg.score_given,
155  etg.grading_progress,
156  etg.unscaled_score_given,
157  etg.unscaled_score_maximum,
158  etg.grading_started_at,
159  etg.grading_completed_at,
160  etg.feedback_json,
161  etg.feedback_text,
162  etg.deleted_at
163FROM exercise_task_submissions ets
164  JOIN exercise_task_gradings etg ON etg.exercise_task_submission_id = ets.id
165WHERE ets.id = ANY($1)
166  AND ets.deleted_at IS NULL
167  AND etg.deleted_at IS NULL
168        "#,
169        exercise_task_submission_ids
170    )
171    .fetch_all(conn)
172    .await?;
173
174    Ok(gradings
175        .into_iter()
176        .map(|grading| (grading.exercise_task_submission_id, grading))
177        .collect())
178}
179
180pub async fn get_total_score_given_for_exercise_slide_submission(
181    conn: &mut PgConnection,
182    exercise_slide_submission_id: &Uuid,
183) -> ModelResult<Option<f32>> {
184    let res = sqlx::query!(
185        "
186SELECT SUM(COALESCE(etg.score_given, 0))::real
187FROM exercise_task_gradings etg
188  JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
189WHERE ets.exercise_slide_submission_id = $1
190  AND etg.deleted_at IS NULL
191  AND ets.deleted_at IS NULL
192        ",
193        exercise_slide_submission_id
194    )
195    .fetch_one(conn)
196    .await?;
197    Ok(res.sum)
198}
199
200/// For now gets this information from some task submission in a slide submission.
201pub async fn get_point_update_strategy_from_gradings(
202    conn: &mut PgConnection,
203    exercise_slide_submission_id: &Uuid,
204) -> ModelResult<GradingProgress> {
205    let res = sqlx::query!(
206        r#"
207SELECT etg.grading_progress
208FROM exercise_task_gradings etg
209  JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
210WHERE ets.exercise_slide_submission_id = $1
211  AND etg.deleted_at IS NULL
212  AND ets.deleted_at IS NULL
213LIMIT 1
214    "#,
215        exercise_slide_submission_id
216    )
217    .fetch_one(conn)
218    .await?;
219    Ok(res.grading_progress)
220}
221
222pub async fn get_course_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Option<Uuid>> {
223    let course_id = sqlx::query!(
224        "
225SELECT *
226from exercise_task_gradings
227where id = $1
228        ",
229        id
230    )
231    .fetch_one(conn)
232    .await?
233    .course_id;
234    Ok(course_id)
235}
236
237pub async fn get_course_or_exam_id(
238    conn: &mut PgConnection,
239    id: Uuid,
240) -> ModelResult<CourseOrExamId> {
241    let res = sqlx::query!(
242        "
243SELECT *
244from exercise_task_gradings
245where id = $1
246",
247        id
248    )
249    .fetch_one(conn)
250    .await?;
251    CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
252}
253
254pub async fn new_grading(
255    conn: &mut PgConnection,
256    exercise: &Exercise,
257    submission: &ExerciseTaskSubmission,
258) -> ModelResult<ExerciseTaskGrading> {
259    let grading = sqlx::query_as!(
260        ExerciseTaskGrading,
261        r#"
262INSERT INTO exercise_task_gradings(
263    exercise_task_submission_id,
264    course_id,
265    exam_id,
266    exercise_id,
267    exercise_task_id,
268    grading_started_at
269  )
270VALUES($1, $2, $3, $4, $5, now())
271RETURNING *
272"#,
273        submission.id,
274        exercise.course_id,
275        exercise.exam_id,
276        exercise.id,
277        submission.exercise_task_id,
278    )
279    .fetch_one(conn)
280    .await?;
281    Ok(grading)
282}
283
284pub async fn set_grading_progress(
285    conn: &mut PgConnection,
286    id: Uuid,
287    grading_progress: GradingProgress,
288) -> ModelResult<()> {
289    sqlx::query!(
290        "
291UPDATE exercise_task_gradings
292SET grading_progress = $1
293WHERE id = $2
294",
295        grading_progress as GradingProgress,
296        id
297    )
298    .execute(conn)
299    .await?;
300    Ok(())
301}
302
303#[allow(clippy::too_many_arguments)]
304pub async fn grade_submission(
305    conn: &mut PgConnection,
306    submission: &ExerciseTaskSubmission,
307    exercise_task: &ExerciseTask,
308    exercise: &Exercise,
309    grading: &ExerciseTaskGrading,
310    user_exercise_state: &UserExerciseState,
311    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
312    send_grading_request: impl Fn(
313        Url,
314        &ExerciseTask,
315        &ExerciseTaskSubmission,
316    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
317) -> ModelResult<ExerciseTaskGrading> {
318    let exercise_service_info =
319        get_service_info_by_exercise_type(conn, &exercise_task.exercise_type, fetch_service_info)
320            .await?;
321    let exercise_service =
322        get_exercise_service_by_exercise_type(conn, &exercise_task.exercise_type).await?;
323    let grade_url = get_internal_grade_url(&exercise_service, &exercise_service_info).await?;
324    let exercise_task_grading_result =
325        send_grading_request(grade_url, exercise_task, submission).await?;
326    let mut tx = conn.begin().await?;
327    let updated_grading =
328        update_grading(&mut tx, grading, &exercise_task_grading_result, exercise).await?;
329    crate::user_course_exercise_service_variables::insert_after_exercise_task_graded(
330        &mut tx,
331        &exercise_task_grading_result.set_user_variables,
332        exercise_task,
333        user_exercise_state,
334    )
335    .await?;
336    tx.commit().await?;
337    Ok(updated_grading)
338}
339
340pub async fn update_grading(
341    conn: &mut PgConnection,
342    grading: &ExerciseTaskGrading,
343    grading_result: &ExerciseTaskGradingResult,
344    exercise: &Exercise,
345) -> ModelResult<ExerciseTaskGrading> {
346    let grading_completed_at = if grading_result.grading_progress.is_complete() {
347        Some(Utc::now())
348    } else {
349        None
350    };
351    let exercise_slide_id = exercise_tasks::get_exercise_task_by_id(conn, grading.exercise_task_id)
352        .await?
353        .exercise_slide_id;
354    let exercise_task_count =
355        exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(conn, &[exercise_slide_id])
356            .await?
357            .len() as f32;
358    let correctness_coefficient =
359        grading_result.score_given / (grading_result.score_maximum as f32);
360    // ensure the score doesn't go over the maximum
361    let score_given_with_all_decimals = f32::min(
362        (exercise.score_maximum as f32) * correctness_coefficient / exercise_task_count,
363        exercise.score_maximum as f32 / exercise_task_count,
364    );
365    // Scores are rounded to two decimals
366    let score_given_rounded = f32_to_three_decimals(score_given_with_all_decimals);
367    let grading = sqlx::query_as!(
368        ExerciseTaskGrading,
369        r#"
370UPDATE exercise_task_gradings
371SET grading_progress = $2,
372  unscaled_score_given = $3,
373  unscaled_score_maximum = $4,
374  feedback_text = $5,
375  feedback_json = $6,
376  grading_completed_at = $7,
377  score_given = $8
378WHERE id = $1
379RETURNING *
380"#,
381        grading.id,
382        grading_result.grading_progress as GradingProgress,
383        grading_result.score_given,
384        grading_result.score_maximum,
385        grading_result.feedback_text,
386        grading_result.feedback_json,
387        grading_completed_at,
388        score_given_rounded
389    )
390    .fetch_one(conn)
391    .await?;
392
393    Ok(grading)
394}
395
396/// Fetches the grading for the student, but hides the result in some circumstances.
397/// For example, for an ongoing exam.
398pub async fn get_for_student(
399    conn: &mut PgConnection,
400    grading_id: Uuid,
401    user_id: Uuid,
402) -> ModelResult<Option<ExerciseTaskGrading>> {
403    let grading = get_by_id(conn, grading_id).await?;
404    if let Some(exam_id) = grading.exam_id {
405        let exam = exams::get(conn, exam_id).await?;
406        let enrollment = exams::get_enrollment(conn, exam_id, user_id)
407            .await?
408            .ok_or_else(|| {
409                ModelError::new(
410                    ModelErrorType::Generic,
411                    "User has grading for exam but no enrollment".to_string(),
412                    None,
413                )
414            })?;
415        if Utc::now() > enrollment.started_at + chrono::Duration::minutes(exam.time_minutes.into())
416            || exam.ends_at.map(|ea| Utc::now() > ea).unwrap_or_default()
417        {
418            // exam over, return grading
419            Ok(Some(grading))
420        } else {
421            // exam still ongoing, do not return grading
422            Ok(None)
423        }
424    } else {
425        Ok(Some(grading))
426    }
427}
428
429pub async fn get_all_gradings_by_exercise_slide_submission_id(
430    conn: &mut PgConnection,
431    exercise_slide_submission_id: Uuid,
432) -> ModelResult<Vec<ExerciseTaskGrading>> {
433    let res = sqlx::query_as!(
434        ExerciseTaskGrading,
435        r#"
436SELECT id,
437created_at,
438updated_at,
439exercise_task_submission_id,
440course_id,
441exam_id,
442exercise_id,
443exercise_task_id,
444grading_priority,
445score_given,
446grading_progress,
447unscaled_score_given,
448unscaled_score_maximum,
449grading_started_at,
450grading_completed_at,
451feedback_json,
452feedback_text,
453deleted_at
454FROM exercise_task_gradings
455WHERE deleted_at IS NULL
456  AND exercise_task_submission_id IN (
457    SELECT id
458    FROM exercise_task_submissions
459    WHERE exercise_slide_submission_id = $1
460  )
461"#,
462        exercise_slide_submission_id
463    )
464    .fetch_all(&mut *conn)
465    .await?;
466    Ok(res)
467}
468
469pub async fn get_new_and_old_exercise_task_gradings_by_regrading_id(
470    conn: &mut PgConnection,
471    regrading_id: Uuid,
472) -> ModelResult<HashMap<Uuid, ExerciseTaskGrading>> {
473    let res = sqlx::query_as!(
474        ExerciseTaskGrading,
475        r#"
476SELECT id,
477  created_at,
478  updated_at,
479  exercise_task_submission_id,
480  course_id,
481  exam_id,
482  exercise_id,
483  exercise_task_id,
484  grading_priority,
485  score_given,
486  grading_progress,
487  unscaled_score_given,
488  unscaled_score_maximum,
489  grading_started_at,
490  grading_completed_at,
491  feedback_json,
492  feedback_text,
493  deleted_at
494FROM exercise_task_gradings
495WHERE deleted_at IS NULL
496  AND id IN (
497    SELECT etrs.grading_before_regrading
498    FROM exercise_task_regrading_submissions etrs
499    WHERE etrs.deleted_at IS NULL
500      AND etrs.regrading_id = $1
501    UNION
502    SELECT etrs.grading_after_regrading
503    FROM exercise_task_regrading_submissions etrs
504    WHERE etrs.grading_after_regrading IS NOT NULL
505      AND etrs.deleted_at IS NULL
506      AND etrs.regrading_id = $1
507  );
508    "#,
509        regrading_id
510    )
511    .fetch_all(conn)
512    .await?;
513    let mut map = HashMap::with_capacity(res.len());
514    for regrading in res {
515        map.insert(regrading.id, regrading);
516    }
517    Ok(map)
518}
519
520// Get all gradings for user for course module and exercise type
521pub async fn get_user_exercise_task_gradings_by_module_and_exercise_type(
522    conn: &mut PgConnection,
523    user_id: Uuid,
524    exercise_type: &str,
525    module_id: Uuid,
526    course_id: Uuid,
527) -> ModelResult<Vec<CustomViewExerciseTaskGrading>> {
528    let res: Vec<CustomViewExerciseTaskGrading> = sqlx::query_as!(
529        CustomViewExerciseTaskGrading,
530        r#"
531SELECT etg.id,
532  etg.created_at,
533  etg.exercise_id,
534  etg.exercise_task_id,
535  etg.feedback_json,
536  etg.feedback_text
537FROM exercise_task_gradings etg
538  JOIN exercise_tasks et ON etg.exercise_task_id = et.id
539  JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
540  JOIN exercise_slide_submissions ess ON ets.exercise_slide_submission_id = ess.id
541  JOIN exercises e ON ess.exercise_id = e.id
542  JOIN chapters c ON e.chapter_id = c.id
543WHERE etg.deleted_at IS NULL
544  AND et.deleted_at IS NULL
545  AND et.exercise_type = $2
546  AND ess.user_id = $1
547  AND ess.course_id = $4
548  AND ess.deleted_at IS NULL
549  AND e.deleted_at IS NULL
550  AND c.deleted_at IS NULL
551  AND c.course_module_id = $3
552      "#,
553        user_id,
554        exercise_type,
555        module_id,
556        course_id
557    )
558    .fetch_all(conn)
559    .await?;
560    Ok(res)
561}