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