Skip to main content

headless_lms_models/
exercise_tasks.rs

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/// Information necessary for the frontend to render an exercise task
22#[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    /**
29    If none, the task is not completable at the moment because the service needs to
30    be configured to the system.
31    */
32    pub exercise_iframe_url: Option<String>,
33    /**
34    Unique for each (exercise_service, user) combo. If none, the task is not completable at the moment because the service needs to
35    be configured to the system.
36    */
37    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
324// TODO: Move most of this to exercise_slides
325pub 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
365// TODO: Move most of this logic to exercise_slides
366pub 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}