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            exam_id: None,
415            exercise_id,
416            user_id,
417            user_points_update_strategy: UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
418        },
419    )
420    .await?;
421    let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
422        conn,
423        user_id,
424        exercise_id,
425        Some(course_id),
426        None,
427    )
428    .await?;
429    // Set selected exercise slide
430    user_exercise_states::upsert_selected_exercise_slide_id(
431        conn,
432        user_id,
433        exercise_id,
434        Some(course_id),
435        None,
436        Some(exercise_slide_id),
437    )
438    .await?;
439    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
440        conn,
441        user_exercise_state.id,
442        exercise_slide_id,
443    )
444    .await?;
445    let task_submission_id = exercise_task_submissions::insert_with_id(
446        conn,
447        &exercise_task_submissions::SubmissionData {
448            id: Uuid::new_v5(&course_id, &id),
449            exercise_id,
450            course_id,
451            exercise_task_id,
452            exercise_slide_submission_id: slide_submission.id,
453            exercise_slide_id,
454            user_id,
455            course_instance_id,
456            data_json: Value::String(spec),
457        },
458    )
459    .await?;
460
461    let task_submission = exercise_task_submissions::get_by_id(conn, task_submission_id).await?;
462    let exercise = exercises::get_by_id(conn, exercise_id).await?;
463    let grading = exercise_task_gradings::new_grading(conn, &exercise, &task_submission).await?;
464    let grading_result = ExerciseTaskGradingResult {
465        feedback_json: Some(serde_json::json!([{"SelectedOptioIsCorrect": true}])),
466        feedback_text: Some("Good job!".to_string()),
467        grading_progress: GradingProgress::FullyGraded,
468        score_given: out_of_100,
469        score_maximum: 100,
470        set_user_variables: Some(HashMap::new()),
471    };
472    headless_lms_models::library::grading::propagate_user_exercise_state_update_from_exercise_task_grading_result(
473        conn,
474        &exercise,
475        &grading,
476        &grading_result,
477        user_exercise_slide_state,
478        UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
479    )
480    .await
481    .unwrap();
482    Ok(())
483}
484
485#[allow(clippy::too_many_arguments)]
486pub async fn create_exam(
487    conn: &mut PgConnection,
488    name: String,
489    starts_at: Option<DateTime<Utc>>,
490    ends_at: Option<DateTime<Utc>>,
491    time_minutes: i32,
492    organization_id: Uuid,
493    course_id: Uuid,
494    exam_id: Uuid,
495    teacher: Uuid,
496    minimum_points_treshold: i32,
497    grade_manually: bool,
498) -> Result<Uuid> {
499    let new_exam_id = exams::insert(
500        conn,
501        PKeyPolicy::Fixed(exam_id),
502        &NewExam {
503            name,
504            starts_at,
505            ends_at,
506            time_minutes,
507            organization_id,
508            minimum_points_treshold,
509            grade_manually,
510        },
511    )
512    .await?;
513
514    let (exam_exercise_block_1, exam_exercise_1, exam_exercise_slide_1, exam_exercise_task_1) =
515        quizzes_exercise(
516            "Multiple choice with feedback".to_string(),
517            Uuid::new_v5(&course_id, b"eced4875-ece9-4c3d-ad0a-2443e61b3e78"),
518            false,
519            serde_json::from_str(include_str!(
520                "../../assets/quizzes-multiple-choice-feedback.json"
521            ))?,
522            None,
523            CommonExerciseData {
524                exercise_id: Uuid::new_v5(&course_id, b"b1b16970-60bc-426e-9537-b29bd2185db3"),
525                exercise_slide_id: Uuid::new_v5(
526                    &course_id,
527                    b"ea461a21-e0b4-4e09-a811-231f583b3dcb",
528                ),
529                exercise_task_id: Uuid::new_v5(&course_id, b"9d8ccf47-3e83-4459-8f2f-8e546a75f372"),
530                block_id: Uuid::new_v5(&course_id, b"a4edb4e5-507d-43f1-8058-9d95941dbf09"),
531            },
532        );
533    let (exam_exercise_block_2, exam_exercise_2, exam_exercise_slide_2, exam_exercise_task_2) =
534        create_best_exercise(
535            Uuid::new_v5(&course_id, b"fe5bb5a9-d0ab-4072-abe1-119c9c1e4f4a"),
536            Uuid::new_v5(&course_id, b"22959aad-26fc-4212-8259-c128cdab8b08"),
537            Uuid::new_v5(&course_id, b"d8ba9e92-4530-4a74-9b11-eb708fa54d40"),
538            Uuid::new_v5(&course_id, b"846f4895-f573-41e2-9926-cd700723ac18"),
539            Some("Best exercise".to_string()),
540            CommonExerciseData {
541                exercise_id: Uuid::new_v5(&course_id, b"44f472e5-b726-4c50-89a1-93f4170673f5"),
542                exercise_slide_id: Uuid::new_v5(
543                    &course_id,
544                    b"23182b3d-fbf4-4c0d-93fa-e9ddc199cc52",
545                ),
546                exercise_task_id: Uuid::new_v5(&course_id, b"ca105826-5007-439f-87be-c25f9c79506e"),
547                block_id: Uuid::new_v5(&course_id, b"96a9e586-cf88-4cb2-b7c9-efc2bc47e90b"),
548            },
549        );
550    pages::insert_page(
551        conn,
552        NewPage {
553            exercises: vec![exam_exercise_1, exam_exercise_2],
554            exercise_slides: vec![exam_exercise_slide_1, exam_exercise_slide_2],
555            exercise_tasks: vec![exam_exercise_task_1, exam_exercise_task_2],
556            content: serde_json::json!([
557                heading(
558                    "The exam",
559                    Uuid::parse_str("d6cf16ce-fe78-4e57-8399-e8b63d7fddac").unwrap(),
560                    1
561                ),
562                paragraph(
563                    "In this exam you're supposed to answer to two easy questions. Good luck!",
564                    Uuid::parse_str("474d4f21-798b-4ba0-b39f-120b134e7fa0").unwrap(),
565                ),
566                exam_exercise_block_1,
567                exam_exercise_block_2,
568            ]),
569            url_path: "".to_string(),
570            title: "".to_string(),
571            course_id: None,
572            exam_id: Some(new_exam_id),
573            chapter_id: None,
574            front_page_of_chapter_id: None,
575            content_search_language: None,
576        },
577        teacher,
578        get_seed_spec_fetcher(),
579        models_requests::fetch_service_info,
580    )
581    .await?;
582    course_exams::upsert(conn, new_exam_id, course_id).await?;
583    Ok(new_exam_id)
584}
585
586#[allow(clippy::too_many_arguments)]
587pub async fn create_best_peer_review(
588    conn: &mut PgConnection,
589    course_id: Uuid,
590    exercise_id: Uuid,
591    processing_strategy: peer_or_self_review_configs::PeerReviewProcessingStrategy,
592    accepting_threshold: f32,
593    points_are_all_or_nothing: bool,
594    peer_reviews_to_give: i32,
595    peer_reviews_to_receive: i32,
596) -> Result<()> {
597    let prc = peer_or_self_review_configs::upsert_with_id(
598        conn,
599        PKeyPolicy::Generate,
600        &CmsPeerOrSelfReviewConfig {
601            id: Uuid::new_v4(),
602            course_id,
603            exercise_id: Some(exercise_id),
604            peer_reviews_to_give,
605            peer_reviews_to_receive,
606            accepting_threshold,
607            processing_strategy,
608            points_are_all_or_nothing,
609            review_instructions: None,
610        },
611    )
612    .await?;
613
614    peer_or_self_review_questions::insert(
615        conn,
616        PKeyPolicy::Generate,
617        &CmsPeerOrSelfReviewQuestion {
618            id: Uuid::new_v4(),
619            peer_or_self_review_config_id: prc.id,
620            order_number: 0,
621            question: "What are your thoughts on the answer".to_string(),
622            question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
623            answer_required: true,
624            weight: 0.0,
625        },
626    )
627    .await?;
628
629    peer_or_self_review_questions::insert(
630        conn,
631        PKeyPolicy::Generate,
632        &CmsPeerOrSelfReviewQuestion {
633            id: Uuid::new_v4(),
634            peer_or_self_review_config_id: prc.id,
635            order_number: 1,
636            question: "Was the answer correct?".to_string(),
637            question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
638            answer_required: true,
639            weight: 0.0,
640        },
641    )
642    .await?;
643
644    peer_or_self_review_questions::insert(
645        conn,
646        PKeyPolicy::Generate,
647        &CmsPeerOrSelfReviewQuestion {
648            id: Uuid::new_v4(),
649            peer_or_self_review_config_id: prc.id,
650            order_number: 2,
651            question: "Was the answer good?".to_string(),
652            question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
653            answer_required: true,
654            weight: 0.0,
655        },
656    )
657    .await?;
658
659    exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
660        conn,
661        exercise_id,
662        true,
663        false,
664        false,
665    )
666    .await?;
667    Ok(())
668}