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