Skip to main content

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