1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use headless_lms_utils::document_schema_processor::GutenbergBlock;
4use serde_json::Value;
5use uuid::Uuid;
6
7use headless_lms_models::{
8 pages::{CmsPageExercise, CmsPageExerciseSlide, CmsPageExerciseTask},
9 peer_or_self_review_configs::CmsPeerOrSelfReviewConfig,
10 peer_or_self_review_questions::CmsPeerOrSelfReviewQuestion,
11};
12
13use crate::programs::seed::{
14 builder::{context::SeedContext, json_source::JsonSource},
15 seed_helpers::{ExampleExerciseFlexibleParams, example_exercise_flexible},
16};
17use headless_lms_utils::attributes;
18
19#[derive(Debug, Clone)]
21pub struct ExerciseIds {
22 pub exercise_id: Uuid,
23 pub slide_id: Uuid,
24 pub task_id: Uuid,
25 pub block_id: Uuid,
26}
27
28#[derive(Debug, Clone)]
30pub enum ExerciseBuilder {
31 ExampleExercise {
34 ids: ExerciseIds,
35 assignment_blocks: Vec<GutenbergBlock>,
36 name: String,
37 options: Value,
38 needs_peer_review: bool,
39 peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
40 peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
41 },
42 Quizzes {
44 ids: ExerciseIds,
45 name: String,
46 needs_peer_review: bool,
47 deadline: Option<DateTime<Utc>>,
48 spec: JsonSource,
49 assignment_blocks: Vec<GutenbergBlock>,
50 teacher_reviews_answer_after_locking: bool,
51 },
52 Tmc {
54 ids: ExerciseIds,
55 name: String,
56 deadline: Option<DateTime<Utc>>,
57 spec: JsonSource,
58 assignment_blocks: Vec<GutenbergBlock>,
59 },
60}
61
62impl ExerciseBuilder {
63 pub fn quizzes(
64 name: impl Into<String>,
65 ids: ExerciseIds,
66 needs_pr: bool,
67 deadline: Option<DateTime<Utc>>,
68 spec: JsonSource,
69 assignment_blocks: Vec<GutenbergBlock>,
70 teacher_reviews_answer_after_locking: bool,
71 ) -> Self {
72 Self::Quizzes {
73 ids,
74 name: name.into(),
75 needs_peer_review: needs_pr,
76 deadline,
77 spec,
78 assignment_blocks,
79 teacher_reviews_answer_after_locking,
80 }
81 }
82
83 pub fn tmc(
84 name: impl Into<String>,
85 ids: ExerciseIds,
86 deadline: Option<DateTime<Utc>>,
87 spec: JsonSource,
88 assignment_blocks: Vec<GutenbergBlock>,
89 ) -> Self {
90 Self::Tmc {
91 ids,
92 name: name.into(),
93 deadline,
94 spec,
95 assignment_blocks,
96 }
97 }
98
99 pub fn example_exercise(
100 name: impl Into<String>,
101 ids: ExerciseIds,
102 assignment_blocks: Vec<GutenbergBlock>,
103 options: Value,
104 needs_peer_review: bool,
105 peer_or_self_review_config: Option<CmsPeerOrSelfReviewConfig>,
106 peer_or_self_review_questions: Option<Vec<CmsPeerOrSelfReviewQuestion>>,
107 ) -> Self {
108 Self::ExampleExercise {
109 ids,
110 assignment_blocks,
111 name: name.into(),
112 options,
113 needs_peer_review,
114 peer_or_self_review_config,
115 peer_or_self_review_questions,
116 }
117 }
118
119 pub(crate) fn to_cms(
120 &self,
121 _cx: &SeedContext,
122 ) -> Result<(
123 GutenbergBlock,
124 CmsPageExercise,
125 CmsPageExerciseSlide,
126 CmsPageExerciseTask,
127 )> {
128 Ok(match self {
129 ExerciseBuilder::Quizzes {
130 ids,
131 name,
132 needs_peer_review: _,
133 deadline: _,
134 spec,
135 assignment_blocks,
136 teacher_reviews_answer_after_locking,
137 } => {
138 let spec_v = spec.load()?;
139 let assignment_json = serde_json::to_value(assignment_blocks)?;
140 let (block, exercise, mut slides, mut tasks) =
141 example_exercise_flexible(ExampleExerciseFlexibleParams {
142 exercise_id: ids.exercise_id,
143 exercise_name: name.clone(),
144 exercise_slides: vec![(
145 ids.slide_id,
146 vec![(ids.task_id, "quizzes".to_string(), assignment_json, spec_v)],
147 )],
148 client_id: ids.block_id,
149 needs_peer_review: None,
150 peer_or_self_review_config: None,
151 peer_or_self_review_questions: None,
152 teacher_reviews_answer_after_locking: *teacher_reviews_answer_after_locking,
153 });
154 let slide = slides.swap_remove(0);
155 let task = tasks.swap_remove(0);
156 (block, exercise, slide, task)
157 }
158
159 ExerciseBuilder::Tmc {
160 ids,
161 name,
162 deadline: _,
163 spec,
164 assignment_blocks,
165 } => {
166 let spec_v = spec.load()?;
167 let assignment_json = serde_json::to_value(assignment_blocks)?;
168 let (block, exercise, mut slides, mut tasks) =
169 example_exercise_flexible(ExampleExerciseFlexibleParams {
170 exercise_id: ids.exercise_id,
171 exercise_name: name.clone(),
172 exercise_slides: vec![(
173 ids.slide_id,
174 vec![(ids.task_id, "tmc".to_string(), assignment_json, spec_v)],
175 )],
176 client_id: ids.block_id,
177 needs_peer_review: Some(false),
178 peer_or_self_review_config: None,
179 peer_or_self_review_questions: None,
180 teacher_reviews_answer_after_locking: true,
181 });
182 let slide = slides.swap_remove(0);
183 let task = tasks.swap_remove(0);
184 (block, exercise, slide, task)
185 }
186
187 ExerciseBuilder::ExampleExercise {
188 ids,
189 assignment_blocks,
190 name,
191 options,
192 needs_peer_review,
193 peer_or_self_review_config,
194 peer_or_self_review_questions,
195 } => {
196 let assignment_json = serde_json::to_value(assignment_blocks)?;
197 let (_block, mut exercise, slides, tasks) =
198 example_exercise_flexible(ExampleExerciseFlexibleParams {
199 exercise_id: ids.exercise_id,
200 exercise_name: name.clone(),
201 exercise_slides: vec![(
202 ids.slide_id,
203 vec![(
204 ids.task_id,
205 "example-exercise".to_string(),
206 assignment_json,
207 options.clone(),
208 )],
209 )],
210 client_id: ids.block_id,
211 needs_peer_review: Some(*needs_peer_review),
212 peer_or_self_review_config: peer_or_self_review_config.clone(),
213 peer_or_self_review_questions: peer_or_self_review_questions.clone(),
214 teacher_reviews_answer_after_locking: true,
215 });
216 exercise.use_course_default_peer_or_self_review_config =
217 *needs_peer_review && peer_or_self_review_config.is_none();
218
219 let slide = slides
220 .into_iter()
221 .next()
222 .expect("example exercise must produce one slide");
223 let task = tasks
224 .into_iter()
225 .next()
226 .expect("example exercise must produce one task");
227
228 let b = GutenbergBlock {
229 client_id: ids.block_id,
230 name: "moocfi/exercise".to_string(),
231 is_valid: true,
232 attributes: attributes! {
233 "id": ids.exercise_id,
234 "name": exercise.name,
235 "dropCap": false,
236 },
237 inner_blocks: vec![],
238 };
239
240 (b, exercise, slide, task)
241 }
242 })
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::programs::seed::seed_helpers::paragraph;
250 use chrono::Utc;
251 use serde_json::json;
252 use uuid::Uuid;
253
254 fn create_test_ids() -> ExerciseIds {
255 ExerciseIds {
256 exercise_id: Uuid::new_v4(),
257 slide_id: Uuid::new_v4(),
258 task_id: Uuid::new_v4(),
259 block_id: Uuid::new_v4(),
260 }
261 }
262
263 #[test]
264 fn test_quizzes_builder_creation() {
265 let ids = create_test_ids();
266 let assignment_blocks = vec![paragraph("Answer this question.", Uuid::new_v4())];
267 let spec = JsonSource::Inline(json!({"type": "multiple_choice"}));
268 let deadline = Some(Utc::now());
269
270 let builder = ExerciseBuilder::quizzes(
271 "Test Quiz",
272 ids.clone(),
273 true,
274 deadline,
275 spec,
276 assignment_blocks.clone(),
277 true,
278 );
279
280 match builder {
281 ExerciseBuilder::Quizzes {
282 ids: builder_ids,
283 name,
284 needs_peer_review,
285 deadline: builder_deadline,
286 spec: _,
287 assignment_blocks: builder_blocks,
288 teacher_reviews_answer_after_locking: _,
289 } => {
290 assert_eq!(name, "Test Quiz");
291 assert!(needs_peer_review);
292 assert_eq!(builder_deadline, deadline);
293 assert_eq!(builder_ids.exercise_id, ids.exercise_id);
294 assert_eq!(builder_blocks.len(), 1);
295 assert_eq!(builder_blocks[0].name, "core/paragraph");
296 }
297 _ => panic!("Expected Quizzes variant"),
298 }
299 }
300
301 #[test]
302 fn test_tmc_builder_creation() {
303 let ids = create_test_ids();
304 let assignment_blocks = vec![paragraph("Write an `add` function.", Uuid::new_v4())];
305 let spec = JsonSource::Inline(json!({"type": "tmc"}));
306 let deadline = Some(Utc::now());
307
308 let builder = ExerciseBuilder::tmc(
309 "Test TMC",
310 ids.clone(),
311 deadline,
312 spec,
313 assignment_blocks.clone(),
314 );
315
316 match builder {
317 ExerciseBuilder::Tmc {
318 ids: builder_ids,
319 name,
320 deadline: builder_deadline,
321 spec: _,
322 assignment_blocks: builder_blocks,
323 } => {
324 assert_eq!(name, "Test TMC");
325 assert_eq!(builder_deadline, deadline);
326 assert_eq!(builder_ids.exercise_id, ids.exercise_id);
327 assert_eq!(builder_blocks.len(), 1);
328 assert_eq!(builder_blocks[0].name, "core/paragraph");
329 }
330 _ => panic!("Expected Tmc variant"),
331 }
332 }
333
334 #[test]
335 fn test_example_exercise_builder_creation() {
336 let ids = create_test_ids();
337 let assignment_blocks = vec![paragraph("Answer this question.", Uuid::new_v4())];
338 let options = json!({
339 "options": [
340 {"text": "Option 1", "correct": true},
341 {"text": "Option 2", "correct": false}
342 ]
343 });
344
345 let builder = ExerciseBuilder::example_exercise(
346 "Test Example Exercise",
347 ids.clone(),
348 assignment_blocks.clone(),
349 options.clone(),
350 false,
351 None,
352 None,
353 );
354
355 match builder {
356 ExerciseBuilder::ExampleExercise {
357 ids: builder_ids,
358 assignment_blocks: builder_blocks,
359 name,
360 options: builder_options,
361 needs_peer_review: _,
362 peer_or_self_review_config: _,
363 peer_or_self_review_questions: _,
364 } => {
365 assert_eq!(name, "Test Example Exercise");
366 assert_eq!(builder_ids.exercise_id, ids.exercise_id);
367 assert_eq!(builder_blocks.len(), 1);
368 assert_eq!(builder_blocks[0].name, "core/paragraph");
369 assert_eq!(builder_options, options);
370 }
371 _ => panic!("Expected ExampleExercise variant"),
372 }
373 }
374
375 #[test]
376 fn test_exercise_ids_structure() {
377 let ids = create_test_ids();
378
379 assert_ne!(ids.exercise_id, ids.slide_id);
380 assert_ne!(ids.exercise_id, ids.task_id);
381 assert_ne!(ids.exercise_id, ids.block_id);
382 assert_ne!(ids.slide_id, ids.task_id);
383 assert_ne!(ids.slide_id, ids.block_id);
384 assert_ne!(ids.task_id, ids.block_id);
385 }
386
387 #[test]
388 fn test_builder_without_deadline() {
389 let ids = create_test_ids();
390 let assignment_blocks = vec![paragraph("Answer this question.", Uuid::new_v4())];
391 let spec = JsonSource::Inline(json!({"type": "multiple_choice"}));
392
393 let builder = ExerciseBuilder::quizzes(
394 "No Deadline Quiz",
395 ids,
396 false,
397 None,
398 spec,
399 assignment_blocks,
400 true,
401 );
402
403 match builder {
404 ExerciseBuilder::Quizzes {
405 deadline,
406 needs_peer_review,
407 teacher_reviews_answer_after_locking: _,
408 ..
409 } => {
410 assert_eq!(deadline, None);
411 assert!(!needs_peer_review);
412 }
413 _ => panic!("Expected Quizzes variant"),
414 }
415 }
416
417 #[test]
418 fn test_string_conversion_for_names() {
419 let ids = create_test_ids();
420 let assignment_blocks = vec![paragraph("Answer this question.", Uuid::new_v4())];
421 let spec = JsonSource::Inline(json!({"type": "multiple_choice"}));
422
423 let builder1 = ExerciseBuilder::quizzes(
424 "String name".to_string(),
425 ids.clone(),
426 false,
427 None,
428 spec.clone(),
429 assignment_blocks.clone(),
430 true,
431 );
432
433 let builder2 =
434 ExerciseBuilder::quizzes("&str name", ids, false, None, spec, assignment_blocks, true);
435
436 match (builder1, builder2) {
437 (
438 ExerciseBuilder::Quizzes { name: name1, .. },
439 ExerciseBuilder::Quizzes { name: name2, .. },
440 ) => {
441 assert_eq!(name1, "String name");
442 assert_eq!(name2, "&str name");
443 }
444 _ => panic!("Expected Quizzes variants"),
445 }
446 }
447}