headless_lms_server/programs/seed/builder/
exercise.rs

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