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 course_instance_id: Some(course_instance_id),
415 exam_id: None,
416 exercise_id,
417 user_id,
418 user_points_update_strategy: UserPointsUpdateStrategy::CanAddPointsAndCanRemovePoints,
419 },
420 )
421 .await?;
422 let user_exercise_state = user_exercise_states::get_or_create_user_exercise_state(
423 conn,
424 user_id,
425 exercise_id,
426 Some(course_instance_id),
427 None,
428 )
429 .await?;
430 user_exercise_states::upsert_selected_exercise_slide_id(
432 conn,
433 user_id,
434 exercise_id,
435 Some(course_instance_id),
436 None,
437 Some(exercise_slide_id),
438 )
439 .await?;
440 let user_exercise_slide_state = user_exercise_slide_states::get_or_insert_by_unique_index(
441 conn,
442 user_exercise_state.id,
443 exercise_slide_id,
444 )
445 .await?;
446 let task_submission_id = exercise_task_submissions::insert_with_id(
447 conn,
448 &exercise_task_submissions::SubmissionData {
449 id: Uuid::new_v5(&course_id, &id),
450 exercise_id,
451 course_id,
452 exercise_task_id,
453 exercise_slide_submission_id: slide_submission.id,
454 exercise_slide_id,
455 user_id,
456 course_instance_id,
457 data_json: Value::String(spec),
458 },
459 )
460 .await?;
461
462 let task_submission = exercise_task_submissions::get_by_id(conn, task_submission_id).await?;
463 let exercise = exercises::get_by_id(conn, exercise_id).await?;
464 let grading = exercise_task_gradings::new_grading(conn, &exercise, &task_submission).await?;
465 let grading_result = ExerciseTaskGradingResult {
466 feedback_json: Some(serde_json::json!([{"SelectedOptioIsCorrect": true}])),
467 feedback_text: Some("Good job!".to_string()),
468 grading_progress: GradingProgress::FullyGraded,
469 score_given: out_of_100,
470 score_maximum: 100,
471 set_user_variables: Some(HashMap::new()),
472 };
473 headless_lms_models::library::grading::propagate_user_exercise_state_update_from_exercise_task_grading_result(
474 conn,
475 &exercise,
476 &grading,
477 &grading_result,
478 user_exercise_slide_state,
479 UserPointsUpdateStrategy::CanAddPointsButCannotRemovePoints,
480 )
481 .await
482 .unwrap();
483 Ok(())
484}
485
486#[allow(clippy::too_many_arguments)]
487pub async fn create_exam(
488 conn: &mut PgConnection,
489 name: String,
490 starts_at: Option<DateTime<Utc>>,
491 ends_at: Option<DateTime<Utc>>,
492 time_minutes: i32,
493 organization_id: Uuid,
494 course_id: Uuid,
495 exam_id: Uuid,
496 teacher: Uuid,
497 minimum_points_treshold: i32,
498 grade_manually: bool,
499) -> Result<Uuid> {
500 let new_exam_id = exams::insert(
501 conn,
502 PKeyPolicy::Fixed(exam_id),
503 &NewExam {
504 name,
505 starts_at,
506 ends_at,
507 time_minutes,
508 organization_id,
509 minimum_points_treshold,
510 grade_manually,
511 },
512 )
513 .await?;
514
515 let (exam_exercise_block_1, exam_exercise_1, exam_exercise_slide_1, exam_exercise_task_1) =
516 quizzes_exercise(
517 "Multiple choice with feedback".to_string(),
518 Uuid::new_v5(&course_id, b"eced4875-ece9-4c3d-ad0a-2443e61b3e78"),
519 false,
520 serde_json::from_str(include_str!(
521 "../../assets/quizzes-multiple-choice-feedback.json"
522 ))?,
523 None,
524 CommonExerciseData {
525 exercise_id: Uuid::new_v5(&course_id, b"b1b16970-60bc-426e-9537-b29bd2185db3"),
526 exercise_slide_id: Uuid::new_v5(
527 &course_id,
528 b"ea461a21-e0b4-4e09-a811-231f583b3dcb",
529 ),
530 exercise_task_id: Uuid::new_v5(&course_id, b"9d8ccf47-3e83-4459-8f2f-8e546a75f372"),
531 block_id: Uuid::new_v5(&course_id, b"a4edb4e5-507d-43f1-8058-9d95941dbf09"),
532 },
533 );
534 let (exam_exercise_block_2, exam_exercise_2, exam_exercise_slide_2, exam_exercise_task_2) =
535 create_best_exercise(
536 Uuid::new_v5(&course_id, b"fe5bb5a9-d0ab-4072-abe1-119c9c1e4f4a"),
537 Uuid::new_v5(&course_id, b"22959aad-26fc-4212-8259-c128cdab8b08"),
538 Uuid::new_v5(&course_id, b"d8ba9e92-4530-4a74-9b11-eb708fa54d40"),
539 Uuid::new_v5(&course_id, b"846f4895-f573-41e2-9926-cd700723ac18"),
540 Some("Best exercise".to_string()),
541 CommonExerciseData {
542 exercise_id: Uuid::new_v5(&course_id, b"44f472e5-b726-4c50-89a1-93f4170673f5"),
543 exercise_slide_id: Uuid::new_v5(
544 &course_id,
545 b"23182b3d-fbf4-4c0d-93fa-e9ddc199cc52",
546 ),
547 exercise_task_id: Uuid::new_v5(&course_id, b"ca105826-5007-439f-87be-c25f9c79506e"),
548 block_id: Uuid::new_v5(&course_id, b"96a9e586-cf88-4cb2-b7c9-efc2bc47e90b"),
549 },
550 );
551 pages::insert_page(
552 conn,
553 NewPage {
554 exercises: vec![exam_exercise_1, exam_exercise_2],
555 exercise_slides: vec![exam_exercise_slide_1, exam_exercise_slide_2],
556 exercise_tasks: vec![exam_exercise_task_1, exam_exercise_task_2],
557 content: serde_json::json!([
558 heading(
559 "The exam",
560 Uuid::parse_str("d6cf16ce-fe78-4e57-8399-e8b63d7fddac").unwrap(),
561 1
562 ),
563 paragraph(
564 "In this exam you're supposed to answer to two easy questions. Good luck!",
565 Uuid::parse_str("474d4f21-798b-4ba0-b39f-120b134e7fa0").unwrap(),
566 ),
567 exam_exercise_block_1,
568 exam_exercise_block_2,
569 ]),
570 url_path: "".to_string(),
571 title: "".to_string(),
572 course_id: None,
573 exam_id: Some(new_exam_id),
574 chapter_id: None,
575 front_page_of_chapter_id: None,
576 content_search_language: None,
577 },
578 teacher,
579 get_seed_spec_fetcher(),
580 models_requests::fetch_service_info,
581 )
582 .await?;
583 course_exams::upsert(conn, new_exam_id, course_id).await?;
584 Ok(new_exam_id)
585}
586
587#[allow(clippy::too_many_arguments)]
588pub async fn create_best_peer_review(
589 conn: &mut PgConnection,
590 course_id: Uuid,
591 exercise_id: Uuid,
592 processing_strategy: peer_or_self_review_configs::PeerReviewProcessingStrategy,
593 accepting_threshold: f32,
594 points_are_all_or_nothing: bool,
595 peer_reviews_to_give: i32,
596 peer_reviews_to_receive: i32,
597) -> Result<()> {
598 let prc = peer_or_self_review_configs::upsert_with_id(
599 conn,
600 PKeyPolicy::Generate,
601 &CmsPeerOrSelfReviewConfig {
602 id: Uuid::new_v4(),
603 course_id,
604 exercise_id: Some(exercise_id),
605 peer_reviews_to_give,
606 peer_reviews_to_receive,
607 accepting_threshold,
608 processing_strategy,
609 points_are_all_or_nothing,
610 review_instructions: None,
611 },
612 )
613 .await?;
614
615 peer_or_self_review_questions::insert(
616 conn,
617 PKeyPolicy::Generate,
618 &CmsPeerOrSelfReviewQuestion {
619 id: Uuid::new_v4(),
620 peer_or_self_review_config_id: prc.id,
621 order_number: 0,
622 question: "What are your thoughts on the answer".to_string(),
623 question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Essay,
624 answer_required: true,
625 weight: 0.0,
626 },
627 )
628 .await?;
629
630 peer_or_self_review_questions::insert(
631 conn,
632 PKeyPolicy::Generate,
633 &CmsPeerOrSelfReviewQuestion {
634 id: Uuid::new_v4(),
635 peer_or_self_review_config_id: prc.id,
636 order_number: 1,
637 question: "Was the answer correct?".to_string(),
638 question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
639 answer_required: true,
640 weight: 0.0,
641 },
642 )
643 .await?;
644
645 peer_or_self_review_questions::insert(
646 conn,
647 PKeyPolicy::Generate,
648 &CmsPeerOrSelfReviewQuestion {
649 id: Uuid::new_v4(),
650 peer_or_self_review_config_id: prc.id,
651 order_number: 2,
652 question: "Was the answer good?".to_string(),
653 question_type: peer_or_self_review_questions::PeerOrSelfReviewQuestionType::Scale,
654 answer_required: true,
655 weight: 0.0,
656 },
657 )
658 .await?;
659
660 exercises::set_exercise_to_use_exercise_specific_peer_or_self_review_config(
661 conn,
662 exercise_id,
663 true,
664 false,
665 false,
666 )
667 .await?;
668 Ok(())
669}