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_by_exercise_task_submission_ids(
169    conn: &mut PgConnection,
170    exercise_task_submission_ids: &[Uuid],
171) -> ModelResult<HashMap<Uuid, ExerciseTaskGrading>> {
172    if exercise_task_submission_ids.is_empty() {
173        return Ok(HashMap::new());
174    }
175
176    let gradings = sqlx::query_as!(
177        ExerciseTaskGrading,
178        r#"
179SELECT etg.id,
180  etg.created_at,
181  etg.updated_at,
182  etg.exercise_task_submission_id,
183  etg.course_id,
184  etg.exam_id,
185  etg.exercise_id,
186  etg.exercise_task_id,
187  etg.grading_priority,
188  etg.score_given,
189  etg.grading_progress as "grading_progress: _",
190  etg.unscaled_score_given,
191  etg.unscaled_score_maximum,
192  etg.grading_started_at,
193  etg.grading_completed_at,
194  etg.feedback_json,
195  etg.feedback_text,
196  etg.deleted_at
197FROM exercise_task_submissions ets
198  JOIN exercise_task_gradings etg ON etg.exercise_task_submission_id = ets.id
199WHERE ets.id = ANY($1)
200  AND ets.deleted_at IS NULL
201  AND etg.deleted_at IS NULL
202        "#,
203        exercise_task_submission_ids
204    )
205    .fetch_all(conn)
206    .await?;
207
208    Ok(gradings
209        .into_iter()
210        .map(|grading| (grading.exercise_task_submission_id, grading))
211        .collect())
212}
213
214pub async fn get_total_score_given_for_exercise_slide_submission(
215    conn: &mut PgConnection,
216    exercise_slide_submission_id: &Uuid,
217) -> ModelResult<Option<f32>> {
218    let res = sqlx::query!(
219        "
220SELECT SUM(COALESCE(etg.score_given, 0))::real
221FROM exercise_task_gradings etg
222  JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
223WHERE ets.exercise_slide_submission_id = $1
224  AND etg.deleted_at IS NULL
225  AND ets.deleted_at IS NULL
226        ",
227        exercise_slide_submission_id
228    )
229    .fetch_one(conn)
230    .await?;
231    Ok(res.sum)
232}
233
234/// For now gets this information from some task submission in a slide submission.
235pub async fn get_point_update_strategy_from_gradings(
236    conn: &mut PgConnection,
237    exercise_slide_submission_id: &Uuid,
238) -> ModelResult<GradingProgress> {
239    let res = sqlx::query!(
240        r#"
241SELECT etg.grading_progress as "grading_progress: GradingProgress"
242FROM exercise_task_gradings etg
243  JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
244WHERE ets.exercise_slide_submission_id = $1
245  AND etg.deleted_at IS NULL
246  AND ets.deleted_at IS NULL
247LIMIT 1
248    "#,
249        exercise_slide_submission_id
250    )
251    .fetch_one(conn)
252    .await?;
253    Ok(res.grading_progress)
254}
255
256pub async fn get_course_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Option<Uuid>> {
257    let course_id = sqlx::query!(
258        "
259SELECT course_id
260from exercise_task_gradings
261where id = $1
262        ",
263        id
264    )
265    .fetch_one(conn)
266    .await?
267    .course_id;
268    Ok(course_id)
269}
270
271pub async fn get_course_or_exam_id(
272    conn: &mut PgConnection,
273    id: Uuid,
274) -> ModelResult<CourseOrExamId> {
275    let res = sqlx::query!(
276        "
277SELECT course_id,
278  exam_id
279from exercise_task_gradings
280where id = $1
281",
282        id
283    )
284    .fetch_one(conn)
285    .await?;
286    CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
287}
288
289pub async fn new_grading(
290    conn: &mut PgConnection,
291    exercise: &Exercise,
292    submission: &ExerciseTaskSubmission,
293) -> ModelResult<ExerciseTaskGrading> {
294    let grading = sqlx::query_as!(
295        ExerciseTaskGrading,
296        r#"
297INSERT INTO exercise_task_gradings(
298    exercise_task_submission_id,
299    course_id,
300    exam_id,
301    exercise_id,
302    exercise_task_id,
303    grading_started_at
304  )
305VALUES($1, $2, $3, $4, $5, now())
306RETURNING id,
307  created_at,
308  updated_at,
309  exercise_task_submission_id,
310  course_id,
311  exam_id,
312  exercise_id,
313  exercise_task_id,
314  grading_priority,
315  score_given,
316  grading_progress as "grading_progress: _",
317  unscaled_score_given,
318  unscaled_score_maximum,
319  grading_started_at,
320  grading_completed_at,
321  feedback_json,
322  feedback_text,
323  deleted_at
324"#,
325        submission.id,
326        exercise.course_id,
327        exercise.exam_id,
328        exercise.id,
329        submission.exercise_task_id,
330    )
331    .fetch_one(conn)
332    .await?;
333    Ok(grading)
334}
335
336pub async fn set_grading_progress(
337    conn: &mut PgConnection,
338    id: Uuid,
339    grading_progress: GradingProgress,
340) -> ModelResult<()> {
341    sqlx::query!(
342        "
343UPDATE exercise_task_gradings
344SET grading_progress = $1
345WHERE id = $2
346",
347        grading_progress as GradingProgress,
348        id
349    )
350    .execute(conn)
351    .await?;
352    Ok(())
353}
354
355#[allow(clippy::too_many_arguments)]
356pub async fn grade_submission(
357    conn: &mut PgConnection,
358    submission: &ExerciseTaskSubmission,
359    exercise_task: &ExerciseTask,
360    exercise: &Exercise,
361    grading: &ExerciseTaskGrading,
362    user_exercise_state: &UserExerciseState,
363    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
364    send_grading_request: impl Fn(
365        Url,
366        &ExerciseTask,
367        &ExerciseTaskSubmission,
368    ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
369) -> ModelResult<ExerciseTaskGrading> {
370    let exercise_service_info =
371        get_service_info_by_exercise_type(conn, &exercise_task.exercise_type, fetch_service_info)
372            .await?;
373    let exercise_service =
374        get_exercise_service_by_exercise_type(conn, &exercise_task.exercise_type).await?;
375    let grade_url = get_internal_grade_url(&exercise_service, &exercise_service_info).await?;
376    let exercise_task_grading_result =
377        send_grading_request(grade_url, exercise_task, submission).await?;
378    let mut tx = conn.begin().await?;
379    let updated_grading =
380        update_grading(&mut tx, grading, &exercise_task_grading_result, exercise).await?;
381    crate::user_course_exercise_service_variables::insert_after_exercise_task_graded(
382        &mut tx,
383        &exercise_task_grading_result.set_user_variables,
384        exercise_task,
385        user_exercise_state,
386    )
387    .await?;
388    tx.commit().await?;
389    Ok(updated_grading)
390}
391
392pub async fn update_grading(
393    conn: &mut PgConnection,
394    grading: &ExerciseTaskGrading,
395    grading_result: &ExerciseTaskGradingResult,
396    exercise: &Exercise,
397) -> ModelResult<ExerciseTaskGrading> {
398    let grading_completed_at = if grading_result.grading_progress.is_complete() {
399        Some(Utc::now())
400    } else {
401        None
402    };
403    let exercise_slide_id = exercise_tasks::get_exercise_task_by_id(conn, grading.exercise_task_id)
404        .await?
405        .exercise_slide_id;
406    let exercise_task_count =
407        exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(conn, &[exercise_slide_id])
408            .await?
409            .len() as f32;
410    let correctness_coefficient =
411        grading_result.score_given / (grading_result.score_maximum as f32);
412    // ensure the score doesn't go over the maximum
413    let score_given_with_all_decimals = f32::min(
414        (exercise.score_maximum as f32) * correctness_coefficient / exercise_task_count,
415        exercise.score_maximum as f32 / exercise_task_count,
416    );
417    // Scores are rounded to two decimals
418    let score_given_rounded = f32_to_three_decimals(score_given_with_all_decimals);
419    let grading = sqlx::query_as!(
420        ExerciseTaskGrading,
421        r#"
422UPDATE exercise_task_gradings
423SET grading_progress = $2,
424  unscaled_score_given = $3,
425  unscaled_score_maximum = $4,
426  feedback_text = $5,
427  feedback_json = $6,
428  grading_completed_at = $7,
429  score_given = $8
430WHERE id = $1
431RETURNING id,
432  created_at,
433  updated_at,
434  exercise_task_submission_id,
435  course_id,
436  exam_id,
437  exercise_id,
438  exercise_task_id,
439  grading_priority,
440  score_given,
441  grading_progress as "grading_progress: _",
442  unscaled_score_given,
443  unscaled_score_maximum,
444  grading_started_at,
445  grading_completed_at,
446  feedback_json,
447  feedback_text,
448  deleted_at
449"#,
450        grading.id,
451        grading_result.grading_progress as GradingProgress,
452        grading_result.score_given,
453        grading_result.score_maximum,
454        grading_result.feedback_text,
455        grading_result.feedback_json,
456        grading_completed_at,
457        score_given_rounded
458    )
459    .fetch_one(conn)
460    .await?;
461
462    Ok(grading)
463}
464
465/// Fetches the grading for the student, but hides the result in some circumstances.
466/// For example, for an ongoing exam.
467pub async fn get_for_student(
468    conn: &mut PgConnection,
469    grading_id: Uuid,
470    user_id: Uuid,
471) -> ModelResult<Option<ExerciseTaskGrading>> {
472    let grading = get_by_id(conn, grading_id).await?;
473    if let Some(exam_id) = grading.exam_id {
474        let exam = exams::get(conn, exam_id).await?;
475        let enrollment = exams::get_enrollment(conn, exam_id, user_id)
476            .await?
477            .ok_or_else(|| {
478                ModelError::new(
479                    ModelErrorType::Generic,
480                    "User has grading for exam but no enrollment".to_string(),
481                    None,
482                )
483            })?;
484        if Utc::now() > enrollment.started_at + chrono::Duration::minutes(exam.time_minutes.into())
485            || exam.ends_at.map(|ea| Utc::now() > ea).unwrap_or_default()
486        {
487            // exam over, return grading
488            Ok(Some(grading))
489        } else {
490            // exam still ongoing, do not return grading
491            Ok(None)
492        }
493    } else {
494        Ok(Some(grading))
495    }
496}
497
498pub async fn get_all_gradings_by_exercise_slide_submission_id(
499    conn: &mut PgConnection,
500    exercise_slide_submission_id: Uuid,
501) -> ModelResult<Vec<ExerciseTaskGrading>> {
502    let res = sqlx::query_as!(
503        ExerciseTaskGrading,
504        r#"
505SELECT id,
506created_at,
507updated_at,
508exercise_task_submission_id,
509course_id,
510exam_id,
511exercise_id,
512exercise_task_id,
513grading_priority,
514score_given,
515grading_progress as "grading_progress: _",
516unscaled_score_given,
517unscaled_score_maximum,
518grading_started_at,
519grading_completed_at,
520feedback_json,
521feedback_text,
522deleted_at
523FROM exercise_task_gradings
524WHERE deleted_at IS NULL
525  AND exercise_task_submission_id IN (
526    SELECT id
527    FROM exercise_task_submissions
528    WHERE exercise_slide_submission_id = $1
529  )
530"#,
531        exercise_slide_submission_id
532    )
533    .fetch_all(&mut *conn)
534    .await?;
535    Ok(res)
536}
537
538pub async fn get_new_and_old_exercise_task_gradings_by_regrading_id(
539    conn: &mut PgConnection,
540    regrading_id: Uuid,
541) -> ModelResult<HashMap<Uuid, ExerciseTaskGrading>> {
542    let res = sqlx::query_as!(
543        ExerciseTaskGrading,
544        r#"
545SELECT id,
546  created_at,
547  updated_at,
548  exercise_task_submission_id,
549  course_id,
550  exam_id,
551  exercise_id,
552  exercise_task_id,
553  grading_priority,
554  score_given,
555  grading_progress as "grading_progress: _",
556  unscaled_score_given,
557  unscaled_score_maximum,
558  grading_started_at,
559  grading_completed_at,
560  feedback_json,
561  feedback_text,
562  deleted_at
563FROM exercise_task_gradings
564WHERE deleted_at IS NULL
565  AND id IN (
566    SELECT etrs.grading_before_regrading
567    FROM exercise_task_regrading_submissions etrs
568    WHERE etrs.deleted_at IS NULL
569      AND etrs.regrading_id = $1
570    UNION
571    SELECT etrs.grading_after_regrading
572    FROM exercise_task_regrading_submissions etrs
573    WHERE etrs.grading_after_regrading IS NOT NULL
574      AND etrs.deleted_at IS NULL
575      AND etrs.regrading_id = $1
576  );
577    "#,
578        regrading_id
579    )
580    .fetch_all(conn)
581    .await?;
582    let mut map = HashMap::with_capacity(res.len());
583    for regrading in res {
584        map.insert(regrading.id, regrading);
585    }
586    Ok(map)
587}
588
589// Get all gradings for user for course module and exercise type
590pub async fn get_user_exercise_task_gradings_by_module_and_exercise_type(
591    conn: &mut PgConnection,
592    user_id: Uuid,
593    exercise_type: &str,
594    module_id: Uuid,
595    course_id: Uuid,
596) -> ModelResult<Vec<CustomViewExerciseTaskGrading>> {
597    let res: Vec<CustomViewExerciseTaskGrading> = sqlx::query_as!(
598        CustomViewExerciseTaskGrading,
599        r#"
600SELECT etg.id,
601  etg.created_at,
602  etg.exercise_id,
603  etg.exercise_task_id,
604  etg.feedback_json,
605  etg.feedback_text
606FROM exercise_task_gradings etg
607  JOIN exercise_tasks et ON etg.exercise_task_id = et.id
608  JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
609  JOIN exercise_slide_submissions ess ON ets.exercise_slide_submission_id = ess.id
610  JOIN exercises e ON ess.exercise_id = e.id
611  JOIN chapters c ON e.chapter_id = c.id
612WHERE etg.deleted_at IS NULL
613  AND et.deleted_at IS NULL
614  AND et.exercise_type = $2
615  AND ess.user_id = $1
616  AND ess.course_id = $4
617  AND ess.deleted_at IS NULL
618  AND e.deleted_at IS NULL
619  AND c.deleted_at IS NULL
620  AND c.course_module_id = $3
621      "#,
622        user_id,
623        exercise_type,
624        module_id,
625        course_id
626    )
627    .fetch_all(conn)
628    .await?;
629    Ok(res)
630}