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