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