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