Skip to main content

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. Peer reviewed without a config of its own, it
32    /// uses the course default.
33    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 service exercise with JSON specification
43    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    /// Test My Code exercise
53    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}