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}