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_total_score_given_for_exercise_slide_submission(
169 conn: &mut PgConnection,
170 exercise_slide_submission_id: &Uuid,
171) -> ModelResult<Option<f32>> {
172 let res = sqlx::query!(
173 "
174SELECT SUM(COALESCE(etg.score_given, 0))::real
175FROM exercise_task_gradings etg
176 JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
177WHERE ets.exercise_slide_submission_id = $1
178 AND etg.deleted_at IS NULL
179 AND ets.deleted_at IS NULL
180 ",
181 exercise_slide_submission_id
182 )
183 .fetch_one(conn)
184 .await?;
185 Ok(res.sum)
186}
187
188pub async fn get_point_update_strategy_from_gradings(
190 conn: &mut PgConnection,
191 exercise_slide_submission_id: &Uuid,
192) -> ModelResult<GradingProgress> {
193 let res = sqlx::query!(
194 r#"
195SELECT etg.grading_progress as "grading_progress: GradingProgress"
196FROM exercise_task_gradings etg
197 JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
198WHERE ets.exercise_slide_submission_id = $1
199 AND etg.deleted_at IS NULL
200 AND ets.deleted_at IS NULL
201LIMIT 1
202 "#,
203 exercise_slide_submission_id
204 )
205 .fetch_one(conn)
206 .await?;
207 Ok(res.grading_progress)
208}
209
210pub async fn get_course_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<Option<Uuid>> {
211 let course_id = sqlx::query!(
212 "
213SELECT course_id
214from exercise_task_gradings
215where id = $1
216 ",
217 id
218 )
219 .fetch_one(conn)
220 .await?
221 .course_id;
222 Ok(course_id)
223}
224
225pub async fn get_course_or_exam_id(
226 conn: &mut PgConnection,
227 id: Uuid,
228) -> ModelResult<CourseOrExamId> {
229 let res = sqlx::query!(
230 "
231SELECT course_id,
232 exam_id
233from exercise_task_gradings
234where id = $1
235",
236 id
237 )
238 .fetch_one(conn)
239 .await?;
240 CourseOrExamId::from(res.course_id, res.exam_id)
241}
242
243pub async fn new_grading(
244 conn: &mut PgConnection,
245 exercise: &Exercise,
246 submission: &ExerciseTaskSubmission,
247) -> ModelResult<ExerciseTaskGrading> {
248 let grading = sqlx::query_as!(
249 ExerciseTaskGrading,
250 r#"
251INSERT INTO exercise_task_gradings(
252 exercise_task_submission_id,
253 course_id,
254 exam_id,
255 exercise_id,
256 exercise_task_id,
257 grading_started_at
258 )
259VALUES($1, $2, $3, $4, $5, now())
260RETURNING id,
261 created_at,
262 updated_at,
263 exercise_task_submission_id,
264 course_id,
265 exam_id,
266 exercise_id,
267 exercise_task_id,
268 grading_priority,
269 score_given,
270 grading_progress as "grading_progress: _",
271 unscaled_score_given,
272 unscaled_score_maximum,
273 grading_started_at,
274 grading_completed_at,
275 feedback_json,
276 feedback_text,
277 deleted_at
278"#,
279 submission.id,
280 exercise.course_id,
281 exercise.exam_id,
282 exercise.id,
283 submission.exercise_task_id,
284 )
285 .fetch_one(conn)
286 .await?;
287 Ok(grading)
288}
289
290pub async fn set_grading_progress(
291 conn: &mut PgConnection,
292 id: Uuid,
293 grading_progress: GradingProgress,
294) -> ModelResult<()> {
295 sqlx::query!(
296 "
297UPDATE exercise_task_gradings
298SET grading_progress = $1
299WHERE id = $2
300",
301 grading_progress as GradingProgress,
302 id
303 )
304 .execute(conn)
305 .await?;
306 Ok(())
307}
308
309#[allow(clippy::too_many_arguments)]
310pub async fn grade_submission(
311 conn: &mut PgConnection,
312 submission: &ExerciseTaskSubmission,
313 exercise_task: &ExerciseTask,
314 exercise: &Exercise,
315 grading: &ExerciseTaskGrading,
316 user_exercise_state: &UserExerciseState,
317 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
318 send_grading_request: impl Fn(
319 Url,
320 &ExerciseTask,
321 &ExerciseTaskSubmission,
322 ) -> BoxFuture<'static, ModelResult<ExerciseTaskGradingResult>>,
323) -> ModelResult<ExerciseTaskGrading> {
324 let exercise_service_info =
325 get_service_info_by_exercise_type(conn, &exercise_task.exercise_type, fetch_service_info)
326 .await?;
327 let exercise_service =
328 get_exercise_service_by_exercise_type(conn, &exercise_task.exercise_type).await?;
329 let grade_url = get_internal_grade_url(&exercise_service, &exercise_service_info).await?;
330 let exercise_task_grading_result =
331 send_grading_request(grade_url, exercise_task, submission).await?;
332 let mut tx = conn.begin().await?;
333 let updated_grading =
334 update_grading(&mut tx, grading, &exercise_task_grading_result, exercise).await?;
335 crate::user_course_instance_exercise_service_variables::insert_after_exercise_task_graded(
336 &mut tx,
337 &exercise_task_grading_result.set_user_variables,
338 exercise_task,
339 user_exercise_state,
340 )
341 .await?;
342 tx.commit().await?;
343 Ok(updated_grading)
344}
345
346pub async fn update_grading(
347 conn: &mut PgConnection,
348 grading: &ExerciseTaskGrading,
349 grading_result: &ExerciseTaskGradingResult,
350 exercise: &Exercise,
351) -> ModelResult<ExerciseTaskGrading> {
352 let grading_completed_at = if grading_result.grading_progress.is_complete() {
353 Some(Utc::now())
354 } else {
355 None
356 };
357 let exercise_slide_id = exercise_tasks::get_exercise_task_by_id(conn, grading.exercise_task_id)
358 .await?
359 .exercise_slide_id;
360 let exercise_task_count =
361 exercise_tasks::get_exercise_tasks_by_exercise_slide_ids(conn, &[exercise_slide_id])
362 .await?
363 .len() as f32;
364 let correctness_coefficient =
365 grading_result.score_given / (grading_result.score_maximum as f32);
366 let score_given_with_all_decimals = f32::min(
368 (exercise.score_maximum as f32) * correctness_coefficient / exercise_task_count,
369 exercise.score_maximum as f32 / exercise_task_count,
370 );
371 let score_given_rounded = f32_to_three_decimals(score_given_with_all_decimals);
373 let grading = sqlx::query_as!(
374 ExerciseTaskGrading,
375 r#"
376UPDATE exercise_task_gradings
377SET grading_progress = $2,
378 unscaled_score_given = $3,
379 unscaled_score_maximum = $4,
380 feedback_text = $5,
381 feedback_json = $6,
382 grading_completed_at = $7,
383 score_given = $8
384WHERE id = $1
385RETURNING id,
386 created_at,
387 updated_at,
388 exercise_task_submission_id,
389 course_id,
390 exam_id,
391 exercise_id,
392 exercise_task_id,
393 grading_priority,
394 score_given,
395 grading_progress as "grading_progress: _",
396 unscaled_score_given,
397 unscaled_score_maximum,
398 grading_started_at,
399 grading_completed_at,
400 feedback_json,
401 feedback_text,
402 deleted_at
403"#,
404 grading.id,
405 grading_result.grading_progress as GradingProgress,
406 grading_result.score_given,
407 grading_result.score_maximum,
408 grading_result.feedback_text,
409 grading_result.feedback_json,
410 grading_completed_at,
411 score_given_rounded
412 )
413 .fetch_one(conn)
414 .await?;
415
416 Ok(grading)
417}
418
419pub async fn get_for_student(
422 conn: &mut PgConnection,
423 grading_id: Uuid,
424 user_id: Uuid,
425) -> ModelResult<Option<ExerciseTaskGrading>> {
426 let grading = get_by_id(conn, grading_id).await?;
427 if let Some(exam_id) = grading.exam_id {
428 let exam = exams::get(conn, exam_id).await?;
429 let enrollment = exams::get_enrollment(conn, exam_id, user_id)
430 .await?
431 .ok_or_else(|| {
432 ModelError::new(
433 ModelErrorType::Generic,
434 "User has grading for exam but no enrollment".to_string(),
435 None,
436 )
437 })?;
438 if Utc::now() > enrollment.started_at + chrono::Duration::minutes(exam.time_minutes.into())
439 || exam.ends_at.map(|ea| Utc::now() > ea).unwrap_or_default()
440 {
441 Ok(Some(grading))
443 } else {
444 Ok(None)
446 }
447 } else {
448 Ok(Some(grading))
449 }
450}
451
452pub async fn get_all_gradings_by_exercise_slide_submission_id(
453 conn: &mut PgConnection,
454 exercise_slide_submission_id: Uuid,
455) -> ModelResult<Vec<ExerciseTaskGrading>> {
456 let res = sqlx::query_as!(
457 ExerciseTaskGrading,
458 r#"
459SELECT id,
460created_at,
461updated_at,
462exercise_task_submission_id,
463course_id,
464exam_id,
465exercise_id,
466exercise_task_id,
467grading_priority,
468score_given,
469grading_progress as "grading_progress: _",
470unscaled_score_given,
471unscaled_score_maximum,
472grading_started_at,
473grading_completed_at,
474feedback_json,
475feedback_text,
476deleted_at
477FROM exercise_task_gradings
478WHERE deleted_at IS NULL
479 AND exercise_task_submission_id IN (
480 SELECT id
481 FROM exercise_task_submissions
482 WHERE exercise_slide_submission_id = $1
483 )
484"#,
485 exercise_slide_submission_id
486 )
487 .fetch_all(&mut *conn)
488 .await?;
489 Ok(res)
490}
491
492pub async fn get_new_and_old_exercise_task_gradings_by_regrading_id(
493 conn: &mut PgConnection,
494 regrading_id: Uuid,
495) -> ModelResult<HashMap<Uuid, ExerciseTaskGrading>> {
496 let res = sqlx::query_as!(
497 ExerciseTaskGrading,
498 r#"
499SELECT id,
500 created_at,
501 updated_at,
502 exercise_task_submission_id,
503 course_id,
504 exam_id,
505 exercise_id,
506 exercise_task_id,
507 grading_priority,
508 score_given,
509 grading_progress as "grading_progress: _",
510 unscaled_score_given,
511 unscaled_score_maximum,
512 grading_started_at,
513 grading_completed_at,
514 feedback_json,
515 feedback_text,
516 deleted_at
517FROM exercise_task_gradings
518WHERE deleted_at IS NULL
519 AND id IN (
520 SELECT etrs.grading_before_regrading
521 FROM exercise_task_regrading_submissions etrs
522 WHERE etrs.deleted_at IS NULL
523 AND etrs.regrading_id = $1
524 UNION
525 SELECT etrs.grading_after_regrading
526 FROM exercise_task_regrading_submissions etrs
527 WHERE etrs.grading_after_regrading IS NOT NULL
528 AND etrs.deleted_at IS NULL
529 AND etrs.regrading_id = $1
530 );
531 "#,
532 regrading_id
533 )
534 .fetch_all(conn)
535 .await?;
536 let mut map = HashMap::with_capacity(res.len());
537 for regrading in res {
538 map.insert(regrading.id, regrading);
539 }
540 Ok(map)
541}
542
543pub async fn get_user_exercise_task_gradings_by_module_and_exercise_type(
545 conn: &mut PgConnection,
546 user_id: Uuid,
547 exercise_type: &str,
548 module_id: Uuid,
549 course_instance_id: Uuid,
550) -> ModelResult<Vec<CustomViewExerciseTaskGrading>> {
551 let res: Vec<CustomViewExerciseTaskGrading> = sqlx::query_as!(
552 CustomViewExerciseTaskGrading,
553 r#"
554SELECT etg.id,
555 etg.created_at,
556 etg.exercise_id,
557 etg.exercise_task_id,
558 etg.feedback_json,
559 etg.feedback_text
560FROM exercise_task_gradings etg
561 JOIN exercise_tasks et ON etg.exercise_task_id = et.id
562 JOIN exercise_task_submissions ets ON etg.exercise_task_submission_id = ets.id
563 JOIN exercise_slide_submissions ess ON ets.exercise_slide_submission_id = ess.id
564 JOIN exercises e ON ess.exercise_id = e.id
565 JOIN chapters c ON e.chapter_id = c.id
566WHERE etg.deleted_at IS NULL
567 AND et.deleted_at IS NULL
568 AND et.exercise_type = $2
569 AND ess.user_id = $1
570 AND ess.course_instance_id = $4
571 AND ess.deleted_at IS NULL
572 AND e.deleted_at IS NULL
573 AND c.deleted_at IS NULL
574 AND c.course_module_id = $3
575 "#,
576 user_id,
577 exercise_type,
578 module_id,
579 course_instance_id
580 )
581 .fetch_all(conn)
582 .await?;
583 Ok(res)
584}