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
43pub 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
176pub 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 ActivityProgress::Completed
193}
194
195pub 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}