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}
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
295// TODO: Move most of this to exercise_slides
296pub 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
332// TODO: Move most of this logic to exercise_slides
333pub 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}