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