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