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