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 fn pseudonymous_user_id(exercise_service_id: &Uuid, user_id: &Uuid) -> Uuid {
178 Uuid::new_v5(exercise_service_id, user_id.as_bytes())
179}
180
181pub async fn get_course_material_exercise_tasks(
182 conn: &mut PgConnection,
183 exercise_slide_id: Uuid,
184 user_id: Option<Uuid>,
185 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
186 file_store: &dyn FileStore,
187 app_conf: &ApplicationConfiguration,
188) -> ModelResult<Vec<CourseMaterialExerciseTask>> {
189 let exercise_tasks: Vec<ExerciseTask> =
190 get_exercise_tasks_by_exercise_slide_id(conn, &exercise_slide_id).await?;
191 let mut latest_submissions_by_task_id = if let Some(user_id) = user_id {
192 exercise_task_submissions::get_users_latest_exercise_task_submissions_for_exercise_slide(
193 conn,
194 exercise_slide_id,
195 user_id,
196 file_store,
197 app_conf,
198 )
199 .await?
200 .unwrap_or_default()
201 .into_iter()
202 .map(|s| (s.exercise_task_id, s))
203 .collect()
204 } else {
205 HashMap::new()
206 };
207
208 let unique_exercise_service_slugs = exercise_tasks
209 .iter()
210 .cloned()
211 .map(|et| et.exercise_type)
212 .collect::<HashSet<_>>()
213 .into_iter()
214 .collect::<Vec<_>>();
215 let exercise_service_slug_to_service_and_info =
216 exercise_service_info::get_selected_exercise_services_by_type(
217 &mut *conn,
218 &unique_exercise_service_slugs,
219 fetch_service_info,
220 )
221 .await?;
222
223 let mut material_tasks = Vec::with_capacity(exercise_tasks.len());
224 for exercise_task in exercise_tasks.into_iter() {
225 let model_solution_spec = exercise_task.model_solution_spec;
226 let previous_submission = latest_submissions_by_task_id.remove(&exercise_task.id);
227 let previous_submission_grading = if let Some(submission) = previous_submission.as_ref() {
228 exercise_task_gradings::get_by_exercise_task_submission_id(conn, submission.id).await?
229 } else {
230 None
231 };
232
233 let (exercise_service, service_info) = exercise_service_slug_to_service_and_info
234 .get(&exercise_task.exercise_type)
235 .ok_or_else(|| {
236 ModelError::new(
237 ModelErrorType::InvalidRequest,
238 "Exercise service not found".to_string(),
239 None,
240 )
241 })?;
242 let mut exercise_iframe_url =
243 exercise_services::get_exercise_service_externally_preferred_baseurl(exercise_service)?;
244 exercise_iframe_url.set_path(&service_info.user_interface_iframe_path);
245
246 material_tasks.push(CourseMaterialExerciseTask {
247 id: exercise_task.id,
248 exercise_service_slug: exercise_task.exercise_type,
249 exercise_slide_id: exercise_task.exercise_slide_id,
250 exercise_iframe_url: Some(exercise_iframe_url.to_string()),
251 pseudonumous_user_id: user_id
252 .map(|uid| pseudonymous_user_id(&service_info.exercise_service_id, &uid)),
253 assignment: exercise_task.assignment,
254 public_spec: exercise_task.public_spec,
255 model_solution_spec,
256 previous_submission,
257 previous_submission_grading,
258 order_number: exercise_task.order_number,
259 deleted_at: exercise_task.deleted_at,
260 });
261 }
262 Ok(material_tasks)
263}
264
265pub async fn get_exercise_tasks_by_exercise_slide_id<T>(
266 conn: &mut PgConnection,
267 exercise_slide_id: &Uuid,
268) -> ModelResult<T>
269where
270 T: Default + Extend<ExerciseTask> + FromIterator<ExerciseTask>,
271{
272 let res = sqlx::query_as!(
273 ExerciseTask,
274 "
275SELECT *
276FROM exercise_tasks
277WHERE exercise_slide_id = $1
278AND deleted_at IS NULL;
279 ",
280 exercise_slide_id,
281 )
282 .fetch(conn)
283 .try_collect()
284 .await?;
285 Ok(res)
286}
287
288pub async fn get_exercise_tasks_by_exercise_slide_id_including_deleted<T>(
289 conn: &mut PgConnection,
290 exercise_slide_id: &Uuid,
291) -> ModelResult<T>
292where
293 T: Default + Extend<ExerciseTask> + FromIterator<ExerciseTask>,
294{
295 let res = sqlx::query_as!(
296 ExerciseTask,
297 "
298SELECT *
299FROM exercise_tasks
300WHERE exercise_slide_id = $1
301 ",
302 exercise_slide_id,
303 )
304 .fetch(conn)
305 .try_collect()
306 .await?;
307 Ok(res)
308}
309
310pub async fn get_exercise_tasks_by_exercise_slide_ids(
311 conn: &mut PgConnection,
312 exercise_slide_ids: &[Uuid],
313) -> ModelResult<Vec<ExerciseTask>> {
314 let exercise_tasks = sqlx::query_as!(
315 ExerciseTask,
316 "
317SELECT *
318FROM exercise_tasks
319WHERE exercise_slide_id = ANY($1)
320 AND deleted_at IS NULL;
321 ",
322 exercise_slide_ids,
323 )
324 .fetch_all(conn)
325 .await?;
326 Ok(exercise_tasks)
327}
328
329pub async fn get_existing_users_exercise_slide_for_course(
331 conn: &mut PgConnection,
332 user_id: Uuid,
333 exercise_id: Uuid,
334 course_id: Uuid,
335 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
336 file_store: &dyn FileStore,
337 app_conf: &ApplicationConfiguration,
338) -> ModelResult<Option<CourseMaterialExerciseSlide>> {
339 let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
340 conn,
341 user_id,
342 exercise_id,
343 CourseOrExamId::Course(course_id),
344 )
345 .await?;
346 let exercise_tasks = if let Some(user_exercise_state) = user_exercise_state {
347 if let Some(selected_exercise_slide_id) = user_exercise_state.selected_exercise_slide_id {
348 let exercise_tasks = get_course_material_exercise_tasks(
349 conn,
350 selected_exercise_slide_id,
351 Some(user_id),
352 fetch_service_info,
353 file_store,
354 app_conf,
355 )
356 .await?;
357 Some(CourseMaterialExerciseSlide {
358 id: selected_exercise_slide_id,
359 exercise_tasks,
360 })
361 } else {
362 None
363 }
364 } else {
365 None
366 };
367 Ok(exercise_tasks)
368}
369
370pub async fn get_or_select_user_exercise_slide_for_course_or_exam(
372 conn: &mut PgConnection,
373 user_id: Uuid,
374 exercise_id: Uuid,
375 course_or_exam_id: CourseOrExamId,
376 fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
377 file_store: &dyn FileStore,
378 app_conf: &ApplicationConfiguration,
379) -> ModelResult<CourseMaterialExerciseSlide> {
380 let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
381 let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
382 conn,
383 user_id,
384 exercise_id,
385 course_id,
386 exam_id,
387 )
388 .await?;
389 info!("statestate {:#?}", user_exercise_state);
390 let selected_exercise_slide_id =
391 if let Some(selected_exercise_slide_id) = user_exercise_state.selected_exercise_slide_id {
392 info!("found {}", selected_exercise_slide_id);
393 selected_exercise_slide_id
394 } else {
395 info!("random");
396 let exercise_slide_id =
397 exercise_slides::get_random_exercise_slide_for_exercise(conn, exercise_id)
398 .await?
399 .id;
400 user_exercise_states::upsert_selected_exercise_slide_id(
401 conn,
402 user_id,
403 exercise_id,
404 course_id,
405 exam_id,
406 Some(exercise_slide_id),
407 )
408 .await?;
409 exercise_slide_id
410 };
411
412 let exercise_tasks = get_course_material_exercise_tasks(
413 conn,
414 selected_exercise_slide_id,
415 Some(user_id),
416 fetch_service_info,
417 file_store,
418 app_conf,
419 )
420 .await?;
421 info!("got tasks");
422 if exercise_tasks.is_empty() {
423 return Err(ModelError::new(
424 ModelErrorType::PreconditionFailed,
425 "Missing exercise definition.".to_string(),
426 None,
427 ));
428 }
429
430 Ok(CourseMaterialExerciseSlide {
431 id: selected_exercise_slide_id,
432 exercise_tasks,
433 })
434}
435
436pub async fn get_exercise_tasks_by_exercise_id(
437 conn: &mut PgConnection,
438 exercise_id: Uuid,
439) -> ModelResult<Vec<ExerciseTask>> {
440 let exercise_tasks = sqlx::query_as!(
441 ExerciseTask,
442 "
443SELECT t.*
444FROM exercise_tasks t
445 JOIN exercise_slides s ON (t.exercise_slide_id = s.id)
446WHERE s.exercise_id = $1
447 AND s.deleted_at IS NULL
448 AND t.deleted_at IS NULL;
449 ",
450 exercise_id
451 )
452 .fetch_all(conn)
453 .await?;
454 Ok(exercise_tasks)
455}
456
457pub async fn delete_exercise_tasks_by_slide_ids(
458 conn: &mut PgConnection,
459 exercise_slide_ids: &[Uuid],
460) -> ModelResult<Vec<Uuid>> {
461 let deleted_ids = sqlx::query!(
462 "
463UPDATE exercise_tasks
464SET deleted_at = now()
465WHERE exercise_slide_id = ANY($1)
466AND deleted_at IS NULL
467RETURNING id;
468 ",
469 &exercise_slide_ids,
470 )
471 .fetch_all(conn)
472 .await?
473 .into_iter()
474 .map(|x| x.id)
475 .collect();
476 Ok(deleted_ids)
477}
478
479pub async fn get_exercise_task_model_solution_spec_by_id(
480 conn: &mut PgConnection,
481 exercise_task_id: Uuid,
482) -> ModelResult<Option<serde_json::Value>> {
483 let exercise_task = sqlx::query_as!(
484 ExerciseTask,
485 "
486SELECT *
487FROM exercise_tasks et
488WHERE et.id = $1;
489 ",
490 exercise_task_id
491 )
492 .fetch_one(conn)
493 .await?;
494 Ok(exercise_task.model_solution_spec)
495}
496
497pub async fn get_all_exercise_tas_by_exercise_slide_submission_id(
498 conn: &mut PgConnection,
499 exercise_slide_submission_id: Uuid,
500) -> ModelResult<Vec<ExerciseTaskGrading>> {
501 let res = sqlx::query_as!(
502 ExerciseTaskGrading,
503 r#"
504SELECT id,
505created_at,
506updated_at,
507exercise_task_submission_id,
508course_id,
509exam_id,
510exercise_id,
511exercise_task_id,
512grading_priority,
513score_given,
514grading_progress,
515unscaled_score_given,
516unscaled_score_maximum,
517grading_started_at,
518grading_completed_at,
519feedback_json,
520feedback_text,
521deleted_at
522FROM exercise_task_gradings
523WHERE deleted_at IS NULL
524 AND exercise_task_submission_id IN (
525 SELECT id
526 FROM exercise_task_submissions
527 WHERE exercise_slide_submission_id = $1
528 )
529"#,
530 exercise_slide_submission_id
531 )
532 .fetch_all(&mut *conn)
533 .await?;
534 Ok(res)
535}
536
537pub async fn get_all_exercise_tasks_by_module_and_exercise_type(
538 conn: &mut PgConnection,
539 exercise_type: &str,
540 module_id: Uuid,
541) -> ModelResult<Vec<CustomViewExerciseTaskSpec>> {
542 let res: Vec<CustomViewExerciseTaskSpec> = sqlx::query_as!(
543 CustomViewExerciseTaskSpec,
544 r#"
545 SELECT distinct (et.id),
546 et.public_spec,
547 et.order_number
548 FROM exercise_tasks et
549 JOIN exercise_slides es ON es.id = et.exercise_slide_id
550 JOIN exercises e ON es.exercise_id = e.id JOIN chapters c ON e.chapter_id = c.id
551 WHERE et.exercise_type = $1 AND c.course_module_id = $2
552 AND et.deleted_at IS NULL
553 AND es.deleted_at IS NULL
554 AND e.deleted_at IS NULL
555 AND c.deleted_at IS NULL;
556 "#,
557 exercise_type,
558 module_id
559 )
560 .fetch_all(conn)
561 .await?;
562 Ok(res)
563}
564
565pub fn stream_course_exercise_tasks(
566 conn: &mut PgConnection,
567 course_id: Uuid,
568) -> impl Stream<Item = sqlx::Result<ExerciseTaskSpec>> + '_ {
569 sqlx::query_as!(
570 ExerciseTaskSpec,
571 r#"
572 SELECT distinct (t.id),
573 t.created_at,
574 t.updated_at,
575 t.exercise_type,
576 t.private_spec,
577 e.name as exercise_name,
578 mod.id as course_module_id,
579 mod.name as course_module_name
580 FROM exercise_tasks t
581 JOIN exercise_slides s
582 ON s.id = t.exercise_slide_id
583 JOIN exercises e
584 ON s.exercise_id = e.id
585 JOIN chapters ch ON e.chapter_id = ch.id
586 JOIN course_modules mod ON mod.id = ch.course_module_id
587 WHERE e.course_id = $1
588 AND e.deleted_at IS NULL
589 AND s.deleted_at IS NULL
590 AND t.deleted_at IS NULL
591 AND ch.deleted_at IS NULL
592 AND mod.deleted_at IS NULL;
593 "#,
594 course_id
595 )
596 .fetch(conn)
597}