1use std::collections::HashMap;
2
3use futures::future::BoxFuture;
4use headless_lms_utils::numbers::f32_to_three_decimals;
5use url::Url;
6
7use crate::{
8 CourseOrExamId, exams,
9 exercise_service_info::{ExerciseServiceInfoApi, get_service_info_by_exercise_type},
10 exercise_services::{get_exercise_service_by_exercise_type, get_internal_grade_url},
11 exercise_task_submissions::ExerciseTaskSubmission,
12 exercise_tasks::{self, ExerciseTask},
13 exercises::{Exercise, GradingProgress},
14 library::custom_view_exercises::CustomViewExerciseTaskGrading,
15 prelude::*,
16 user_exercise_states::UserExerciseState,
17};
18
19#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
20#[cfg_attr(feature = "ts_rs", derive(TS))]
21pub struct ExerciseTaskGrading {
22 pub id: Uuid,
23 pub created_at: DateTime<Utc>,
24 pub updated_at: DateTime<Utc>,
25 pub exercise_task_submission_id: Uuid,
26 pub course_id: Option<Uuid>,
27 pub exam_id: Option<Uuid>,
28 pub exercise_id: Uuid,
29 pub exercise_task_id: Uuid,
30 pub grading_priority: i32,
31 pub score_given: Option<f32>,
32 pub grading_progress: GradingProgress,
33 pub unscaled_score_given: Option<f32>,
34 pub unscaled_score_maximum: Option<i32>,
35 pub grading_started_at: Option<DateTime<Utc>>,
36 pub grading_completed_at: Option<DateTime<Utc>>,
37 pub feedback_json: Option<serde_json::Value>,
38 pub feedback_text: Option<String>,
39 pub deleted_at: Option<DateTime<Utc>>,
40}
41
42#[derive(Debug, Serialize, PartialEq, Eq, Clone)]
43pub struct ExerciseTaskGradingRequest<'a> {
44 pub grading_update_url: &'a str,
45 pub exercise_spec: &'a Option<serde_json::Value>,
46 pub submission_data: &'a Option<serde_json::Value>,
47}
48
49#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
50#[cfg_attr(feature = "ts_rs", derive(TS))]
51pub struct ExerciseTaskGradingResult {
52 pub grading_progress: GradingProgress,
53 pub score_given: f32,
54 pub score_maximum: i32,
55 pub feedback_text: Option<String>,
56 pub feedback_json: Option<serde_json::Value>,
57 #[serde(skip_serializing_if = "Option::is_none")] pub set_user_variables: Option<HashMap<String, serde_json::Value>>,
59}
60
61#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, sqlx::Type)]
62#[cfg_attr(feature = "ts_rs", derive(TS))]
63#[sqlx(type_name = "user_points_update_strategy", rename_all = "kebab-case")]
64pub enum UserPointsUpdateStrategy {
65 CanAddPointsButCannotRemovePoints,
66 CanAddPointsAndCanRemovePoints,
67}
68
69pub async fn insert(
70 conn: &mut PgConnection,
71 pkey_policy: PKeyPolicy<Uuid>,
72 submission_id: Uuid,
73 course_id: Uuid,
74 exercise_id: Uuid,
75 exercise_task_id: Uuid,
76) -> ModelResult<Uuid> {
77 let res = sqlx::query!(
78 "
79INSERT INTO exercise_task_gradings (
80 id,
81 exercise_task_submission_id,
82 course_id,
83 exercise_id,
84 exercise_task_id
85 )
86VALUES ($1, $2, $3, $4, $5)
87RETURNING id
88 ",
89 pkey_policy.into_uuid(),
90 submission_id,
91 course_id,
92 exercise_id,
93 exercise_task_id
94 )
95 .fetch_one(conn)
96 .await?;
97 Ok(res.id)
98}
99
100pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ExerciseTaskGrading> {
101 let res = sqlx::query_as!(
102 ExerciseTaskGrading,
103 r#"
104SELECT id,
105 created_at,
106 updated_at,
107 exercise_task_submission_id,
108 course_id,
109 exam_id,
110 exercise_id,
111 exercise_task_id,
112 grading_priority,
113 score_given,
114 grading_progress as "grading_progress: _",
115 unscaled_score_maximum,
116 unscaled_score_given,
117 grading_started_at,
118 grading_completed_at,
119 feedback_json,
120 feedback_text,
121 deleted_at
122FROM exercise_task_gradings
123WHERE id = $1
124"#,
125 id
126 )
127 .fetch_one(conn)
128 .await?;
129 Ok(res)
130}
131
132pub async fn get_by_exercise_task_submission_id(
133 conn: &mut PgConnection,
134 exercise_task_submission_id: Uuid,
135) -> ModelResult<Option<ExerciseTaskGrading>> {
136 let res = sqlx::query_as!(
137 ExerciseTaskGrading,
138 r#"
139SELECT id,
140 created_at,
141 updated_at,
142 exercise_task_submission_id,
143 course_id,
144 exam_id,
145 exercise_id,
146 exercise_task_id,
147 grading_priority,
148 score_given,
149 grading_progress as "grading_progress: _",
150 unscaled_score_maximum,
151 unscaled_score_given,
152 grading_started_at,
153 grading_completed_at,
154 feedback_json,
155 feedback_text,
156 deleted_at
157FROM exercise_task_gradings
158WHERE exercise_task_submission_id = $1
159 AND deleted_at IS NULL
160 "#,
161 exercise_task_submission_id,
162 )
163 .fetch_optional(conn)
164 .await?;
165 Ok(res)
166}
167
168pub async fn get_by_exercise_task_submission_ids(
169 conn: &mut PgConnection,
170 exercise_task_submission_ids: &[Uuid],
171) -> ModelResult<HashMap<Uuid, ExerciseTaskGrading>> {
172 if exercise_task_submission_ids.is_empty() {
173 return Ok(HashMap::new());
174 }
175
176 let gradings = sqlx::query_as!(
177 ExerciseTaskGrading,
178 r#"
179SELECT etg.id,
180 etg.created_at,
181 etg.updated_at,
182 etg.exercise_task_submission_id,
183 etg.course_id,
184 etg.exam_id,
185 etg.exercise_id,
186 etg.exercise_task_id,
187 etg.grading_priority,
188 etg.score_given,
189 etg.grading_progress as "grading_progress: _",
190 etg.unscaled_score_given,
191 etg.unscaled_score_maximum,
192 etg.grading_started_at,
193 etg.grading_completed_at,
194 etg.feedback_json,
195 etg.feedback_text,
196 etg.deleted_at
197FROM exercise_task_submissions ets
198 JOIN exercise_task_gradings etg ON etg.exercise_task_submission_id = ets.id
199WHERE ets.id = ANY($1)
200 AND ets.deleted_at IS NULL
201 AND etg.deleted_at IS NULL
202 "#,
203 exercise_task_submission_ids
204 )
205 .fetch_all(conn)
206 .await?;
207
208 Ok(gradings
209 .into_iter()
210 .map(|grading| (grading.exercise_task_submission_id, grading))
211 .collect())
212}
213
214pub async fn get_total_score_given_for_exercise_slide_submission(
215 conn: &mut PgConnection,
216 exercise_slide_submission_id: &Uuid,
217) -> ModelResult<Option<f32>> {
218 let res = sqlx::query!(
219 "
220SELECT SUM(COALESCE(etg.score_given, 0))::real
221FROM exercise_task_gradings etg
222 JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
223WHERE ets.exercise_slide_submission_id = $1
224 AND etg.deleted_at IS NULL
225 AND ets.deleted_at IS NULL
226 ",
227 exercise_slide_submission_id
228 )
229 .fetch_one(conn)
230 .await?;
231 Ok(res.sum)
232}
233
234pub async fn get_point_update_strategy_from_gradings(
236 conn: &mut PgConnection,
237 exercise_slide_submission_id: &Uuid,
238) -> ModelResult<GradingProgress> {
239 let res = sqlx::query!(
240 r#"
241SELECT etg.grading_progress as "grading_progress: GradingProgress"
242FROM exercise_task_gradings etg
243 JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
244WHERE ets.exercise_slide_submission_id = $1
245 AND etg.deleted_at IS NULL
246 AND ets.deleted_at IS NULL
247LIMIT 1
248 "#,
249 exercise_slide_submission_id
250 )
251 .fetch_one(conn)
252 .await?;
253 Ok(res.grading_progress)
254}
255
256pub async fn get_course_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Option<Uuid>> {
257 let course_id = sqlx::query!(
258 "
259SELECT course_id
260from exercise_task_gradings
261where id = $1
262 ",
263 id
264 )
265 .fetch_one(conn)
266 .await?
267 .course_id;
268 Ok(course_id)
269}
270
271pub async fn get_course_or_exam_id(
272 conn: &mut PgConnection,
273 id: Uuid,
274) -> ModelResult<CourseOrExamId> {
275 let res = sqlx::query!(
276 "
277SELECT course_id,
278 exam_id
279from exercise_task_gradings
280where id = $1
281",
282 id
283 )
284 .fetch_one(conn)
285 .await?;
286 CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
287}
288
289pub async fn new_grading(
290 conn: &mut PgConnection,
291 exercise: &Exercise,
292 submission: &ExerciseTaskSubmission,
293) -> ModelResult<ExerciseTaskGrading> {
294 let grading = sqlx::query_as!(
295 ExerciseTaskGrading,
296 r#"
297INSERT INTO exercise_task_gradings(
298 exercise_task_submission_id,
299 course_id,
300 exam_id,
301 exercise_id,
302 exercise_task_id,
303 grading_started_at
304 )
305VALUES($1, $2, $3, $4, $5, now())
306RETURNING id,
307 created_at,
308 updated_at,
309 exercise_task_submission_id,
310 course_id,
311 exam_id,
312 exercise_id,
313 exercise_task_id,
314 grading_priority,
315 score_given,
316 grading_progress as "grading_progress: _",
317 unscaled_score_given,
318 unscaled_score_maximum,
319 grading_started_at,
320 grading_completed_at,
321 feedback_json,
322 feedback_text,
323 deleted_at
324"#,
325 submission.id,
326 exercise.course_id,
327 exercise.exam_id,
328 exercise.id,
329 submission.exercise_task_id,
330 )
331 .fetch_one(conn)
332 .await?;
333 Ok(grading)
334}
335
336pub async fn set_grading_progress(
337 conn: &mut PgConnection,
338 id: Uuid,
339 grading_progress: GradingProgress,
340) -> ModelResult<()> {
341 sqlx::query!(
342 "
343UPDATE exercise_task_gradings
344SET grading_progress = $1
345WHERE id = $2
346",
347 grading_progress as GradingProgress,
348 id
349 )
350 .execute(conn)
351 .await?;
352 Ok(())
353}
354
355#[allow(clippy::too_many_arguments)]
356pub async fn grade_submission(
357 conn: &mut PgConnection,
358 submission: &ExerciseTaskSubmission,
359 exercise_task: &ExerciseTask,
360 exercise: &Exercise,
361 grading: &ExerciseTaskGrading,
362 user_exercise_state: &UserExerciseState,
363 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
364 send_grading_request: impl Fn(
365 Url,
366 &ExerciseTask,
367 &ExerciseTaskSubmission,
368 ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
369) -> ModelResult<ExerciseTaskGrading> {
370 let exercise_service_info =
371 get_service_info_by_exercise_type(conn, &exercise_task.exercise_type, fetch_service_info)
372 .await?;
373 let exercise_service =
374 get_exercise_service_by_exercise_type(conn, &exercise_task.exercise_type).await?;
375 let grade_url = get_internal_grade_url(&exercise_service, &exercise_service_info).await?;
376 let exercise_task_grading_result =
377 send_grading_request(grade_url, exercise_task, submission).await?;
378 let mut tx = conn.begin().await?;
379 let updated_grading =
380 update_grading(&mut tx, grading, &exercise_task_grading_result, exercise).await?;
381 crate::user_course_exercise_service_variables::insert_after_exercise_task_graded(
382 &mut tx,
383 &exercise_task_grading_result.set_user_variables,
384 exercise_task,
385 user_exercise_state,
386 )
387 .await?;
388 tx.commit().await?;
389 Ok(updated_grading)
390}
391
392pub async fn update_grading(
393 conn: &mut PgConnection,
394 grading: &ExerciseTaskGrading,
395 grading_result: &ExerciseTaskGradingResult,
396 exercise: &Exercise,
397) -> ModelResult<ExerciseTaskGrading> {
398 let grading_completed_at = if grading_result.grading_progress.is_complete() {
399 Some(Utc::now())
400 } else {
401 None
402 };
403 let exercise_slide_id = exercise_tasks::get_exercise_task_by_id(conn, grading.exercise_task_id)
404 .await?
405 .exercise_slide_id;
406 let exercise_task_count =
407 exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(conn, &[exercise_slide_id])
408 .await?
409 .len() as f32;
410 let correctness_coefficient =
411 grading_result.score_given / (grading_result.score_maximum as f32);
412 let score_given_with_all_decimals = f32::min(
414 (exercise.score_maximum as f32) * correctness_coefficient / exercise_task_count,
415 exercise.score_maximum as f32 / exercise_task_count,
416 );
417 let score_given_rounded = f32_to_three_decimals(score_given_with_all_decimals);
419 let grading = sqlx::query_as!(
420 ExerciseTaskGrading,
421 r#"
422UPDATE exercise_task_gradings
423SET grading_progress = $2,
424 unscaled_score_given = $3,
425 unscaled_score_maximum = $4,
426 feedback_text = $5,
427 feedback_json = $6,
428 grading_completed_at = $7,
429 score_given = $8
430WHERE id = $1
431RETURNING id,
432 created_at,
433 updated_at,
434 exercise_task_submission_id,
435 course_id,
436 exam_id,
437 exercise_id,
438 exercise_task_id,
439 grading_priority,
440 score_given,
441 grading_progress as "grading_progress: _",
442 unscaled_score_given,
443 unscaled_score_maximum,
444 grading_started_at,
445 grading_completed_at,
446 feedback_json,
447 feedback_text,
448 deleted_at
449"#,
450 grading.id,
451 grading_result.grading_progress as GradingProgress,
452 grading_result.score_given,
453 grading_result.score_maximum,
454 grading_result.feedback_text,
455 grading_result.feedback_json,
456 grading_completed_at,
457 score_given_rounded
458 )
459 .fetch_one(conn)
460 .await?;
461
462 Ok(grading)
463}
464
465pub async fn get_for_student(
468 conn: &mut PgConnection,
469 grading_id: Uuid,
470 user_id: Uuid,
471) -> ModelResult<Option<ExerciseTaskGrading>> {
472 let grading = get_by_id(conn, grading_id).await?;
473 if let Some(exam_id) = grading.exam_id {
474 let exam = exams::get(conn, exam_id).await?;
475 let enrollment = exams::get_enrollment(conn, exam_id, user_id)
476 .await?
477 .ok_or_else(|| {
478 ModelError::new(
479 ModelErrorType::Generic,
480 "User has grading for exam but no enrollment".to_string(),
481 None,
482 )
483 })?;
484 if Utc::now() > enrollment.started_at + chrono::Duration::minutes(exam.time_minutes.into())
485 || exam.ends_at.map(|ea| Utc::now() > ea).unwrap_or_default()
486 {
487 Ok(Some(grading))
489 } else {
490 Ok(None)
492 }
493 } else {
494 Ok(Some(grading))
495 }
496}
497
498pub async fn get_all_gradings_by_exercise_slide_submission_id(
499 conn: &mut PgConnection,
500 exercise_slide_submission_id: Uuid,
501) -> ModelResult<Vec<ExerciseTaskGrading>> {
502 let res = sqlx::query_as!(
503 ExerciseTaskGrading,
504 r#"
505SELECT id,
506created_at,
507updated_at,
508exercise_task_submission_id,
509course_id,
510exam_id,
511exercise_id,
512exercise_task_id,
513grading_priority,
514score_given,
515grading_progress as "grading_progress: _",
516unscaled_score_given,
517unscaled_score_maximum,
518grading_started_at,
519grading_completed_at,
520feedback_json,
521feedback_text,
522deleted_at
523FROM exercise_task_gradings
524WHERE deleted_at IS NULL
525 AND exercise_task_submission_id IN (
526 SELECT id
527 FROM exercise_task_submissions
528 WHERE exercise_slide_submission_id = $1
529 )
530"#,
531 exercise_slide_submission_id
532 )
533 .fetch_all(&mut *conn)
534 .await?;
535 Ok(res)
536}
537
538pub async fn get_new_and_old_exercise_task_gradings_by_regrading_id(
539 conn: &mut PgConnection,
540 regrading_id: Uuid,
541) -> ModelResult<HashMap<Uuid, ExerciseTaskGrading>> {
542 let res = sqlx::query_as!(
543 ExerciseTaskGrading,
544 r#"
545SELECT id,
546 created_at,
547 updated_at,
548 exercise_task_submission_id,
549 course_id,
550 exam_id,
551 exercise_id,
552 exercise_task_id,
553 grading_priority,
554 score_given,
555 grading_progress as "grading_progress: _",
556 unscaled_score_given,
557 unscaled_score_maximum,
558 grading_started_at,
559 grading_completed_at,
560 feedback_json,
561 feedback_text,
562 deleted_at
563FROM exercise_task_gradings
564WHERE deleted_at IS NULL
565 AND id IN (
566 SELECT etrs.grading_before_regrading
567 FROM exercise_task_regrading_submissions etrs
568 WHERE etrs.deleted_at IS NULL
569 AND etrs.regrading_id = $1
570 UNION
571 SELECT etrs.grading_after_regrading
572 FROM exercise_task_regrading_submissions etrs
573 WHERE etrs.grading_after_regrading IS NOT NULL
574 AND etrs.deleted_at IS NULL
575 AND etrs.regrading_id = $1
576 );
577 "#,
578 regrading_id
579 )
580 .fetch_all(conn)
581 .await?;
582 let mut map = HashMap::with_capacity(res.len());
583 for regrading in res {
584 map.insert(regrading.id, regrading);
585 }
586 Ok(map)
587}
588
589pub async fn get_user_exercise_task_gradings_by_module_and_exercise_type(
591 conn: &mut PgConnection,
592 user_id: Uuid,
593 exercise_type: &str,
594 module_id: Uuid,
595 course_id: Uuid,
596) -> ModelResult<Vec<CustomViewExerciseTaskGrading>> {
597 let res: Vec<CustomViewExerciseTaskGrading> = sqlx::query_as!(
598 CustomViewExerciseTaskGrading,
599 r#"
600SELECT etg.id,
601 etg.created_at,
602 etg.exercise_id,
603 etg.exercise_task_id,
604 etg.feedback_json,
605 etg.feedback_text
606FROM exercise_task_gradings etg
607 JOIN exercise_tasks et ON etg.exercise_task_id = et.id
608 JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
609 JOIN exercise_slide_submissions ess ON ets.exercise_slide_submission_id = ess.id
610 JOIN exercises e ON ess.exercise_id = e.id
611 JOIN chapters c ON e.chapter_id = c.id
612WHERE etg.deleted_at IS NULL
613 AND et.deleted_at IS NULL
614 AND et.exercise_type = $2
615 AND ess.user_id = $1
616 AND ess.course_id = $4
617 AND ess.deleted_at IS NULL
618 AND e.deleted_at IS NULL
619 AND c.deleted_at IS NULL
620 AND c.course_module_id = $3
621 "#,
622 user_id,
623 exercise_type,
624 module_id,
625 course_id
626 )
627 .fetch_all(conn)
628 .await?;
629 Ok(res)
630}