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