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