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
32static SEED_SPEC_FETCHER: OnceCell<Box<dyn SpecFetcher + Send + Sync>> = OnceCell::new();
34
35pub 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
49pub fn get_seed_spec_fetcher() -> &'static (dyn SpecFetcher + Send + Sync) {
51    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        Some(false),
231        None,
232        None,
233    );
234    (
235        exercise_block,
236        exercise,
237        slides.swap_remove(0),
238        tasks.swap_remove(0),
239    )
240}
241
242#[allow(clippy::type_complexity)]
243pub fn example_exercise_flexible(
244    exercise_id: Uuid,
245    exercise_name: String,
246    exercise_slides: Vec<(Uuid, Vec<(Uuid, String, Value, Value)>)>,
247    client_id: Uuid,
248    needs_peer_review: Option<bool>,
249    peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
250    peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
251) -> (
252    GutenbergBlock,
253    CmsPageExercise,
254    Vec<CmsPageExerciseSlide>,
255    Vec<CmsPageExerciseTask>,
256) {
257    let block = GutenbergBlock {
258        client_id,
259        name: "moocfi/exercise".to_string(),
260        is_valid: true,
261        attributes: attributes! {
262            "id": exercise_id,
263            "name": exercise_name,
264            "dropCap": false,
265        },
266        inner_blocks: vec![],
267    };
268    let slides: Vec<CmsPageExerciseSlide> = exercise_slides
269        .iter()
270        .map(|(slide_id, _)| CmsPageExerciseSlide {
271            id: *slide_id,
272            exercise_id,
273            order_number: 1,
274        })
275        .collect();
276    let tasks: Vec<CmsPageExerciseTask> = exercise_slides
277        .into_iter()
278        .flat_map(|(slide_id, tasks)| {
279            tasks.into_iter().enumerate().map(
280                move |(order_number, (task_id, task_type, assignment, spec))| {
281                    (
282                        slide_id,
283                        task_id,
284                        task_type,
285                        assignment,
286                        spec,
287                        order_number as i32,
288                    )
289                },
290            )
291        })
292        .map(
293            |(slide_id, task_id, exercise_type, assignment, spec, order_number)| {
294                CmsPageExerciseTask {
295                    id: task_id,
296                    exercise_slide_id: slide_id,
297                    assignment,
298                    exercise_type,
299                    private_spec: Some(spec),
300                    order_number,
301                }
302            },
303        )
304        .collect();
305
306    let exercise = CmsPageExercise {
307        id: exercise_id,
308        name: exercise_name,
309        order_number: 0,
310        score_maximum: tasks.len() as i32,
311        max_tries_per_slide: None,
312        limit_number_of_tries: false,
313        deadline: None,
314        needs_peer_review: needs_peer_review.unwrap_or(false),
315        needs_self_review: false,
316        use_course_default_peer_or_self_review_config: false,
317        peer_or_self_review_config,
318        peer_or_self_review_questions,
319    };
320    (block, exercise, slides, tasks)
321}
322
323pub fn quizzes_exercise(
324    name: String,
325    paragraph_id: Uuid,
326    needs_peer_review: bool,
327    private_spec: serde_json::Value,
328    deadline: Option<DateTime<Utc>>,
329    exercise_data: CommonExerciseData,
330) -> (
331    GutenbergBlock,
332    CmsPageExercise,
333    CmsPageExerciseSlide,
334    CmsPageExerciseTask,
335) {
336    let CommonExerciseData {
337        exercise_id,
338        exercise_slide_id,
339        exercise_task_id,
340        block_id,
341    } = exercise_data;
342    let block = GutenbergBlock {
343        client_id: block_id,
344        name: "moocfi/exercise".to_string(),
345        is_valid: true,
346        attributes: attributes! {
347            "id": exercise_id,
348            "name": name,
349            "dropCap": false,
350        },
351        inner_blocks: vec![],
352    };
353    let exercise = CmsPageExercise {
354        id: exercise_id,
355        name,
356        order_number: 1,
357        score_maximum: 1,
358        max_tries_per_slide: None,
359        limit_number_of_tries: false,
360        deadline,
361        needs_peer_review,
362        needs_self_review: false,
363        use_course_default_peer_or_self_review_config: true,
364        peer_or_self_review_config: None,
365        peer_or_self_review_questions: None,
366    };
367    let exercise_slide = CmsPageExerciseSlide {
368        id: exercise_slide_id,
369        exercise_id,
370        order_number: 1,
371    };
372    let exercise_task = CmsPageExerciseTask {
373        id: exercise_task_id,
374        exercise_slide_id,
375        assignment: serde_json::json!([paragraph("Answer this question.", paragraph_id)]),
376        exercise_type: "quizzes".to_string(),
377        private_spec: Some(serde_json::json!(private_spec)),
378        order_number: 0,
379    };
380    (block, exercise, exercise_slide, exercise_task)
381}
382
383#[allow(clippy::too_many_arguments)]
384pub fn tmc_exercise(
385    name: String,
386    exercise_id: Uuid,
387    exercise_slide_id: Uuid,
388    exercise_task_id: Uuid,
389    block_id: Uuid,
390    paragraph_id: Uuid,
391    needs_peer_review: bool,
392    private_spec: serde_json::Value,
393    deadline: Option<DateTime<Utc>>,
394) -> (
395    GutenbergBlock,
396    CmsPageExercise,
397    CmsPageExerciseSlide,
398    CmsPageExerciseTask,
399) {
400    let block = GutenbergBlock {
401        client_id: block_id,
402        name: "moocfi/exercise".to_string(),
403        is_valid: true,
404        attributes: attributes! {
405            "id": exercise_id,
406            "name": name,
407            "dropCap": false,
408        },
409        inner_blocks: vec![],
410    };
411    let exercise = CmsPageExercise {
412        id: exercise_id,
413        name,
414        order_number: 1,
415        score_maximum: 1,
416        max_tries_per_slide: None,
417        limit_number_of_tries: false,
418        deadline,
419        needs_peer_review,
420        needs_self_review: false,
421        use_course_default_peer_or_self_review_config: true,
422        peer_or_self_review_config: None,
423        peer_or_self_review_questions: None,
424    };
425    let exercise_slide = CmsPageExerciseSlide {
426        id: exercise_slide_id,
427        exercise_id,
428        order_number: 1,
429    };
430    let exercise_task = CmsPageExerciseTask {
431        id: exercise_task_id,
432        exercise_slide_id,
433        assignment: serde_json::json!([paragraph("Write an `add` function.", paragraph_id)]),
434        exercise_type: "tmc".to_string(),
435        private_spec: Some(serde_json::json!(private_spec)),
436        order_number: 0,
437    };
438    (block, exercise, exercise_slide, exercise_task)
439}
440
441#[allow(clippy::too_many_arguments)]
442pub async fn submit_and_grade(
443    conn: &mut PgConnection,
444    id: &[u8],
445    exercise_id: Uuid,
446    exercise_slide_id: Uuid,
447    course_id: Uuid,
448    exercise_task_id: Uuid,
449    user_id: Uuid,
450    course_instance_id: Uuid,
451    spec: String,
452    out_of_100: f32,
453) -> Result<()> {
454    let id: Vec<u8> = [id, &user_id.as_bytes()[..]].concat();
456    let slide_submission = exercise_slide_submissions::insert_exercise_slide_submission_with_id(
457        conn,
458        Uuid::new_v4(),
459        &exercise_slide_submissions::NewExerciseSlideSubmission {
460            exercise_slide_id,
461            course_id: Some(course_id),
462            exam_id: None,
463            exercise_id,
464            user_id,
465            user_points_update_strategy: UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
466        },
467    )
468    .await?;
469    let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
470        conn,
471        user_id,
472        exercise_id,
473        Some(course_id),
474        None,
475    )
476    .await?;
477    user_exercise_states::upsert_selected_exercise_slide_id(
479        conn,
480        user_id,
481        exercise_id,
482        Some(course_id),
483        None,
484        Some(exercise_slide_id),
485    )
486    .await?;
487    let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
488        conn,
489        user_exercise_state.id,
490        exercise_slide_id,
491    )
492    .await?;
493    let task_submission_id = exercise_task_submissions::insert_with_id(
494        conn,
495        &exercise_task_submissions::SubmissionData {
496            id: Uuid::new_v5(&course_id, &id),
497            exercise_id,
498            course_id,
499            exercise_task_id,
500            exercise_slide_submission_id: slide_submission.id,
501            exercise_slide_id,
502            user_id,
503            course_instance_id,
504            data_json: Value::String(spec),
505        },
506    )
507    .await?;
508
509    let task_submission = exercise_task_submissions::get_by_id(conn, task_submission_id).await?;
510    let exercise = exercises::get_by_id(conn, exercise_id).await?;
511    let grading = exercise_task_gradings::new_grading(conn, &exercise, &task_submission).await?;
512    let grading_result = ExerciseTaskGradingResult {
513        feedback_json: Some(serde_json::json!([{"SelectedOptioIsCorrect": true}])),
514        feedback_text: Some("Good job!".to_string()),
515        grading_progress: GradingProgress::FullyGraded,
516        score_given: out_of_100,
517        score_maximum: 100,
518        set_user_variables: Some(HashMap::new()),
519    };
520    headless_lms_models::library::grading::propagate_user_exercise_state_update_from_exercise_task_grading_result(
521        conn,
522        &exercise,
523        &grading,
524        &grading_result,
525        user_exercise_slide_state,
526        UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
527    )
528    .await
529    .unwrap();
530    Ok(())
531}
532
533#[allow(clippy::too_many_arguments)]
534pub async fn create_exam(
535    conn: &mut PgConnection,
536    name: String,
537    starts_at: Option<DateTime<Utc>>,
538    ends_at: Option<DateTime<Utc>>,
539    time_minutes: i32,
540    organization_id: Uuid,
541    course_id: Uuid,
542    exam_id: Uuid,
543    teacher: Uuid,
544    minimum_points_treshold: i32,
545    grade_manually: bool,
546) -> Result<Uuid> {
547    let new_exam_id = exams::insert(
548        conn,
549        PKeyPolicy::Fixed(exam_id),
550        &NewExam {
551            name,
552            starts_at,
553            ends_at,
554            time_minutes,
555            organization_id,
556            minimum_points_treshold,
557            grade_manually,
558        },
559    )
560    .await?;
561
562    let (exam_exercise_block_1, exam_exercise_1, exam_exercise_slide_1, exam_exercise_task_1) =
563        quizzes_exercise(
564            "Multiple choice with feedback".to_string(),
565            Uuid::new_v5(&course_id, b"eced4875-ece9-4c3d-ad0a-2443e61b3e78"),
566            false,
567            serde_json::from_str(include_str!(
568                "../../assets/quizzes-multiple-choice-feedback.json"
569            ))?,
570            None,
571            CommonExerciseData {
572                exercise_id: Uuid::new_v5(&course_id, b"b1b16970-60bc-426e-9537-b29bd2185db3"),
573                exercise_slide_id: Uuid::new_v5(
574                    &course_id,
575                    b"ea461a21-e0b4-4e09-a811-231f583b3dcb",
576                ),
577                exercise_task_id: Uuid::new_v5(&course_id, b"9d8ccf47-3e83-4459-8f2f-8e546a75f372"),
578                block_id: Uuid::new_v5(&course_id, b"a4edb4e5-507d-43f1-8058-9d95941dbf09"),
579            },
580        );
581    let (exam_exercise_block_2, exam_exercise_2, exam_exercise_slide_2, exam_exercise_task_2) =
582        create_best_exercise(
583            Uuid::new_v5(&course_id, b"fe5bb5a9-d0ab-4072-abe1-119c9c1e4f4a"),
584            Uuid::new_v5(&course_id, b"22959aad-26fc-4212-8259-c128cdab8b08"),
585            Uuid::new_v5(&course_id, b"d8ba9e92-4530-4a74-9b11-eb708fa54d40"),
586            Uuid::new_v5(&course_id, b"846f4895-f573-41e2-9926-cd700723ac18"),
587            Some("Best exercise".to_string()),
588            CommonExerciseData {
589                exercise_id: Uuid::new_v5(&course_id, b"44f472e5-b726-4c50-89a1-93f4170673f5"),
590                exercise_slide_id: Uuid::new_v5(
591                    &course_id,
592                    b"23182b3d-fbf4-4c0d-93fa-e9ddc199cc52",
593                ),
594                exercise_task_id: Uuid::new_v5(&course_id, b"ca105826-5007-439f-87be-c25f9c79506e"),
595                block_id: Uuid::new_v5(&course_id, b"96a9e586-cf88-4cb2-b7c9-efc2bc47e90b"),
596            },
597        );
598    pages::insert_page(
599        conn,
600        NewPage {
601            exercises: vec![exam_exercise_1, exam_exercise_2],
602            exercise_slides: vec![exam_exercise_slide_1, exam_exercise_slide_2],
603            exercise_tasks: vec![exam_exercise_task_1, exam_exercise_task_2],
604            content: vec![
605                heading(
606                    "The exam",
607                    Uuid::parse_str("d6cf16ce-fe78-4e57-8399-e8b63d7fddac").unwrap(),
608                    1,
609                ),
610                paragraph(
611                    "In this exam you're supposed to answer to two easy questions. Good luck!",
612                    Uuid::parse_str("474d4f21-798b-4ba0-b39f-120b134e7fa0").unwrap(),
613                ),
614                exam_exercise_block_1,
615                exam_exercise_block_2,
616            ],
617            url_path: "".to_string(),
618            title: "".to_string(),
619            course_id: None,
620            exam_id: Some(new_exam_id),
621            chapter_id: None,
622            front_page_of_chapter_id: None,
623            content_search_language: None,
624        },
625        teacher,
626        get_seed_spec_fetcher(),
627        models_requests::fetch_service_info,
628    )
629    .await?;
630    course_exams::upsert(conn, new_exam_id, course_id).await?;
631    Ok(new_exam_id)
632}
633
634#[allow(clippy::too_many_arguments)]
635pub async fn create_best_peer_review(
636    conn: &mut PgConnection,
637    course_id: Uuid,
638    exercise_id: Uuid,
639    processing_strategy: peer_or_self_review_configs::PeerReviewProcessingStrategy,
640    accepting_threshold: f32,
641    points_are_all_or_nothing: bool,
642    peer_reviews_to_give: i32,
643    peer_reviews_to_receive: i32,
644) -> Result<()> {
645    let prc = peer_or_self_review_configs::upsert_with_id(
646        conn,
647        PKeyPolicy::Generate,
648        &CmsPeerOrSelfReviewConfig {
649            id: Uuid::new_v4(),
650            course_id,
651            exercise_id: Some(exercise_id),
652            peer_reviews_to_give,
653            peer_reviews_to_receive,
654            accepting_threshold,
655            processing_strategy,
656            points_are_all_or_nothing,
657            review_instructions: None,
658        },
659    )
660    .await?;
661
662    peer_or_self_review_questions::insert(
663        conn,
664        PKeyPolicy::Generate,
665        &CmsPeerOrSelfReviewQuestion {
666            id: Uuid::new_v4(),
667            peer_or_self_review_config_id: prc.id,
668            order_number: 0,
669            question: "What are your thoughts on the answer".to_string(),
670            question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
671            answer_required: true,
672            weight: 0.0,
673        },
674    )
675    .await?;
676
677    peer_or_self_review_questions::insert(
678        conn,
679        PKeyPolicy::Generate,
680        &CmsPeerOrSelfReviewQuestion {
681            id: Uuid::new_v4(),
682            peer_or_self_review_config_id: prc.id,
683            order_number: 1,
684            question: "Was the answer correct?".to_string(),
685            question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
686            answer_required: true,
687            weight: 0.0,
688        },
689    )
690    .await?;
691
692    peer_or_self_review_questions::insert(
693        conn,
694        PKeyPolicy::Generate,
695        &CmsPeerOrSelfReviewQuestion {
696            id: Uuid::new_v4(),
697            peer_or_self_review_config_id: prc.id,
698            order_number: 2,
699            question: "Was the answer good?".to_string(),
700            question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
701            answer_required: true,
702            weight: 0.0,
703        },
704    )
705    .await?;
706
707    exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
708        conn,
709        exercise_id,
710        true,
711        false,
712        false,
713    )
714    .await?;
715    Ok(())
716}