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
176/// The stable per-service id an exercise service sees in place of a user's real id.
177pub fn pseudonymous_user_id(exercise_service_id: &Uuid, user_id: &Uuid) -> Uuid {
178    Uuid::new_v5(exercise_service_id, user_id.as_bytes())
179}
180
181pub async fn get_course_material_exercise_tasks(
182    conn: &mut PgConnection,
183    exercise_slide_id: Uuid,
184    user_id: Option<Uuid>,
185    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
186    file_store: &dyn FileStore,
187    app_conf: &ApplicationConfiguration,
188) -> ModelResult<Vec<CourseMaterialExerciseTask>> {
189    let exercise_tasks: Vec<ExerciseTask> =
190        get_exercise_tasks_by_exercise_slide_id(conn, &exercise_slide_id).await?;
191    let mut latest_submissions_by_task_id = if let Some(user_id) = user_id {
192        exercise_task_submissions::get_users_latest_exercise_task_submissions_for_exercise_slide(
193            conn,
194            exercise_slide_id,
195            user_id,
196            file_store,
197            app_conf,
198        )
199        .await?
200        .unwrap_or_default()
201        .into_iter()
202        .map(|s| (s.exercise_task_id, s))
203        .collect()
204    } else {
205        HashMap::new()
206    };
207
208    let unique_exercise_service_slugs = exercise_tasks
209        .iter()
210        .cloned()
211        .map(|et| et.exercise_type)
212        .collect::<HashSet<_>>()
213        .into_iter()
214        .collect::<Vec<_>>();
215    let exercise_service_slug_to_service_and_info =
216        exercise_service_info::get_selected_exercise_services_by_type(
217            &mut *conn,
218            &unique_exercise_service_slugs,
219            fetch_service_info,
220        )
221        .await?;
222
223    let mut material_tasks = Vec::with_capacity(exercise_tasks.len());
224    for exercise_task in exercise_tasks.into_iter() {
225        let model_solution_spec = exercise_task.model_solution_spec;
226        let previous_submission = latest_submissions_by_task_id.remove(&exercise_task.id);
227        let previous_submission_grading = if let Some(submission) = previous_submission.as_ref() {
228            exercise_task_gradings::get_by_exercise_task_submission_id(conn, submission.id).await?
229        } else {
230            None
231        };
232
233        let (exercise_service, service_info) = exercise_service_slug_to_service_and_info
234            .get(&exercise_task.exercise_type)
235            .ok_or_else(|| {
236                ModelError::new(
237                    ModelErrorType::InvalidRequest,
238                    "Exercise service not found".to_string(),
239                    None,
240                )
241            })?;
242        let mut exercise_iframe_url =
243            exercise_services::get_exercise_service_externally_preferred_baseurl(exercise_service)?;
244        exercise_iframe_url.set_path(&service_info.user_interface_iframe_path);
245
246        material_tasks.push(CourseMaterialExerciseTask {
247            id: exercise_task.id,
248            exercise_service_slug: exercise_task.exercise_type,
249            exercise_slide_id: exercise_task.exercise_slide_id,
250            exercise_iframe_url: Some(exercise_iframe_url.to_string()),
251            pseudonumous_user_id: user_id
252                .map(|uid| pseudonymous_user_id(&service_info.exercise_service_id, &uid)),
253            assignment: exercise_task.assignment,
254            public_spec: exercise_task.public_spec,
255            model_solution_spec,
256            previous_submission,
257            previous_submission_grading,
258            order_number: exercise_task.order_number,
259            deleted_at: exercise_task.deleted_at,
260        });
261    }
262    Ok(material_tasks)
263}
264
265pub async fn get_exercise_tasks_by_exercise_slide_id<T>(
266    conn: &mut PgConnection,
267    exercise_slide_id: &Uuid,
268) -> ModelResult<T>
269where
270    T: Default + Extend<ExerciseTask> + FromIterator<ExerciseTask>,
271{
272    let res = sqlx::query_as!(
273        ExerciseTask,
274        "
275SELECT *
276FROM exercise_tasks
277WHERE exercise_slide_id = $1
278AND deleted_at IS NULL;
279        ",
280        exercise_slide_id,
281    )
282    .fetch(conn)
283    .try_collect()
284    .await?;
285    Ok(res)
286}
287
288pub async fn get_exercise_tasks_by_exercise_slide_id_including_deleted<T>(
289    conn: &mut PgConnection,
290    exercise_slide_id: &Uuid,
291) -> ModelResult<T>
292where
293    T: Default + Extend<ExerciseTask> + FromIterator<ExerciseTask>,
294{
295    let res = sqlx::query_as!(
296        ExerciseTask,
297        "
298SELECT *
299FROM exercise_tasks
300WHERE exercise_slide_id = $1
301        ",
302        exercise_slide_id,
303    )
304    .fetch(conn)
305    .try_collect()
306    .await?;
307    Ok(res)
308}
309
310pub async fn get_exercise_tasks_by_exercise_slide_ids(
311    conn: &mut PgConnection,
312    exercise_slide_ids: &[Uuid],
313) -> ModelResult<Vec<ExerciseTask>> {
314    let exercise_tasks = sqlx::query_as!(
315        ExerciseTask,
316        "
317SELECT *
318FROM exercise_tasks
319WHERE exercise_slide_id = ANY($1)
320  AND deleted_at IS NULL;
321        ",
322        exercise_slide_ids,
323    )
324    .fetch_all(conn)
325    .await?;
326    Ok(exercise_tasks)
327}
328
329// TODO: Move most of this to exercise_slides
330pub async fn get_existing_users_exercise_slide_for_course(
331    conn: &mut PgConnection,
332    user_id: Uuid,
333    exercise_id: Uuid,
334    course_id: Uuid,
335    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
336    file_store: &dyn FileStore,
337    app_conf: &ApplicationConfiguration,
338) -> ModelResult<Option<CourseMaterialExerciseSlide>> {
339    let user_exercise_state = user_exercise_states::get_user_exercise_state_if_exists(
340        conn,
341        user_id,
342        exercise_id,
343        CourseOrExamId::Course(course_id),
344    )
345    .await?;
346    let exercise_tasks = if let Some(user_exercise_state) = user_exercise_state {
347        if let Some(selected_exercise_slide_id) = user_exercise_state.selected_exercise_slide_id {
348            let exercise_tasks = get_course_material_exercise_tasks(
349                conn,
350                selected_exercise_slide_id,
351                Some(user_id),
352                fetch_service_info,
353                file_store,
354                app_conf,
355            )
356            .await?;
357            Some(CourseMaterialExerciseSlide {
358                id: selected_exercise_slide_id,
359                exercise_tasks,
360            })
361        } else {
362            None
363        }
364    } else {
365        None
366    };
367    Ok(exercise_tasks)
368}
369
370// TODO: Move most of this logic to exercise_slides
371pub async fn get_or_select_user_exercise_slide_for_course_or_exam(
372    conn: &mut PgConnection,
373    user_id: Uuid,
374    exercise_id: Uuid,
375    course_or_exam_id: CourseOrExamId,
376    fetch_service_info: impl Fn(Url) -> BoxFuture<'static, ModelResult<ExerciseServiceInfoApi>>,
377    file_store: &dyn FileStore,
378    app_conf: &ApplicationConfiguration,
379) -> ModelResult<CourseMaterialExerciseSlide> {
380    let (course_id, exam_id) = course_or_exam_id.to_course_and_exam_ids();
381    let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
382        conn,
383        user_id,
384        exercise_id,
385        course_id,
386        exam_id,
387    )
388    .await?;
389    info!("statestate {:#?}", user_exercise_state);
390    let selected_exercise_slide_id =
391        if let Some(selected_exercise_slide_id) = user_exercise_state.selected_exercise_slide_id {
392            info!("found {}", selected_exercise_slide_id);
393            selected_exercise_slide_id
394        } else {
395            info!("random");
396            let exercise_slide_id =
397                exercise_slides::get_random_exercise_slide_for_exercise(conn, exercise_id)
398                    .await?
399                    .id;
400            user_exercise_states::upsert_selected_exercise_slide_id(
401                conn,
402                user_id,
403                exercise_id,
404                course_id,
405                exam_id,
406                Some(exercise_slide_id),
407            )
408            .await?;
409            exercise_slide_id
410        };
411
412    let exercise_tasks = get_course_material_exercise_tasks(
413        conn,
414        selected_exercise_slide_id,
415        Some(user_id),
416        fetch_service_info,
417        file_store,
418        app_conf,
419    )
420    .await?;
421    info!("got tasks");
422    if exercise_tasks.is_empty() {
423        return Err(ModelError::new(
424            ModelErrorType::PreconditionFailed,
425            "Missing exercise definition.".to_string(),
426            None,
427        ));
428    }
429
430    Ok(CourseMaterialExerciseSlide {
431        id: selected_exercise_slide_id,
432        exercise_tasks,
433    })
434}
435
436pub async fn get_exercise_tasks_by_exercise_id(
437    conn: &mut PgConnection,
438    exercise_id: Uuid,
439) -> ModelResult<Vec<ExerciseTask>> {
440    let exercise_tasks = sqlx::query_as!(
441        ExerciseTask,
442        "
443SELECT t.*
444FROM exercise_tasks t
445  JOIN exercise_slides s ON (t.exercise_slide_id = s.id)
446WHERE s.exercise_id = $1
447  AND s.deleted_at IS NULL
448  AND t.deleted_at IS NULL;
449        ",
450        exercise_id
451    )
452    .fetch_all(conn)
453    .await?;
454    Ok(exercise_tasks)
455}
456
457pub async fn delete_exercise_tasks_by_slide_ids(
458    conn: &mut PgConnection,
459    exercise_slide_ids: &[Uuid],
460) -> ModelResult<Vec<Uuid>> {
461    let deleted_ids = sqlx::query!(
462        "
463UPDATE exercise_tasks
464SET deleted_at = now()
465WHERE exercise_slide_id = ANY($1)
466AND deleted_at IS NULL
467RETURNING id;
468        ",
469        &exercise_slide_ids,
470    )
471    .fetch_all(conn)
472    .await?
473    .into_iter()
474    .map(|x| x.id)
475    .collect();
476    Ok(deleted_ids)
477}
478
479pub async fn get_exercise_task_model_solution_spec_by_id(
480    conn: &mut PgConnection,
481    exercise_task_id: Uuid,
482) -> ModelResult<Option<serde_json::Value>> {
483    let exercise_task = sqlx::query_as!(
484        ExerciseTask,
485        "
486SELECT *
487FROM exercise_tasks et
488WHERE et.id = $1;
489    ",
490        exercise_task_id
491    )
492    .fetch_one(conn)
493    .await?;
494    Ok(exercise_task.model_solution_spec)
495}
496
497pub async fn get_all_exercise_tas_by_exercise_slide_submission_id(
498    conn: &mut PgConnection,
499    exercise_slide_submission_id: Uuid,
500) -> ModelResult<Vec<ExerciseTaskGrading>> {
501    let res = sqlx::query_as!(
502        ExerciseTaskGrading,
503        r#"
504SELECT id,
505created_at,
506updated_at,
507exercise_task_submission_id,
508course_id,
509exam_id,
510exercise_id,
511exercise_task_id,
512grading_priority,
513score_given,
514grading_progress,
515unscaled_score_given,
516unscaled_score_maximum,
517grading_started_at,
518grading_completed_at,
519feedback_json,
520feedback_text,
521deleted_at
522FROM exercise_task_gradings
523WHERE deleted_at IS NULL
524  AND exercise_task_submission_id IN (
525    SELECT id
526    FROM exercise_task_submissions
527    WHERE exercise_slide_submission_id = $1
528  )
529"#,
530        exercise_slide_submission_id
531    )
532    .fetch_all(&mut *conn)
533    .await?;
534    Ok(res)
535}
536
537pub async fn get_all_exercise_tasks_by_module_and_exercise_type(
538    conn: &mut PgConnection,
539    exercise_type: &str,
540    module_id: Uuid,
541) -> ModelResult<Vec<CustomViewExerciseTaskSpec>> {
542    let res: Vec<CustomViewExerciseTaskSpec> = sqlx::query_as!(
543        CustomViewExerciseTaskSpec,
544        r#"
545    SELECT distinct (et.id),
546        et.public_spec,
547        et.order_number
548    FROM exercise_tasks et
549    JOIN exercise_slides es ON es.id = et.exercise_slide_id
550    JOIN exercises e ON es.exercise_id = e.id JOIN chapters c ON e.chapter_id = c.id
551    WHERE et.exercise_type = $1 AND c.course_module_id = $2
552      AND et.deleted_at IS NULL
553      AND es.deleted_at IS NULL
554      AND e.deleted_at IS NULL
555      AND c.deleted_at IS NULL;
556        "#,
557        exercise_type,
558        module_id
559    )
560    .fetch_all(conn)
561    .await?;
562    Ok(res)
563}
564
565pub fn stream_course_exercise_tasks(
566    conn: &mut PgConnection,
567    course_id: Uuid,
568) -> impl Stream<Item = sqlx::Result<ExerciseTaskSpec>> + '_ {
569    sqlx::query_as!(
570        ExerciseTaskSpec,
571        r#"
572        SELECT distinct (t.id),
573        t.created_at,
574        t.updated_at,
575        t.exercise_type,
576        t.private_spec,
577        e.name as exercise_name,
578        mod.id as course_module_id,
579        mod.name as course_module_name
580      FROM exercise_tasks t
581      JOIN exercise_slides s
582        ON s.id = t.exercise_slide_id
583      JOIN exercises e
584        ON s.exercise_id = e.id
585      JOIN chapters ch ON e.chapter_id = ch.id
586      JOIN course_modules mod ON mod.id = ch.course_module_id
587        WHERE e.course_id = $1
588        AND e.deleted_at IS NULL
589        AND s.deleted_at IS NULL
590        AND t.deleted_at IS NULL
591        AND ch.deleted_at IS NULL
592        AND mod.deleted_at IS NULL;
593        "#,
594        course_id
595    )
596    .fetch(conn)
597}