headless_lms_models/
user_exercise_task_states.rs

1use crate::{
2    exercise_task_gradings::{ExerciseTaskGrading, UserPointsUpdateStrategy},
3    exercises::{ActivityProgress, GradingProgress},
4    prelude::*,
5};
6
7#[derive(Clone, Debug, Deserialize, Serialize)]
8#[cfg_attr(feature = "ts_rs", derive(TS))]
9pub struct UserExerciseTaskState {
10    pub exercise_task_id: Uuid,
11    pub user_exercise_slide_state_id: Uuid,
12    pub created_at: DateTime<Utc>,
13    pub updated_at: DateTime<Utc>,
14    pub deleted_at: Option<DateTime<Utc>>,
15    pub score_given: Option<f32>,
16    pub grading_progress: GradingProgress,
17}
18
19pub async fn insert(
20    conn: &mut PgConnection,
21    exercise_task_id: Uuid,
22    user_exercise_slide_state_id: Uuid,
23    grading_progress: GradingProgress,
24) -> ModelResult<()> {
25    sqlx::query!(
26        "
27INSERT INTO user_exercise_task_states (
28    exercise_task_id,
29    user_exercise_slide_state_id,
30    grading_progress
31  )
32VALUES ($1, $2, $3)
33        ",
34        exercise_task_id,
35        user_exercise_slide_state_id,
36        grading_progress as GradingProgress,
37    )
38    .execute(conn)
39    .await?;
40    Ok(())
41}
42
43/// Upserts user score from task grading results. The score can always increase
44/// or decrease, since they represent only a part of the whole user submission.
45pub async fn upsert_with_grading(
46    conn: &mut PgConnection,
47    user_exercise_slide_state_id: Uuid,
48    exercise_task_grading: &ExerciseTaskGrading,
49) -> ModelResult<UserExerciseTaskState> {
50    upsert_with_grading_status(
51        conn,
52        exercise_task_grading.exercise_task_id,
53        user_exercise_slide_state_id,
54        exercise_task_grading.score_given,
55        exercise_task_grading.grading_progress,
56    )
57    .await
58}
59
60async fn upsert_with_grading_status(
61    conn: &mut PgConnection,
62    exercise_task_id: Uuid,
63    user_exercise_slide_state_id: Uuid,
64    score_given: Option<f32>,
65    grading_progress: GradingProgress,
66) -> ModelResult<UserExerciseTaskState> {
67    let res = sqlx::query_as!(
68        UserExerciseTaskState,
69        r#"
70INSERT INTO user_exercise_task_states (
71    exercise_task_id,
72    user_exercise_slide_state_id,
73    score_given,
74    grading_progress
75  )
76VALUES ($1, $2, $3, $4) ON CONFLICT (exercise_task_id, user_exercise_slide_state_id) DO
77UPDATE
78SET deleted_at = NULL,
79  score_given = $3,
80  grading_progress = $4
81RETURNING exercise_task_id,
82  user_exercise_slide_state_id,
83  created_at,
84  updated_at,
85  deleted_at,
86  score_given,
87  grading_progress as "grading_progress: _"
88    "#,
89        exercise_task_id,
90        user_exercise_slide_state_id,
91        score_given,
92        grading_progress as GradingProgress,
93    )
94    .fetch_one(conn)
95    .await?;
96    Ok(res)
97}
98
99pub async fn get(
100    conn: &mut PgConnection,
101    exercise_task_id: Uuid,
102    user_exercise_state_id: Uuid,
103) -> ModelResult<UserExerciseTaskState> {
104    let res = sqlx::query_as!(
105        UserExerciseTaskState,
106        r#"
107SELECT exercise_task_id,
108  user_exercise_slide_state_id,
109  created_at,
110  updated_at,
111  deleted_at,
112  score_given,
113  grading_progress as "grading_progress: _"
114FROM user_exercise_task_states
115WHERE exercise_task_id = $1
116  AND user_exercise_slide_state_id = $2
117  AND deleted_at IS NULL
118        "#,
119        exercise_task_id,
120        user_exercise_state_id,
121    )
122    .fetch_one(conn)
123    .await?;
124    Ok(res)
125}
126
127pub async fn get_grading_summary_by_user_exercise_slide_state_id(
128    conn: &mut PgConnection,
129    user_exercise_slide_state_id: Uuid,
130) -> ModelResult<(Option<f32>, GradingProgress)> {
131    let res = sqlx::query!(
132        r#"
133SELECT score_given,
134  grading_progress AS "grading_progress: GradingProgress"
135FROM user_exercise_task_states
136WHERE user_exercise_slide_state_id = $1
137  AND deleted_at IS NULL
138        "#,
139        user_exercise_slide_state_id
140    )
141    .fetch_all(conn)
142    .await?;
143    let total_score_given = res
144        .iter()
145        .filter_map(|x| x.score_given)
146        .reduce(|acc, next| acc + next);
147    let least_significant_grading_progress = res
148        .iter()
149        .map(|x| x.grading_progress)
150        .min()
151        .unwrap_or(GradingProgress::NotReady);
152    Ok((total_score_given, least_significant_grading_progress))
153}
154
155pub async fn delete(
156    conn: &mut PgConnection,
157    exercise_task_id: Uuid,
158    user_exercise_slide_state_id: Uuid,
159) -> ModelResult<()> {
160    sqlx::query!(
161        "
162UPDATE user_exercise_task_states
163SET deleted_at = now()
164WHERE exercise_task_id = $1
165  AND user_exercise_slide_state_id = $2
166  AND deleted_at IS NULL
167    ",
168        exercise_task_id,
169        user_exercise_slide_state_id,
170    )
171    .execute(conn)
172    .await?;
173    Ok(())
174}
175
176/**
177Returns a new state for the activity progress.
178
179In the future this function will be extended to support peer reviews. When
180there's a peer review associated with the exercise, the activity is not complete
181before the user has given the peer reviews that they're required to give.
182*/
183pub fn figure_out_new_activity_progress(
184    current_activity_progress: ActivityProgress,
185) -> ActivityProgress {
186    if current_activity_progress == ActivityProgress::Completed {
187        return ActivityProgress::Completed;
188    }
189
190    // The case where activity is not completed when the user needs to give peer
191    // reviews
192    ActivityProgress::Completed
193}
194
195/**
196Returns a new state for the grading progress.
197
198The new grading progress is always the grading progress from the new grading
199unless the current grading progress is already finished. If the current grading
200progress is finished, we don't change it to anything else so that a new worse
201submission won't take the user's progress away.
202
203In the future this function will be extended to support peer reviews. When
204there's a peer review associated with the exercise, it is part of the overall
205grading progress.
206*/
207pub fn figure_out_new_grading_progress(
208    current_grading_progress: Option<GradingProgress>,
209    grading_grading_progress: GradingProgress,
210) -> GradingProgress {
211    match current_grading_progress {
212        Some(GradingProgress::FullyGraded) => GradingProgress::FullyGraded,
213        _ => grading_grading_progress,
214    }
215}
216
217pub fn figure_out_new_score_given(
218    current_score_given: Option<f32>,
219    grading_score_given: Option<f32>,
220    user_points_update_strategy: UserPointsUpdateStrategy,
221) -> Option<f32> {
222    let current_score_given = if let Some(current_score_given) = current_score_given {
223        current_score_given
224    } else {
225        info!(
226            "Current state has no score, using score from grading ({:?})",
227            grading_score_given
228        );
229        return grading_score_given;
230    };
231    let grading_score_given = if let Some(grading_score_given) = grading_score_given {
232        grading_score_given
233    } else {
234        info!(
235            "Grading has no score, using score from current state ({:?})",
236            current_score_given
237        );
238        return Some(current_score_given);
239    };
240
241    let new_score = match user_points_update_strategy {
242        UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints => {
243            if current_score_given >= grading_score_given {
244                info!(
245                    "Not updating score ({:?} >= {:?})",
246                    current_score_given, grading_score_given
247                );
248                current_score_given
249            } else {
250                info!(
251                    "Updating score from {:?} to {:?}",
252                    current_score_given, grading_score_given
253                );
254                grading_score_given
255            }
256        }
257        UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints => {
258            info!(
259                "Updating score from {:?} to {:?}",
260                current_score_given, grading_score_given
261            );
262            grading_score_given
263        }
264    };
265    Some(new_score)
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::test_helper::*;
272
273    mod get_grading_summary_by_user_exercise_slide_state_id {
274        use headless_lms_utils::numbers::f32_approx_eq;
275        use serde_json::Value;
276
277        use crate::{
278            chapters::{self, NewChapter},
279            exercise_slides,
280            exercise_tasks::{self, NewExerciseTask},
281            exercises,
282            pages::{self, NewCoursePage},
283            user_exercise_slide_states, user_exercise_states,
284        };
285
286        use super::*;
287
288        #[tokio::test]
289        async fn initial_values() {
290            insert_data!(:tx);
291            let (user_exercise_slide_state_id, task_1, task_2, task_3) =
292                create_test_data(&mut tx).await.unwrap();
293            insert(
294                tx.as_mut(),
295                task_1,
296                user_exercise_slide_state_id,
297                GradingProgress::NotReady,
298            )
299            .await
300            .unwrap();
301            insert(
302                tx.as_mut(),
303                task_2,
304                user_exercise_slide_state_id,
305                GradingProgress::NotReady,
306            )
307            .await
308            .unwrap();
309            insert(
310                tx.as_mut(),
311                task_3,
312                user_exercise_slide_state_id,
313                GradingProgress::NotReady,
314            )
315            .await
316            .unwrap();
317
318            let (score_given, grading_progress) =
319                get_grading_summary_by_user_exercise_slide_state_id(
320                    tx.as_mut(),
321                    user_exercise_slide_state_id,
322                )
323                .await
324                .unwrap();
325            assert_eq!(score_given, None);
326            assert_eq!(grading_progress, GradingProgress::NotReady);
327        }
328
329        #[tokio::test]
330        async fn single_task() {
331            insert_data!(:tx);
332            let (user_exercise_slide_state_id, task_1, task_2, task_3) =
333                create_test_data(&mut tx).await.unwrap();
334            upsert_with_grading_status(
335                tx.as_mut(),
336                task_1,
337                user_exercise_slide_state_id,
338                None,
339                GradingProgress::NotReady,
340            )
341            .await
342            .unwrap();
343            upsert_with_grading_status(
344                tx.as_mut(),
345                task_2,
346                user_exercise_slide_state_id,
347                None,
348                GradingProgress::NotReady,
349            )
350            .await
351            .unwrap();
352            upsert_with_grading_status(
353                tx.as_mut(),
354                task_3,
355                user_exercise_slide_state_id,
356                Some(1.0),
357                GradingProgress::FullyGraded,
358            )
359            .await
360            .unwrap();
361
362            let (score_given, grading_progress) =
363                get_grading_summary_by_user_exercise_slide_state_id(
364                    tx.as_mut(),
365                    user_exercise_slide_state_id,
366                )
367                .await
368                .unwrap();
369            assert!(f32_approx_eq(score_given.unwrap(), 1.0));
370            assert_eq!(grading_progress, GradingProgress::NotReady);
371        }
372
373        #[tokio::test]
374        async fn all_tasks() {
375            insert_data!(:tx);
376            let (user_exercise_slide_state_id, task_1, task_2, task_3) =
377                create_test_data(&mut tx).await.unwrap();
378            upsert_with_grading_status(
379                tx.as_mut(),
380                task_1,
381                user_exercise_slide_state_id,
382                Some(1.0),
383                GradingProgress::FullyGraded,
384            )
385            .await
386            .unwrap();
387            upsert_with_grading_status(
388                tx.as_mut(),
389                task_2,
390                user_exercise_slide_state_id,
391                Some(1.0),
392                GradingProgress::FullyGraded,
393            )
394            .await
395            .unwrap();
396            upsert_with_grading_status(
397                tx.as_mut(),
398                task_3,
399                user_exercise_slide_state_id,
400                Some(1.0),
401                GradingProgress::FullyGraded,
402            )
403            .await
404            .unwrap();
405
406            let (score_given, grading_progress) =
407                get_grading_summary_by_user_exercise_slide_state_id(
408                    tx.as_mut(),
409                    user_exercise_slide_state_id,
410                )
411                .await
412                .unwrap();
413            assert!(f32_approx_eq(score_given.unwrap(), 3.0));
414            assert_eq!(grading_progress, GradingProgress::FullyGraded);
415        }
416
417        async fn create_test_data(tx: &mut Tx<'_>) -> ModelResult<(Uuid, Uuid, Uuid, Uuid)> {
418            insert_data!(tx: tx; :user, :org, :course, :instance, :course_module);
419            let chapter_id = chapters::insert(
420                tx.as_mut(),
421                PKeyPolicy::Generate,
422                &NewChapter {
423                    name: "chapter".to_string(),
424                    color: Some("#065853".to_string()),
425                    course_id: course,
426                    chapter_number: 1,
427                    front_page_id: None,
428                    opens_at: None,
429                    deadline: None,
430                    course_module_id: Some(course_module.id),
431                },
432            )
433            .await?;
434
435            let (page_id, _history) = pages::insert_course_page(
436                tx.as_mut(),
437                &NewCoursePage::new(course, 1, "/test", "test"),
438                user,
439            )
440            .await?;
441            let exercise_id = exercises::insert(
442                tx.as_mut(),
443                PKeyPolicy::Generate,
444                course,
445                "course",
446                page_id,
447                chapter_id,
448                1,
449            )
450            .await?;
451            let slide_id =
452                exercise_slides::insert(tx.as_mut(), PKeyPolicy::Generate, exercise_id, 1).await?;
453            let task_1 = exercise_tasks::insert(
454                tx.as_mut(),
455                PKeyPolicy::Generate,
456                NewExerciseTask {
457                    exercise_slide_id: slide_id,
458                    exercise_type: "test-exercise".to_string(),
459                    assignment: vec![],
460                    public_spec: Some(Value::Null),
461                    private_spec: Some(Value::Null),
462                    model_solution_spec: Some(Value::Null),
463                    order_number: 1,
464                },
465            )
466            .await?;
467            let task_2 = exercise_tasks::insert(
468                tx.as_mut(),
469                PKeyPolicy::Generate,
470                NewExerciseTask {
471                    exercise_slide_id: slide_id,
472                    exercise_type: "test-exercise".to_string(),
473                    assignment: vec![],
474                    public_spec: Some(Value::Null),
475                    private_spec: Some(Value::Null),
476                    model_solution_spec: Some(Value::Null),
477                    order_number: 2,
478                },
479            )
480            .await?;
481            let task_3 = exercise_tasks::insert(
482                tx.as_mut(),
483                PKeyPolicy::Generate,
484                NewExerciseTask {
485                    exercise_slide_id: slide_id,
486                    exercise_type: "test-exercise".to_string(),
487                    assignment: vec![],
488                    public_spec: Some(Value::Null),
489                    private_spec: Some(Value::Null),
490                    model_solution_spec: Some(Value::Null),
491                    order_number: 3,
492                },
493            )
494            .await?;
495            let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
496                tx.as_mut(),
497                user,
498                exercise_id,
499                Some(instance.id),
500                None,
501            )
502            .await?;
503            user_exercise_states::upsert_selected_exercise_slide_id(
504                tx.as_mut(),
505                user,
506                exercise_id,
507                Some(instance.id),
508                None,
509                Some(slide_id),
510            )
511            .await?;
512            let user_exercise_slide_state_id = user_exercise_slide_states::insert(
513                tx.as_mut(),
514                PKeyPolicy::Generate,
515                user_exercise_state.id,
516                slide_id,
517            )
518            .await?;
519            Ok((user_exercise_slide_state_id, task_1, task_2, task_3))
520        }
521    }
522
523    mod figure_out_new_activity_progress {
524        use super::*;
525
526        #[test]
527        fn it_works() {
528            assert_eq!(
529                figure_out_new_activity_progress(ActivityProgress::Initialized),
530                ActivityProgress::Completed
531            );
532        }
533    }
534
535    mod figure_out_new_grading_progress {
536        use super::*;
537
538        const ALL_GRADING_PROGRESSES: [GradingProgress; 5] = [
539            GradingProgress::FullyGraded,
540            GradingProgress::Pending,
541            GradingProgress::PendingManual,
542            GradingProgress::Failed,
543            GradingProgress::NotReady,
544        ];
545
546        #[test]
547        fn current_fully_graded_progress_always_retains() {
548            let current_grading_progress = GradingProgress::FullyGraded;
549            for grading_grading_progress in ALL_GRADING_PROGRESSES {
550                let new_grading_progress = figure_out_new_grading_progress(
551                    Some(current_grading_progress),
552                    grading_grading_progress,
553                );
554                assert_eq!(new_grading_progress, current_grading_progress);
555            }
556        }
557
558        #[test]
559        fn uses_value_from_grading_if_not_completed() {
560            for grading_grading_progress in ALL_GRADING_PROGRESSES {
561                let current_grading_progresses = vec![
562                    None,
563                    Some(GradingProgress::Pending),
564                    Some(GradingProgress::PendingManual),
565                    Some(GradingProgress::Failed),
566                    Some(GradingProgress::NotReady),
567                ];
568                for current_grading_progress in current_grading_progresses {
569                    let new_grading_progress = figure_out_new_grading_progress(
570                        current_grading_progress,
571                        grading_grading_progress,
572                    );
573                    assert_eq!(new_grading_progress, grading_grading_progress);
574                }
575            }
576        }
577    }
578
579    mod figure_out_new_score_given {
580        use headless_lms_utils::numbers::{f32_approx_eq, f32_max};
581
582        use super::*;
583
584        #[test]
585        fn strategy_can_add_points_and_can_remove_points_works() {
586            let test_cases = vec![(1.1, 1.1), (1.1, 20.9), (20.9, 1.1)];
587            for (current, new) in test_cases {
588                let result = figure_out_new_score_given(
589                    Some(current),
590                    Some(new),
591                    UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
592                )
593                .unwrap();
594                assert!(f32_approx_eq(result, new));
595            }
596        }
597
598        #[test]
599        fn strategy_can_add_points_but_cannot_remove_points_works() {
600            let test_cases = vec![(1.1, 1.1), (1.1, 20.9), (20.9, 1.1)];
601            for (current, new) in test_cases {
602                let result = figure_out_new_score_given(
603                    Some(current),
604                    Some(new),
605                    UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
606                )
607                .unwrap();
608                assert!(f32_approx_eq(result, f32_max(current, new)))
609            }
610        }
611
612        #[test]
613        fn it_handles_nones() {
614            let user_points_update_strategies = vec![
615                UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
616                UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
617            ];
618            for update_strategy in user_points_update_strategies {
619                assert_eq!(
620                    figure_out_new_score_given(None, None, update_strategy),
621                    None
622                );
623                assert!(f32_approx_eq(
624                    figure_out_new_score_given(None, Some(1.1), update_strategy).unwrap(),
625                    1.1
626                ));
627                assert!(f32_approx_eq(
628                    figure_out_new_score_given(Some(1.1), None, update_strategy).unwrap(),
629                    1.1
630                ));
631            }
632        }
633    }
634}