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