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 {
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 {
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 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}