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