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) -> 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
320// TODO: Move most of this to exercise_slides
321pub 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
357// TODO: Move most of this logic to exercise_slides
358pub 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}