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