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 score_given_with_all_decimals = (exercise.score_maximum as f32)
375 * correctness_coefficient(grading_result.score_given, grading_result.score_maximum)
376 / exercise_task_count;
377 let score_given_rounded = f32_to_three_decimals(score_given_with_all_decimals);
379 let grading = sqlx::query_as!(
380 ExerciseTaskGrading,
381 r#"
382UPDATE exercise_task_gradings
383SET grading_progress = $2,
384 unscaled_score_given = $3,
385 unscaled_score_maximum = $4,
386 feedback_text = $5,
387 feedback_json = $6,
388 grading_completed_at = $7,
389 score_given = $8
390WHERE id = $1
391RETURNING *
392"#,
393 grading.id,
394 grading_result.grading_progress as GradingProgress,
395 grading_result.score_given,
396 grading_result.score_maximum,
397 grading_result.feedback_text,
398 grading_result.feedback_json,
399 grading_completed_at,
400 score_given_rounded
401 )
402 .fetch_one(conn)
403 .await?;
404
405 Ok(grading)
406}
407
408fn correctness_coefficient(score_given: f32, score_maximum: i32) -> f32 {
413 if score_maximum <= 0 || !score_given.is_finite() {
414 return 0.0;
415 }
416 (score_given / score_maximum as f32).clamp(0.0, 1.0)
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,
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,
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_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_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_id
580 )
581 .fetch_all(conn)
582 .await?;
583 Ok(res)
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
591 fn a_zero_maximum_earns_nothing() {
592 assert_eq!(correctness_coefficient(0.0, 0), 0.0);
593 assert_eq!(correctness_coefficient(3.0, 0), 0.0);
594 }
595
596 #[test]
597 fn a_partial_score_earns_its_share() {
598 assert_eq!(correctness_coefficient(1.0, 4), 0.25);
599 }
600
601 #[test]
602 fn the_share_stays_between_nothing_and_everything() {
603 assert_eq!(correctness_coefficient(5.0, 4), 1.0);
604 assert_eq!(correctness_coefficient(-1.0, 4), 0.0);
605 }
606}