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