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