1use std::collections::{HashMap, HashSet};
2
3use futures::{Stream, TryStreamExt, future::BoxFuture};
4
5use headless_lms_utils::document_schema_processor::GutenbergBlock;
6use url::Url;
7use utoipa::ToSchema;
8
9use crate::{
10 CourseOrExamId,
11 exercise_service_info::{self, ExerciseServiceInfoApi},
12 exercise_services,
13 exercise_slides::{self, CourseMaterialExerciseSlide},
14 exercise_task_gradings::{self, ExerciseTaskGrading},
15 exercise_task_submissions::{self, ExerciseTaskSubmission},
16 library::custom_view_exercises::CustomViewExerciseTaskSpec,
17 prelude::*,
18 user_exercise_states,
19};
20
21#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
23
24pub struct CourseMaterialExerciseTask {
25 pub id: Uuid,
26 pub exercise_service_slug: String,
27 pub exercise_slide_id: Uuid,
28 pub exercise_iframe_url: Option<String>,
33 pub pseudonumous_user_id: Option<Uuid>,
38 pub assignment: serde_json::Value,
39 pub public_spec: Option<serde_json::Value>,
40 pub model_solution_spec: Option<serde_json::Value>,
41 pub previous_submission: Option<ExerciseTaskSubmission>,
42 pub previous_submission_grading: Option<ExerciseTaskGrading>,
43 pub order_number: i32,
44 pub deleted_at: Option<DateTime<Utc>>,
45}
46
47#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
48pub struct NewExerciseTask {
49 pub exercise_slide_id: Uuid,
50 pub exercise_type: String,
51 pub assignment: Vec<GutenbergBlock>,
52 pub public_spec: Option<serde_json::Value>,
53 pub private_spec: Option<serde_json::Value>,
54 pub model_solution_spec: Option<serde_json::Value>,
55 pub order_number: i32,
56}
57
58pub struct ExerciseTaskSpec {
59 pub id: Uuid,
60 pub created_at: DateTime<Utc>,
61 pub updated_at: DateTime<Utc>,
62 pub exercise_type: String,
63 pub private_spec: Option<serde_json::Value>,
64 pub exercise_name: String,
65 pub course_module_id: Uuid,
66 pub course_module_name: Option<String>,
67}
68
69#[derive(Debug, Serialize, Deserialize, FromRow, PartialEq, Clone)]
70
71pub struct ExerciseTask {
72 pub id: Uuid,
73 pub created_at: DateTime<Utc>,
74 pub updated_at: DateTime<Utc>,
75 pub exercise_slide_id: Uuid,
76 pub exercise_type: String,
77 pub assignment: serde_json::Value,
78 pub deleted_at: Option<DateTime<Utc>>,
79 pub public_spec: Option<serde_json::Value>,
80 pub private_spec: Option<serde_json::Value>,
81 pub model_solution_spec: Option<serde_json::Value>,
82 pub copied_from: Option<Uuid>,
83 pub order_number: i32,
84}
85
86impl FromIterator<ExerciseTask> for HashMap<Uuid, ExerciseTask> {
87 fn from_iter<I: IntoIterator<Item = ExerciseTask>>(iter: I) -> Self {
88 let mut map = HashMap::new();
89 map.extend(iter);
90 map
91 }
92}
93
94impl Extend<ExerciseTask> for HashMap<Uuid, ExerciseTask> {
95 fn extend<T: IntoIterator<Item = ExerciseTask>>(&mut self, iter: T) {
96 for exercise_task in iter {
97 self.insert(exercise_task.id, exercise_task);
98 }
99 }
100}
101
102pub async fn insert(
103 conn: &mut PgConnection,
104 pkey_policy: PKeyPolicy<Uuid>,
105 new_exercise_task: NewExerciseTask,
106) -> ModelResult<Uuid> {
107 let res = sqlx::query!(
108 "
109INSERT INTO exercise_tasks (
110 id,
111 exercise_slide_id,
112 exercise_type,
113 assignment,
114 private_spec,
115 public_spec,
116 model_solution_spec,
117 order_number
118 )
119VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
120RETURNING id
121 ",
122 pkey_policy.into_uuid(),
123 new_exercise_task.exercise_slide_id,
124 new_exercise_task.exercise_type,
125 serde_json::to_value(new_exercise_task.assignment)?,
126 new_exercise_task.private_spec,
127 new_exercise_task.public_spec,
128 new_exercise_task.model_solution_spec,
129 new_exercise_task.order_number,
130 )
131 .fetch_one(conn)
132 .await?;
133 Ok(res.id)
134}
135
136pub async fn get_course_or_exam_id(
137 conn: &mut PgConnection,
138 id: Uuid,
139) -> ModelResult<CourseOrExamId> {
140 let res = sqlx::query!(
141 "
142SELECT
143 course_id,
144 exam_id
145FROM exercises
146WHERE id = (
147 SELECT s.exercise_id
148 FROM exercise_slides s
149 JOIN exercise_tasks t ON (s.id = t.exercise_slide_id)
150 WHERE s.deleted_at IS NULL
151 AND t.id = $1
152 AND t.deleted_at IS NULL
153 )
154",
155 id
156 )
157 .fetch_one(conn)
158 .await?;
159 CourseOrExamId::from_course_and_exam_ids(res.course_id, res.exam_id)
160}
161
162pub async fn get_exercise_task_by_id(
163 conn: &mut PgConnection,
164 id: Uuid,
165) -> ModelResult<ExerciseTask> {
166 let exercise_task = sqlx::query_as!(
167 ExerciseTask,
168 "SELECT * FROM exercise_tasks WHERE id = $1;",
169 id
170 )
171 .fetch_one(conn)
172 .await?;
173 Ok(exercise_task)
174}
175
176pub async fn get_course_material_exercise_tasks(
177 conn: &mut PgConnection,
178 exercise_slide_id: Uuid,
179 user_id: Option<Uuid>,
180 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
181 file_store: &dyn FileStore,
182 app_conf: &ApplicationConfiguration,
183) -> ModelResult<Vec<CourseMaterialExerciseTask>> {
184 let exercise_tasks: Vec<ExerciseTask> =
185 get_exercise_tasks_by_exercise_slide_id(conn, &exercise_slide_id).await?;
186 let mut latest_submissions_by_task_id = if let Some(user_id) = user_id {
187 exercise_task_submissions::get_users_latest_exercise_task_submissions_for_exercise_slide(
188 conn,
189 exercise_slide_id,
190 user_id,
191 file_store,
192 app_conf,
193 )
194 .await?
195 .unwrap_or_default()
196 .into_iter()
197 .map(|s| (s.exercise_task_id, s))
198 .collect()
199 } else {
200 HashMap::new()
201 };
202
203 let unique_exercise_service_slugs = exercise_tasks
204 .iter()
205 .cloned()
206 .map(|et| et.exercise_type)
207 .collect::<HashSet<_>>()
208 .into_iter()
209 .collect::<Vec<_>>();
210 let exercise_service_slug_to_service_and_info =
211 exercise_service_info::get_selected_exercise_services_by_type(
212 &mut *conn,
213 &unique_exercise_service_slugs,
214 fetch_service_info,
215 )
216 .await?;
217
218 let mut material_tasks = Vec::with_capacity(exercise_tasks.len());
219 for exercise_task in exercise_tasks.into_iter() {
220 let model_solution_spec = exercise_task.model_solution_spec;
221 let previous_submission = latest_submissions_by_task_id.remove(&exercise_task.id);
222 let previous_submission_grading = if let Some(submission) = previous_submission.as_ref() {
223 exercise_task_gradings::get_by_exercise_task_submission_id(conn, submission.id).await?
224 } else {
225 None
226 };
227
228 let (exercise_service, service_info) = exercise_service_slug_to_service_and_info
229 .get(&exercise_task.exercise_type)
230 .ok_or_else(|| {
231 ModelError::new(
232 ModelErrorType::InvalidRequest,
233 "Exercise service not found".to_string(),
234 None,
235 )
236 })?;
237 let mut exercise_iframe_url =
238 exercise_services::get_exercise_service_externally_preferred_baseurl(exercise_service)?;
239 exercise_iframe_url.set_path(&service_info.user_interface_iframe_path);
240
241 material_tasks.push(CourseMaterialExerciseTask {
242 id: exercise_task.id,
243 exercise_service_slug: exercise_task.exercise_type,
244 exercise_slide_id: exercise_task.exercise_slide_id,
245 exercise_iframe_url: Some(exercise_iframe_url.to_string()),
246 pseudonumous_user_id: user_id
247 .map(|uid| Uuid::new_v5(&service_info.exercise_service_id, uid.as_bytes())),
248 assignment: exercise_task.assignment,
249 public_spec: exercise_task.public_spec,
250 model_solution_spec,
251 previous_submission,
252 previous_submission_grading,
253 order_number: exercise_task.order_number,
254 deleted_at: exercise_task.deleted_at,
255 });
256 }
257 Ok(material_tasks)
258}
259
260pub async fn get_exercise_tasks_by_exercise_slide_id<T>(
261 conn: &mut PgConnection,
262 exercise_slide_id: &Uuid,
263) -> ModelResult<T>
264where
265 T: Default + Extend<ExerciseTask> + FromIterator<ExerciseTask>,
266{
267 let res = sqlx::query_as!(
268 ExerciseTask,
269 "
270SELECT *
271FROM exercise_tasks
272WHERE exercise_slide_id = $1
273AND deleted_at IS NULL;
274 ",
275 exercise_slide_id,
276 )
277 .fetch(conn)
278 .try_collect()
279 .await?;
280 Ok(res)
281}
282
283pub async fn get_exercise_tasks_by_exercise_slide_id_including_deleted<T>(
284 conn: &mut PgConnection,
285 exercise_slide_id: &Uuid,
286) -> ModelResult<T>
287where
288 T: Default + Extend<ExerciseTask> + FromIterator<ExerciseTask>,
289{
290 let res = sqlx::query_as!(
291 ExerciseTask,
292 "
293SELECT *
294FROM exercise_tasks
295WHERE exercise_slide_id = $1
296 ",
297 exercise_slide_id,
298 )
299 .fetch(conn)
300 .try_collect()
301 .await?;
302 Ok(res)
303}
304
305pub async fn get_exercise_tasks_by_exercise_slide_ids(
306 conn: &mut PgConnection,
307 exercise_slide_ids: &[Uuid],
308) -> ModelResult<Vec<ExerciseTask>> {
309 let exercise_tasks = sqlx::query_as!(
310 ExerciseTask,
311 "
312SELECT *
313FROM exercise_tasks
314WHERE exercise_slide_id = ANY($1)
315 AND deleted_at IS NULL;
316 ",
317 exercise_slide_ids,
318 )
319 .fetch_all(conn)
320 .await?;
321 Ok(exercise_tasks)
322}
323
324pub async fn get_existing_users_exercise_slide_for_course(
326 conn: &mut PgConnection,
327 user_id: Uuid,
328 exercise_id: Uuid,
329 course_id: Uuid,
330 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
331 file_store: &dyn FileStore,
332 app_conf: &ApplicationConfiguration,
333) -> ModelResult<Option<CourseMaterialExerciseSlide>> {
334 let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
335 conn,
336 user_id,
337 exercise_id,
338 CourseOrExamId::Course(course_id),
339 )
340 .await?;
341 let exercise_tasks = if let Some(user_exercise_state) = user_exercise_state {
342 if let Some(selected_exercise_slide_id) = user_exercise_state.selected_exercise_slide_id {
343 let exercise_tasks = get_course_material_exercise_tasks(
344 conn,
345 selected_exercise_slide_id,
346 Some(user_id),
347 fetch_service_info,
348 file_store,
349 app_conf,
350 )
351 .await?;
352 Some(CourseMaterialExerciseSlide {
353 id: selected_exercise_slide_id,
354 exercise_tasks,
355 })
356 } else {
357 None
358 }
359 } else {
360 None
361 };
362 Ok(exercise_tasks)
363}
364
365pub async fn get_or_select_user_exercise_slide_for_course_or_exam(
367 conn: &mut PgConnection,
368 user_id: Uuid,
369 exercise_id: Uuid,
370 course_or_exam_id: CourseOrExamId,
371 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
372 file_store: &dyn FileStore,
373 app_conf: &ApplicationConfiguration,
374) -> ModelResult<CourseMaterialExerciseSlide> {
375 let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
376 let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
377 conn,
378 user_id,
379 exercise_id,
380 course_id,
381 exam_id,
382 )
383 .await?;
384 info!("statestate {:#?}", user_exercise_state);
385 let selected_exercise_slide_id =
386 if let Some(selected_exercise_slide_id) = user_exercise_state.selected_exercise_slide_id {
387 info!("found {}", selected_exercise_slide_id);
388 selected_exercise_slide_id
389 } else {
390 info!("random");
391 let exercise_slide_id =
392 exercise_slides::get_random_exercise_slide_for_exercise(conn, exercise_id)
393 .await?
394 .id;
395 user_exercise_states::upsert_selected_exercise_slide_id(
396 conn,
397 user_id,
398 exercise_id,
399 course_id,
400 exam_id,
401 Some(exercise_slide_id),
402 )
403 .await?;
404 exercise_slide_id
405 };
406
407 let exercise_tasks = get_course_material_exercise_tasks(
408 conn,
409 selected_exercise_slide_id,
410 Some(user_id),
411 fetch_service_info,
412 file_store,
413 app_conf,
414 )
415 .await?;
416 info!("got tasks");
417 if exercise_tasks.is_empty() {
418 return Err(ModelError::new(
419 ModelErrorType::PreconditionFailed,
420 "Missing exercise definition.".to_string(),
421 None,
422 ));
423 }
424
425 Ok(CourseMaterialExerciseSlide {
426 id: selected_exercise_slide_id,
427 exercise_tasks,
428 })
429}
430
431pub async fn get_exercise_tasks_by_exercise_id(
432 conn: &mut PgConnection,
433 exercise_id: Uuid,
434) -> ModelResult<Vec<ExerciseTask>> {
435 let exercise_tasks = sqlx::query_as!(
436 ExerciseTask,
437 "
438SELECT t.*
439FROM exercise_tasks t
440 JOIN exercise_slides s ON (t.exercise_slide_id = s.id)
441WHERE s.exercise_id = $1
442 AND s.deleted_at IS NULL
443 AND t.deleted_at IS NULL;
444 ",
445 exercise_id
446 )
447 .fetch_all(conn)
448 .await?;
449 Ok(exercise_tasks)
450}
451
452pub async fn delete_exercise_tasks_by_slide_ids(
453 conn: &mut PgConnection,
454 exercise_slide_ids: &[Uuid],
455) -> ModelResult<Vec<Uuid>> {
456 let deleted_ids = sqlx::query!(
457 "
458UPDATE exercise_tasks
459SET deleted_at = now()
460WHERE exercise_slide_id = ANY($1)
461AND deleted_at IS NULL
462RETURNING id;
463 ",
464 &exercise_slide_ids,
465 )
466 .fetch_all(conn)
467 .await?
468 .into_iter()
469 .map(|x| x.id)
470 .collect();
471 Ok(deleted_ids)
472}
473
474pub async fn get_exercise_task_model_solution_spec_by_id(
475 conn: &mut PgConnection,
476 exercise_task_id: Uuid,
477) -> ModelResult<Option<serde_json::Value>> {
478 let exercise_task = sqlx::query_as!(
479 ExerciseTask,
480 "
481SELECT *
482FROM exercise_tasks et
483WHERE et.id = $1;
484 ",
485 exercise_task_id
486 )
487 .fetch_one(conn)
488 .await?;
489 Ok(exercise_task.model_solution_spec)
490}
491
492pub async fn get_all_exercise_tas_by_exercise_slide_submission_id(
493 conn: &mut PgConnection,
494 exercise_slide_submission_id: Uuid,
495) -> ModelResult<Vec<ExerciseTaskGrading>> {
496 let res = sqlx::query_as!(
497 ExerciseTaskGrading,
498 r#"
499SELECT id,
500created_at,
501updated_at,
502exercise_task_submission_id,
503course_id,
504exam_id,
505exercise_id,
506exercise_task_id,
507grading_priority,
508score_given,
509grading_progress,
510unscaled_score_given,
511unscaled_score_maximum,
512grading_started_at,
513grading_completed_at,
514feedback_json,
515feedback_text,
516deleted_at
517FROM exercise_task_gradings
518WHERE deleted_at IS NULL
519 AND exercise_task_submission_id IN (
520 SELECT id
521 FROM exercise_task_submissions
522 WHERE exercise_slide_submission_id = $1
523 )
524"#,
525 exercise_slide_submission_id
526 )
527 .fetch_all(&mut *conn)
528 .await?;
529 Ok(res)
530}
531
532pub async fn get_all_exercise_tasks_by_module_and_exercise_type(
533 conn: &mut PgConnection,
534 exercise_type: &str,
535 module_id: Uuid,
536) -> ModelResult<Vec<CustomViewExerciseTaskSpec>> {
537 let res: Vec<CustomViewExerciseTaskSpec> = sqlx::query_as!(
538 CustomViewExerciseTaskSpec,
539 r#"
540 SELECT distinct (et.id),
541 et.public_spec,
542 et.order_number
543 FROM exercise_tasks et
544 JOIN exercise_slides es ON es.id = et.exercise_slide_id
545 JOIN exercises e ON es.exercise_id = e.id JOIN chapters c ON e.chapter_id = c.id
546 WHERE et.exercise_type = $1 AND c.course_module_id = $2
547 AND et.deleted_at IS NULL
548 AND es.deleted_at IS NULL
549 AND e.deleted_at IS NULL
550 AND c.deleted_at IS NULL;
551 "#,
552 exercise_type,
553 module_id
554 )
555 .fetch_all(conn)
556 .await?;
557 Ok(res)
558}
559
560pub fn stream_course_exercise_tasks(
561 conn: &mut PgConnection,
562 course_id: Uuid,
563) -> impl Stream<Item = sqlx::Result<ExerciseTaskSpec>> + '_ {
564 sqlx::query_as!(
565 ExerciseTaskSpec,
566 r#"
567 SELECT distinct (t.id),
568 t.created_at,
569 t.updated_at,
570 t.exercise_type,
571 t.private_spec,
572 e.name as exercise_name,
573 mod.id as course_module_id,
574 mod.name as course_module_name
575 FROM exercise_tasks t
576 JOIN exercise_slides s
577 ON s.id = t.exercise_slide_id
578 JOIN exercises e
579 ON s.exercise_id = e.id
580 JOIN chapters ch ON e.chapter_id = ch.id
581 JOIN course_modules mod ON mod.id = ch.course_module_id
582 WHERE e.course_id = $1
583 AND e.deleted_at IS NULL
584 AND s.deleted_at IS NULL
585 AND t.deleted_at IS NULL
586 AND ch.deleted_at IS NULL
587 AND mod.deleted_at IS NULL;
588 "#,
589 course_id
590 )
591 .fetch(conn)
592}