headless_lms_server/programs/seed/
seed_helpers.rs

1use anyhow::{Result, anyhow};
2use chrono::{DateTime, Utc};
3use headless_lms_models::{
4    PKeyPolicy, SpecFetcher, course_exams,
5    exams::{self, NewExam},
6    exercise_slide_submissions,
7    exercise_task_gradings::{self, ExerciseTaskGradingResult, UserPointsUpdateStrategy},
8    exercise_task_submissions,
9    exercises::{self, GradingProgress},
10    page_history::HistoryChangeReason,
11    pages::{
12        self, CmsPageExercise, CmsPageExerciseSlide, CmsPageExerciseTask, CmsPageUpdate, NewPage,
13        PageUpdateArgs,
14    },
15    peer_or_self_review_configs::{self, CmsPeerOrSelfReviewConfig},
16    peer_or_self_review_questions::{self, CmsPeerOrSelfReviewQuestion},
17    user_exercise_slide_states, user_exercise_states,
18};
19use headless_lms_utils::{
20    attributes,
21    document_schema_processor::{GutenbergBlock, validate_unique_client_ids},
22};
23use once_cell::sync::OnceCell;
24use serde_json::Value;
25use sqlx::PgConnection;
26use std::sync::Arc;
27use std::{collections::HashMap, vec};
28use uuid::Uuid;
29
30use crate::domain::models_requests::{self, JwtKey};
31
32// Static holder for our cached spec fetcher
33static SEED_SPEC_FETCHER: OnceCell<Box<dyn SpecFetcher + Send + Sync>> = OnceCell::new();
34
35/// Initialize the global spec fetcher for seeding. Must be called once before any seeding operations.
36pub fn init_seed_spec_fetcher(base_url: String, jwt_key: Arc<JwtKey>) -> Result<()> {
37    let fetcher = Box::new(models_requests::make_seed_spec_fetcher_with_cache(
38        base_url,
39        Uuid::new_v4(),
40        jwt_key,
41    ));
42
43    SEED_SPEC_FETCHER
44        .set(fetcher)
45        .map_err(|_| anyhow!("Seed spec fetcher already initialized"))?;
46    Ok(())
47}
48
49/// Get the global spec fetcher for seeding. Panics if not initialized.
50pub fn get_seed_spec_fetcher() -> &'static (dyn SpecFetcher + Send + Sync) {
51    // We can use unwrap here since this is a fatal error during seeding
52    SEED_SPEC_FETCHER
53        .get()
54        .unwrap_or_else(|| panic!("Seed spec fetcher not initialized"))
55}
56
57pub async fn create_page(
58    conn: &mut PgConnection,
59    course_id: Uuid,
60    author: Uuid,
61    chapter_id: Option<Uuid>,
62    page_data: CmsPageUpdate,
63) -> Result<Uuid> {
64    validate_unique_client_ids(page_data.content.clone())?;
65    let new_page = NewPage {
66        content: vec![],
67        url_path: page_data.url_path.to_string(),
68        title: format!("{} WIP", page_data.title),
69        course_id: Some(course_id),
70        exam_id: None,
71        chapter_id,
72        front_page_of_chapter_id: None,
73        exercises: vec![],
74        exercise_slides: vec![],
75        exercise_tasks: vec![],
76        content_search_language: None,
77    };
78    let page = pages::insert_page(
79        conn,
80        new_page,
81        author,
82        get_seed_spec_fetcher(),
83        models_requests::fetch_service_info,
84    )
85    .await?;
86    pages::update_page(
87        conn,
88        PageUpdateArgs {
89            page_id: page.id,
90            author,
91            cms_page_update: CmsPageUpdate {
92                content: page_data.content,
93                exercises: page_data.exercises,
94                exercise_slides: page_data.exercise_slides,
95                exercise_tasks: page_data.exercise_tasks,
96                url_path: page_data.url_path,
97                title: page_data.title,
98                chapter_id,
99            },
100            retain_ids: true,
101            history_change_reason: HistoryChangeReason::PageSaved,
102            is_exam_page: false,
103        },
104        get_seed_spec_fetcher(),
105        models_requests::fetch_service_info,
106    )
107    .await?;
108    Ok(page.id)
109}
110
111pub fn paragraph(content: &str, block: Uuid) -> GutenbergBlock {
112    GutenbergBlock {
113        name: "core/paragraph".to_string(),
114        is_valid: true,
115        client_id: block,
116        attributes: attributes! {
117            "content": content,
118            "dropCap": false,
119        },
120        inner_blocks: vec![],
121    }
122}
123
124pub fn chatbot_block(block: Uuid, chatbot_conf_id: Uuid, course_id: Uuid) -> GutenbergBlock {
125    GutenbergBlock {
126        client_id: block,
127        name: "moocfi/chatbot".to_string(),
128        is_valid: true,
129        attributes: attributes! {
130            "chatbotConfigurationId": chatbot_conf_id,
131            "courseId": course_id,
132        },
133        inner_blocks: vec![],
134    }
135}
136
137pub fn heading(content: &str, client_id: Uuid, level: i32) -> GutenbergBlock {
138    GutenbergBlock {
139        name: "core/heading".to_string(),
140        is_valid: true,
141        client_id,
142        attributes: attributes! {
143            "content": content,
144            "level": level,
145        },
146        inner_blocks: vec![],
147    }
148}
149
150pub fn list(block: Uuid, ordered: bool, inner_blocks: Vec<GutenbergBlock>) -> GutenbergBlock {
151    GutenbergBlock {
152        name: "core/list".to_string(),
153        client_id: block,
154        is_valid: true,
155        attributes: attributes! {
156            "ordered": ordered,
157        },
158        inner_blocks,
159    }
160}
161
162pub fn list_item(block: Uuid, content: &str) -> GutenbergBlock {
163    GutenbergBlock {
164        name: "core/list-item".to_string(),
165        client_id: block,
166        is_valid: true,
167        attributes: attributes! {
168            "content": content,
169        },
170        inner_blocks: vec![],
171    }
172}
173
174#[derive(Clone, Copy)]
175pub struct CommonExerciseData {
176    pub exercise_id: Uuid,
177    pub exercise_slide_id: Uuid,
178    pub exercise_task_id: Uuid,
179    pub block_id: Uuid,
180}
181
182pub fn create_best_exercise(
183    paragraph_id: Uuid,
184    spec_1: Uuid,
185    spec_2: Uuid,
186    spec_3: Uuid,
187    exercise_name: Option<String>,
188    exercise_data: CommonExerciseData,
189) -> (
190    GutenbergBlock,
191    CmsPageExercise,
192    CmsPageExerciseSlide,
193    CmsPageExerciseTask,
194) {
195    let CommonExerciseData {
196        exercise_id,
197        exercise_slide_id,
198        exercise_task_id,
199        block_id,
200    } = exercise_data;
201    let (exercise_block, exercise, mut slides, mut tasks) = example_exercise_flexible(
202        exercise_id,
203        exercise_name.unwrap_or_else(|| "Best exercise".to_string()),
204        vec![(
205            exercise_slide_id,
206            vec![(
207                exercise_task_id,
208                "example-exercise".to_string(),
209                serde_json::json!([paragraph("Answer this question.", paragraph_id)]),
210                serde_json::json!([
211                    {
212                        "name": "a",
213                        "correct": false,
214                        "id": spec_1,
215                    },
216                    {
217                        "name": "b",
218                        "correct": true,
219                        "id": spec_2,
220                    },
221                    {
222                        "name": "c",
223                        "correct": true,
224                        "id": spec_3,
225                    },
226                ]),
227            )],
228        )],
229        block_id,
230    );
231    (
232        exercise_block,
233        exercise,
234        slides.swap_remove(0),
235        tasks.swap_remove(0),
236    )
237}
238
239#[allow(clippy::type_complexity)]
240pub fn example_exercise_flexible(
241    exercise_id: Uuid,
242    exercise_name: String,
243    exercise_slides: Vec<(Uuid, Vec<(Uuid, String, Value, Value)>)>,
244    client_id: Uuid,
245) -> (
246    GutenbergBlock,
247    CmsPageExercise,
248    Vec<CmsPageExerciseSlide>,
249    Vec<CmsPageExerciseTask>,
250) {
251    let block = GutenbergBlock {
252        client_id,
253        name: "moocfi/exercise".to_string(),
254        is_valid: true,
255        attributes: attributes! {
256            "id": exercise_id,
257            "name": exercise_name,
258            "dropCap": false,
259        },
260        inner_blocks: vec![],
261    };
262    let slides: Vec<CmsPageExerciseSlide> = exercise_slides
263        .iter()
264        .map(|(slide_id, _)| CmsPageExerciseSlide {
265            id: *slide_id,
266            exercise_id,
267            order_number: 1,
268        })
269        .collect();
270    let tasks: Vec<CmsPageExerciseTask> = exercise_slides
271        .into_iter()
272        .flat_map(|(slide_id, tasks)| {
273            tasks.into_iter().enumerate().map(
274                move |(order_number, (task_id, task_type, assignment, spec))| {
275                    (
276                        slide_id,
277                        task_id,
278                        task_type,
279                        assignment,
280                        spec,
281                        order_number as i32,
282                    )
283                },
284            )
285        })
286        .map(
287            |(slide_id, task_id, exercise_type, assignment, spec, order_number)| {
288                CmsPageExerciseTask {
289                    id: task_id,
290                    exercise_slide_id: slide_id,
291                    assignment,
292                    exercise_type,
293                    private_spec: Some(spec),
294                    order_number,
295                }
296            },
297        )
298        .collect();
299
300    let exercise = CmsPageExercise {
301        id: exercise_id,
302        name: exercise_name,
303        order_number: 0,
304        score_maximum: tasks.len() as i32,
305        max_tries_per_slide: None,
306        limit_number_of_tries: false,
307        deadline: None,
308        needs_peer_review: false,
309        needs_self_review: false,
310        use_course_default_peer_or_self_review_config: false,
311        peer_or_self_review_config: None,
312        peer_or_self_review_questions: None,
313    };
314    (block, exercise, slides, tasks)
315}
316
317pub fn quizzes_exercise(
318    name: String,
319    paragraph_id: Uuid,
320    needs_peer_review: bool,
321    private_spec: serde_json::Value,
322    deadline: Option<DateTime<Utc>>,
323    exercise_data: CommonExerciseData,
324) -> (
325    GutenbergBlock,
326    CmsPageExercise,
327    CmsPageExerciseSlide,
328    CmsPageExerciseTask,
329) {
330    let CommonExerciseData {
331        exercise_id,
332        exercise_slide_id,
333        exercise_task_id,
334        block_id,
335    } = exercise_data;
336    let block = GutenbergBlock {
337        client_id: block_id,
338        name: "moocfi/exercise".to_string(),
339        is_valid: true,
340        attributes: attributes! {
341            "id": exercise_id,
342            "name": name,
343            "dropCap": false,
344        },
345        inner_blocks: vec![],
346    };
347    let exercise = CmsPageExercise {
348        id: exercise_id,
349        name,
350        order_number: 1,
351        score_maximum: 1,
352        max_tries_per_slide: None,
353        limit_number_of_tries: false,
354        deadline,
355        needs_peer_review,
356        needs_self_review: false,
357        use_course_default_peer_or_self_review_config: true,
358        peer_or_self_review_config: None,
359        peer_or_self_review_questions: None,
360    };
361    let exercise_slide = CmsPageExerciseSlide {
362        id: exercise_slide_id,
363        exercise_id,
364        order_number: 1,
365    };
366    let exercise_task = CmsPageExerciseTask {
367        id: exercise_task_id,
368        exercise_slide_id,
369        assignment: serde_json::json!([paragraph("Answer this question.", paragraph_id)]),
370        exercise_type: "quizzes".to_string(),
371        private_spec: Some(serde_json::json!(private_spec)),
372        order_number: 0,
373    };
374    (block, exercise, exercise_slide, exercise_task)
375}
376
377#[allow(clippy::too_many_arguments)]
378pub fn tmc_exercise(
379    name: String,
380    exercise_id: Uuid,
381    exercise_slide_id: Uuid,
382    exercise_task_id: Uuid,
383    block_id: Uuid,
384    paragraph_id: Uuid,
385    needs_peer_review: bool,
386    private_spec: serde_json::Value,
387    deadline: Option<DateTime<Utc>>,
388) -> (
389    GutenbergBlock,
390    CmsPageExercise,
391    CmsPageExerciseSlide,
392    CmsPageExerciseTask,
393) {
394    let block = GutenbergBlock {
395        client_id: block_id,
396        name: "moocfi/exercise".to_string(),
397        is_valid: true,
398        attributes: attributes! {
399            "id": exercise_id,
400            "name": name,
401            "dropCap": false,
402        },
403        inner_blocks: vec![],
404    };
405    let exercise = CmsPageExercise {
406        id: exercise_id,
407        name,
408        order_number: 1,
409        score_maximum: 1,
410        max_tries_per_slide: None,
411        limit_number_of_tries: false,
412        deadline,
413        needs_peer_review,
414        needs_self_review: false,
415        use_course_default_peer_or_self_review_config: true,
416        peer_or_self_review_config: None,
417        peer_or_self_review_questions: None,
418    };
419    let exercise_slide = CmsPageExerciseSlide {
420        id: exercise_slide_id,
421        exercise_id,
422        order_number: 1,
423    };
424    let exercise_task = CmsPageExerciseTask {
425        id: exercise_task_id,
426        exercise_slide_id,
427        assignment: serde_json::json!([paragraph("Write an `add` function.", paragraph_id)]),
428        exercise_type: "tmc".to_string(),
429        private_spec: Some(serde_json::json!(private_spec)),
430        order_number: 0,
431    };
432    (block, exercise, exercise_slide, exercise_task)
433}
434
435#[allow(clippy::too_many_arguments)]
436pub async fn submit_and_grade(
437    conn: &mut PgConnection,
438    id: &[u8],
439    exercise_id: Uuid,
440    exercise_slide_id: Uuid,
441    course_id: Uuid,
442    exercise_task_id: Uuid,
443    user_id: Uuid,
444    course_instance_id: Uuid,
445    spec: String,
446    out_of_100: f32,
447) -> Result<()> {
448    // combine the id with the user id to ensure it's unique
449    let id: Vec<u8> = [id, &user_id.as_bytes()[..]].concat();
450    let slide_submission = exercise_slide_submissions::insert_exercise_slide_submission_with_id(
451        conn,
452        Uuid::new_v4(),
453        &exercise_slide_submissions::NewExerciseSlideSubmission {
454            exercise_slide_id,
455            course_id: Some(course_id),
456            exam_id: None,
457            exercise_id,
458            user_id,
459            user_points_update_strategy: UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
460        },
461    )
462    .await?;
463    let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
464        conn,
465        user_id,
466        exercise_id,
467        Some(course_id),
468        None,
469    )
470    .await?;
471    // Set selected exercise slide
472    user_exercise_states::upsert_selected_exercise_slide_id(
473        conn,
474        user_id,
475        exercise_id,
476        Some(course_id),
477        None,
478        Some(exercise_slide_id),
479    )
480    .await?;
481    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
482        conn,
483        user_exercise_state.id,
484        exercise_slide_id,
485    )
486    .await?;
487    let task_submission_id = exercise_task_submissions::insert_with_id(
488        conn,
489        &exercise_task_submissions::SubmissionData {
490            id: Uuid::new_v5(&course_id, &id),
491            exercise_id,
492            course_id,
493            exercise_task_id,
494            exercise_slide_submission_id: slide_submission.id,
495            exercise_slide_id,
496            user_id,
497            course_instance_id,
498            data_json: Value::String(spec),
499        },
500    )
501    .await?;
502
503    let task_submission = exercise_task_submissions::get_by_id(conn, task_submission_id).await?;
504    let exercise = exercises::get_by_id(conn, exercise_id).await?;
505    let grading = exercise_task_gradings::new_grading(conn, &exercise, &task_submission).await?;
506    let grading_result = ExerciseTaskGradingResult {
507        feedback_json: Some(serde_json::json!([{"SelectedOptioIsCorrect": true}])),
508        feedback_text: Some("Good job!".to_string()),
509        grading_progress: GradingProgress::FullyGraded,
510        score_given: out_of_100,
511        score_maximum: 100,
512        set_user_variables: Some(HashMap::new()),
513    };
514    headless_lms_models::library::grading::propagate_user_exercise_state_update_from_exercise_task_grading_result(
515        conn,
516        &exercise,
517        &grading,
518        &grading_result,
519        user_exercise_slide_state,
520        UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
521    )
522    .await
523    .unwrap();
524    Ok(())
525}
526
527#[allow(clippy::too_many_arguments)]
528pub async fn create_exam(
529    conn: &mut PgConnection,
530    name: String,
531    starts_at: Option<DateTime<Utc>>,
532    ends_at: Option<DateTime<Utc>>,
533    time_minutes: i32,
534    organization_id: Uuid,
535    course_id: Uuid,
536    exam_id: Uuid,
537    teacher: Uuid,
538    minimum_points_treshold: i32,
539    grade_manually: bool,
540) -> Result<Uuid> {
541    let new_exam_id = exams::insert(
542        conn,
543        PKeyPolicy::Fixed(exam_id),
544        &NewExam {
545            name,
546            starts_at,
547            ends_at,
548            time_minutes,
549            organization_id,
550            minimum_points_treshold,
551            grade_manually,
552        },
553    )
554    .await?;
555
556    let (exam_exercise_block_1, exam_exercise_1, exam_exercise_slide_1, exam_exercise_task_1) =
557        quizzes_exercise(
558            "Multiple choice with feedback".to_string(),
559            Uuid::new_v5(&course_id, b"eced4875-ece9-4c3d-ad0a-2443e61b3e78"),
560            false,
561            serde_json::from_str(include_str!(
562                "../../assets/quizzes-multiple-choice-feedback.json"
563            ))?,
564            None,
565            CommonExerciseData {
566                exercise_id: Uuid::new_v5(&course_id, b"b1b16970-60bc-426e-9537-b29bd2185db3"),
567                exercise_slide_id: Uuid::new_v5(
568                    &course_id,
569                    b"ea461a21-e0b4-4e09-a811-231f583b3dcb",
570                ),
571                exercise_task_id: Uuid::new_v5(&course_id, b"9d8ccf47-3e83-4459-8f2f-8e546a75f372"),
572                block_id: Uuid::new_v5(&course_id, b"a4edb4e5-507d-43f1-8058-9d95941dbf09"),
573            },
574        );
575    let (exam_exercise_block_2, exam_exercise_2, exam_exercise_slide_2, exam_exercise_task_2) =
576        create_best_exercise(
577            Uuid::new_v5(&course_id, b"fe5bb5a9-d0ab-4072-abe1-119c9c1e4f4a"),
578            Uuid::new_v5(&course_id, b"22959aad-26fc-4212-8259-c128cdab8b08"),
579            Uuid::new_v5(&course_id, b"d8ba9e92-4530-4a74-9b11-eb708fa54d40"),
580            Uuid::new_v5(&course_id, b"846f4895-f573-41e2-9926-cd700723ac18"),
581            Some("Best exercise".to_string()),
582            CommonExerciseData {
583                exercise_id: Uuid::new_v5(&course_id, b"44f472e5-b726-4c50-89a1-93f4170673f5"),
584                exercise_slide_id: Uuid::new_v5(
585                    &course_id,
586                    b"23182b3d-fbf4-4c0d-93fa-e9ddc199cc52",
587                ),
588                exercise_task_id: Uuid::new_v5(&course_id, b"ca105826-5007-439f-87be-c25f9c79506e"),
589                block_id: Uuid::new_v5(&course_id, b"96a9e586-cf88-4cb2-b7c9-efc2bc47e90b"),
590            },
591        );
592    pages::insert_page(
593        conn,
594        NewPage {
595            exercises: vec![exam_exercise_1, exam_exercise_2],
596            exercise_slides: vec![exam_exercise_slide_1, exam_exercise_slide_2],
597            exercise_tasks: vec![exam_exercise_task_1, exam_exercise_task_2],
598            content: vec![
599                heading(
600                    "The exam",
601                    Uuid::parse_str("d6cf16ce-fe78-4e57-8399-e8b63d7fddac").unwrap(),
602                    1,
603                ),
604                paragraph(
605                    "In this exam you're supposed to answer to two easy questions. Good luck!",
606                    Uuid::parse_str("474d4f21-798b-4ba0-b39f-120b134e7fa0").unwrap(),
607                ),
608                exam_exercise_block_1,
609                exam_exercise_block_2,
610            ],
611            url_path: "".to_string(),
612            title: "".to_string(),
613            course_id: None,
614            exam_id: Some(new_exam_id),
615            chapter_id: None,
616            front_page_of_chapter_id: None,
617            content_search_language: None,
618        },
619        teacher,
620        get_seed_spec_fetcher(),
621        models_requests::fetch_service_info,
622    )
623    .await?;
624    course_exams::upsert(conn, new_exam_id, course_id).await?;
625    Ok(new_exam_id)
626}
627
628#[allow(clippy::too_many_arguments)]
629pub async fn create_best_peer_review(
630    conn: &mut PgConnection,
631    course_id: Uuid,
632    exercise_id: Uuid,
633    processing_strategy: peer_or_self_review_configs::PeerReviewProcessingStrategy,
634    accepting_threshold: f32,
635    points_are_all_or_nothing: bool,
636    peer_reviews_to_give: i32,
637    peer_reviews_to_receive: i32,
638) -> Result<()> {
639    let prc = peer_or_self_review_configs::upsert_with_id(
640        conn,
641        PKeyPolicy::Generate,
642        &CmsPeerOrSelfReviewConfig {
643            id: Uuid::new_v4(),
644            course_id,
645            exercise_id: Some(exercise_id),
646            peer_reviews_to_give,
647            peer_reviews_to_receive,
648            accepting_threshold,
649            processing_strategy,
650            points_are_all_or_nothing,
651            review_instructions: None,
652        },
653    )
654    .await?;
655
656    peer_or_self_review_questions::insert(
657        conn,
658        PKeyPolicy::Generate,
659        &CmsPeerOrSelfReviewQuestion {
660            id: Uuid::new_v4(),
661            peer_or_self_review_config_id: prc.id,
662            order_number: 0,
663            question: "What are your thoughts on the answer".to_string(),
664            question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
665            answer_required: true,
666            weight: 0.0,
667        },
668    )
669    .await?;
670
671    peer_or_self_review_questions::insert(
672        conn,
673        PKeyPolicy::Generate,
674        &CmsPeerOrSelfReviewQuestion {
675            id: Uuid::new_v4(),
676            peer_or_self_review_config_id: prc.id,
677            order_number: 1,
678            question: "Was the answer correct?".to_string(),
679            question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
680            answer_required: true,
681            weight: 0.0,
682        },
683    )
684    .await?;
685
686    peer_or_self_review_questions::insert(
687        conn,
688        PKeyPolicy::Generate,
689        &CmsPeerOrSelfReviewQuestion {
690            id: Uuid::new_v4(),
691            peer_or_self_review_config_id: prc.id,
692            order_number: 2,
693            question: "Was the answer good?".to_string(),
694            question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
695            answer_required: true,
696            weight: 0.0,
697        },
698    )
699    .await?;
700
701    exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
702        conn,
703        exercise_id,
704        true,
705        false,
706        false,
707    )
708    .await?;
709    Ok(())
710}