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