1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use uuid::Uuid;
7
8use super::error::{EditError, EditErrorCode};
9use super::op::Op;
10use super::path::{is_course_root, parse_uuid, split_path};
11use super::{CreatedIdLog, CreatedIds, new_item_objects, non_empty_string};
12use crate::ids::IdGenerator;
13
14#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct CourseSettings {
17 pub course: CourseSettingsCourse,
18 pub modules: Vec<ModuleSettings>,
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize)]
24pub struct CourseSettingsCourse {
25 pub id: Uuid,
26 pub slug: String,
27 pub language_code: String,
28 pub name: String,
29 pub description: Option<String>,
30 pub is_draft: bool,
31 pub is_test_mode: bool,
32 pub is_unlisted: bool,
33 pub chapter_locking_enabled: bool,
34 pub base_module_completion_requires_n_submodule_completions: i32,
35 pub ai_policy: AiPolicy,
36 pub audiences: Vec<Audience>,
37 pub closed_at: Option<chrono::DateTime<chrono::Utc>>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(rename_all = "kebab-case")]
43pub enum AiPolicy {
44 NotSet,
45 NoAi,
46 PlanningOnly,
47 Limited,
48 FullUse,
49 Required,
50}
51
52impl AiPolicy {
53 fn names() -> Vec<String> {
55 schemars::schema_for!(AiPolicy)
56 .get("enum")
57 .and_then(Value::as_array)
58 .map(|names| {
59 names
60 .iter()
61 .filter_map(Value::as_str)
62 .map(str::to_string)
63 .collect()
64 })
65 .unwrap_or_default()
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize)]
71pub struct Audience {
72 pub id: Uuid,
73 pub name: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize)]
78pub struct ModuleSettings {
79 pub id: Uuid,
80 pub name: Option<String>,
82 pub completion_policy: CompletionPolicy,
83 pub certification_enabled: bool,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize)]
88pub struct CompletionPolicy {
89 pub policy: CompletionPolicyKind,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub number_of_points_treshold: Option<i32>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub number_of_exercises_attempted_treshold: Option<i32>,
94 pub requires_exam: bool,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
99#[serde(rename_all = "kebab-case")]
100pub enum CompletionPolicyKind {
101 Automatic,
102 Manual,
103}
104
105impl CompletionPolicy {
106 pub fn manual() -> Self {
108 Self {
109 policy: CompletionPolicyKind::Manual,
110 number_of_points_treshold: None,
111 number_of_exercises_attempted_treshold: None,
112 requires_exam: false,
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq)]
119pub struct SettingsResult {
120 pub settings: CourseSettings,
121 pub created: Vec<CreatedIds>,
122}
123
124pub(crate) const EDITABLE_COURSE_FIELDS: [&str; 6] = [
126 "name",
127 "description",
128 "is_unlisted",
129 "chapter_locking_enabled",
130 "base_module_completion_requires_n_submodule_completions",
131 "ai_policy",
132];
133pub(crate) const READ_ONLY_COURSE_FIELDS: [&str; 6] = [
135 "id",
136 "slug",
137 "language_code",
138 "is_draft",
139 "is_test_mode",
140 "closed_at",
141];
142pub(crate) const EDITABLE_MODULE_FIELDS: [&str; 2] = ["completion_policy", "certification_enabled"];
144pub(crate) const POLICY_FIELDS: [&str; 4] = [
146 "policy",
147 "number_of_points_treshold",
148 "number_of_exercises_attempted_treshold",
149 "requires_exam",
150];
151
152pub fn apply(
154 doc: &CourseSettings,
155 ops: &[Op],
156 ids: &dyn IdGenerator,
157) -> Result<SettingsResult, EditError> {
158 let mut settings = doc.clone();
159 let mut ids = CreatedIdLog::new(ids);
160 for (index, op) in ops.iter().enumerate() {
161 ids.set_op(index);
162 apply_op(&mut settings, op, &mut ids).map_err(|e| e.at_op(index))?;
163 }
164 Ok(SettingsResult {
165 settings,
166 created: ids.into_created(),
167 })
168}
169
170fn apply_op(
171 settings: &mut CourseSettings,
172 op: &Op,
173 ids: &mut CreatedIdLog,
174) -> Result<(), EditError> {
175 let segments = split_path(op.path()).ok_or_else(|| {
176 EditError::new(
177 EditErrorCode::InvalidPath,
178 "paths look like /course/name or /<module id>/completion_policy",
179 )
180 })?;
181 if is_course_root(&segments[0], settings.course.id) {
182 return course_op(&mut settings.course, op, &segments[1..], ids);
183 }
184 let id = parse_uuid(&segments[0], op.path())?;
185 if let Some(position) = settings.course.audiences.iter().position(|a| a.id == id) {
186 return match (op, segments.get(1).map(String::as_str)) {
187 (Op::Remove { .. }, None) => {
188 settings.course.audiences.remove(position);
189 Ok(())
190 }
191 (Op::Replace { value, .. }, Some("name")) => {
192 settings.course.audiences[position].name =
193 non_empty_string(Some(value), "an audience name")?;
194 Ok(())
195 }
196 _ => Err(EditError::new(
197 EditErrorCode::InvalidPath,
198 "audiences are added to /course/audiences, renamed at /<id>/name and removed at /<id>",
199 )),
200 };
201 }
202 let module = settings
203 .modules
204 .iter_mut()
205 .find(|m| m.id == id)
206 .ok_or_else(|| {
207 EditError::new(
208 EditErrorCode::NotFound,
209 format!("no module or audience {id} in this course"),
210 )
211 .with_fix("get_course_settings lists module and audience ids")
212 })?;
213 module_op(module, op, &segments[1..])
214}
215
216fn course_op(
217 course: &mut CourseSettingsCourse,
218 op: &Op,
219 rest: &[String],
220 ids: &mut CreatedIdLog,
221) -> Result<(), EditError> {
222 let Some(field) = rest.first().map(String::as_str) else {
223 return Err(EditError::new(
224 EditErrorCode::InvalidPath,
225 "name a course field, e.g. /course/name",
226 ));
227 };
228 if field == "audiences" {
229 return match op {
230 Op::Add { value, .. } if rest.len() == 1 => {
231 for object in new_item_objects(
232 value,
233 "an audience",
234 "a new audience is {\"name\":\"…\"}, or add a list of them",
235 &["name"],
236 )? {
237 let id = ids.new_id();
238 course.audiences.push(Audience {
239 id,
240 name: non_empty_string(object.get("name"), "an audience name")?,
241 });
242 }
243 Ok(())
244 }
245 _ => Err(EditError::new(
246 EditErrorCode::InvalidPath,
247 "audiences are added with add on /course/audiences and removed with remove on /<audience id>",
248 )),
249 };
250 }
251 if READ_ONLY_COURSE_FIELDS.contains(&field) {
252 return Err(EditError::new(
253 EditErrorCode::ReadOnlyField,
254 format!(
255 "{field} is read-only for agents; publishing, unpublishing and closing a course are done by the teacher in the web UI"
256 ),
257 ));
258 }
259 if !EDITABLE_COURSE_FIELDS.contains(&field) || rest.len() > 1 {
260 return Err(unknown_course_field(&rest.join("/")));
261 }
262 let value = match op {
263 Op::Replace { value, .. } => value,
264 Op::Remove { .. } => &Value::Null,
265 _ => {
266 return Err(EditError::new(
267 EditErrorCode::InvalidOp,
268 "course fields are set with replace",
269 ));
270 }
271 };
272 match field {
273 "name" => course.name = non_empty_string(Some(value), "the course name")?,
274 "description" => {
275 course.description = match value {
276 Value::Null => None,
277 Value::String(s) => Some(s.clone()),
278 _ => {
279 return Err(EditError::new(
280 EditErrorCode::InvalidValue,
281 "description is a string or null",
282 ));
283 }
284 }
285 }
286 "is_unlisted" => course.is_unlisted = boolean(Some(value), field)?,
287 "chapter_locking_enabled" => course.chapter_locking_enabled = boolean(Some(value), field)?,
288 "base_module_completion_requires_n_submodule_completions" => {
289 course.base_module_completion_requires_n_submodule_completions = whole_number(value)
290 .ok_or_else(|| {
291 EditError::new(
292 EditErrorCode::InvalidValue,
293 format!("{field} is a whole number, 0 or more"),
294 )
295 })?
296 }
297 "ai_policy" => {
298 course.ai_policy = serde_json::from_value(value.clone()).map_err(|_| {
299 EditError::new(
300 EditErrorCode::InvalidValue,
301 format!("ai_policy is one of {}", AiPolicy::names().join(", ")),
302 )
303 })?
304 }
305 _ => return Err(unknown_course_field(field)),
306 }
307 Ok(())
308}
309
310fn unknown_course_field(path: &str) -> EditError {
312 EditError::new(
313 EditErrorCode::UnknownField,
314 format!("the course has no editable setting \"{path}\""),
315 )
316 .with_fix(format!(
317 "editable: {}, audiences",
318 EDITABLE_COURSE_FIELDS.join(", ")
319 ))
320}
321
322fn module_op(module: &mut ModuleSettings, op: &Op, rest: &[String]) -> Result<(), EditError> {
323 match (rest.first().map(String::as_str), op) {
324 (Some("name"), _) => Err(EditError::new(
325 EditErrorCode::WrongTool,
326 "module names are edited with patch_course_structure",
327 )),
328 (Some("certification_enabled"), Op::Replace { value, .. }) if rest.len() == 1 => {
329 module.certification_enabled = boolean(Some(value), "certification_enabled")?;
330 Ok(())
331 }
332 (Some("completion_policy"), Op::Replace { value, .. }) if rest.len() <= 2 => {
333 match rest.get(1) {
334 None => {
335 module.completion_policy = parse_policy(value)?;
336 Ok(())
337 }
338 Some(field) => set_policy_field(&mut module.completion_policy, field, Some(value)),
339 }
340 }
341 (Some("completion_policy"), Op::Remove { .. }) if rest.len() == 2 => {
342 set_policy_field(&mut module.completion_policy, &rest[1], None)
343 }
344 _ => Err(EditError::new(
345 EditErrorCode::InvalidPath,
346 "module settings are completion_policy (or one of its fields) and certification_enabled, set with replace",
347 )),
348 }
349}
350
351fn parse_policy(value: &Value) -> Result<CompletionPolicy, EditError> {
352 let object = value.as_object().ok_or_else(|| {
353 EditError::new(
354 EditErrorCode::InvalidValue,
355 "completion_policy is an object such as {\"policy\":\"manual\"}",
356 )
357 })?;
358 let mut policy = CompletionPolicy::manual();
359 set_policy_field(&mut policy, "policy", object.get("policy"))?;
360 for (field, value) in object.iter().filter(|(field, _)| *field != "policy") {
361 set_policy_field(&mut policy, field, Some(value))?;
362 }
363 Ok(policy)
364}
365
366fn set_policy_field(
369 policy: &mut CompletionPolicy,
370 field: &str,
371 value: Option<&Value>,
372) -> Result<(), EditError> {
373 let threshold = |value: Option<&Value>| -> Result<Option<i32>, EditError> {
374 match value {
375 None | Some(Value::Null) => Ok(None),
376 Some(value) => whole_number(value).map(Some).ok_or_else(|| {
377 EditError::new(
378 EditErrorCode::InvalidValue,
379 format!("{field} is a whole number, 0 or more, or null"),
380 )
381 }),
382 }
383 };
384 match field {
385 "policy" => {
386 policy.policy = match value.and_then(Value::as_str) {
387 Some("automatic") => CompletionPolicyKind::Automatic,
388 Some("manual") => {
389 *policy = CompletionPolicy::manual();
390 return Ok(());
391 }
392 _ => {
393 return Err(EditError::new(
394 EditErrorCode::InvalidValue,
395 "policy is \"automatic\" or \"manual\"",
396 ));
397 }
398 }
399 }
400 "number_of_points_treshold" => policy.number_of_points_treshold = threshold(value)?,
401 "number_of_exercises_attempted_treshold" => {
402 policy.number_of_exercises_attempted_treshold = threshold(value)?
403 }
404 "requires_exam" => policy.requires_exam = boolean(value, "requires_exam")?,
405 other if other.contains("threshold") => {
406 return Err(EditError::new(
407 EditErrorCode::UnknownField,
408 "the field names are spelled \"treshold\"",
409 )
410 .with_fix(other.replace("threshold", "treshold")));
411 }
412 other => {
413 return Err(EditError::new(
414 EditErrorCode::UnknownField,
415 format!("completion_policy has no \"{other}\""),
416 )
417 .with_fix(POLICY_FIELDS.join(", ")));
418 }
419 }
420 validate_policy(policy)
421}
422
423fn validate_policy(policy: &CompletionPolicy) -> Result<(), EditError> {
424 let has_requirements = policy.number_of_points_treshold.is_some()
425 || policy.number_of_exercises_attempted_treshold.is_some()
426 || policy.requires_exam;
427 if policy.policy == CompletionPolicyKind::Manual && has_requirements {
428 return Err(EditError::new(
429 EditErrorCode::InvalidValue,
430 "a manual completion policy has no thresholds; set policy to \"automatic\" first",
431 ));
432 }
433 Ok(())
434}
435
436fn whole_number(value: &Value) -> Option<i32> {
438 value
439 .as_i64()
440 .and_then(|n| i32::try_from(n).ok())
441 .filter(|n| *n >= 0)
442}
443
444fn boolean(value: Option<&Value>, field: &str) -> Result<bool, EditError> {
445 value.and_then(Value::as_bool).ok_or_else(|| {
446 EditError::new(
447 EditErrorCode::InvalidValue,
448 format!("{field} is true or false"),
449 )
450 })
451}
452
453#[cfg(test)]
454mod tests {
455 use pretty_assertions::assert_eq;
456 use serde_json::json;
457
458 use super::*;
459 use crate::engine::op::PatchTarget;
460 use crate::ids::SequentialIds;
461 use crate::test_fixtures::{id, p};
462
463 fn sample() -> CourseSettings {
464 CourseSettings {
465 course: CourseSettingsCourse {
466 id: id("c1"),
467 slug: "ohpe".into(),
468 language_code: "fi-FI".into(),
469 name: "Ohjelmoinnin perusteet".into(),
470 description: Some("…".into()),
471 is_draft: false,
472 is_test_mode: false,
473 is_unlisted: false,
474 chapter_locking_enabled: false,
475 base_module_completion_requires_n_submodule_completions: 0,
476 ai_policy: AiPolicy::NoAi,
477 audiences: vec![Audience {
478 id: id("au1"),
479 name: "Upper secondary students".into(),
480 }],
481 closed_at: None,
482 },
483 modules: vec![
484 ModuleSettings {
485 id: id("m1"),
486 name: None,
487 completion_policy: CompletionPolicy {
488 policy: CompletionPolicyKind::Automatic,
489 number_of_points_treshold: Some(60),
490 number_of_exercises_attempted_treshold: None,
491 requires_exam: false,
492 },
493 certification_enabled: true,
494 },
495 ModuleSettings {
496 id: id("m2"),
497 name: Some("Advanced".into()),
498 completion_policy: CompletionPolicy::manual(),
499 certification_enabled: false,
500 },
501 ],
502 }
503 }
504
505 fn run(ops: Value) -> Result<SettingsResult, EditError> {
506 let ops = crate::test_fixtures::ops(ops, PatchTarget::CourseSettings)?;
507 let ids = SequentialIds::default();
508 apply(&sample(), &ops, &ids)
509 }
510
511 #[test]
512 fn set_course_fields() {
513 let result = run(json!([
514 {"op":"replace","path":"/course/name","value":"Ohjelmoinnin perusteet 2027"},
515 {"op":"replace","path":"/course/description","value":null},
516 {"op":"replace","path":"/course/is_unlisted","value":true},
517 {"op":"replace","path":"/course/ai_policy","value":"planning-only"},
518 {"op":"replace","path":"/course/base_module_completion_requires_n_submodule_completions","value":2},
519 ]))
520 .unwrap();
521 let course = result.settings.course;
522 assert_eq!(course.name, "Ohjelmoinnin perusteet 2027");
523 assert_eq!(course.description, None);
524 assert!(course.is_unlisted);
525 assert_eq!(course.ai_policy, AiPolicy::PlanningOnly);
526 assert_eq!(
527 course.base_module_completion_requires_n_submodule_completions,
528 2
529 );
530 }
531
532 #[test]
533 fn chapter_locking() {
534 let result =
535 run(json!([{"op":"replace","path":"/course/chapter_locking_enabled","value":true}]))
536 .unwrap();
537 assert!(result.settings.course.chapter_locking_enabled);
538 }
539
540 #[test]
541 fn add_and_remove_audience() {
542 let result = run(json!([
543 {"op":"add","path":"/course/audiences","value":{"name":"Adults"}},
544 {"op":"remove","path":p("au1")},
545 ]))
546 .unwrap();
547 assert_eq!(
548 result.settings.course.audiences,
549 vec![Audience {
550 id: SequentialIds::nth(1),
551 name: "Adults".into()
552 }]
553 );
554 assert_eq!(result.created[0].ids, vec![SequentialIds::nth(1)]);
555 }
556
557 #[test]
558 fn completion_policy_whole_and_one_threshold() {
559 let result = run(json!([
560 {"op":"replace","path":format!("{}/completion_policy", p("m2")),"value":{"policy":"automatic","number_of_points_treshold":60,"number_of_exercises_attempted_treshold":null,"requires_exam":false}},
561 {"op":"replace","path":format!("{}/completion_policy/number_of_points_treshold", p("m2")),"value":50},
562 ]))
563 .unwrap();
564 let policy = &result.settings.modules[1].completion_policy;
565 assert_eq!(policy.policy, CompletionPolicyKind::Automatic);
566 assert_eq!(policy.number_of_points_treshold, Some(50));
567 }
568
569 #[test]
570 fn partial_policy_defaults_and_manual_rules() {
571 let result = run(json!([{"op":"replace","path":format!("{}/completion_policy", p("m1")),"value":{"policy":"automatic"}}])).unwrap();
572 assert_eq!(
573 result.settings.modules[0]
574 .completion_policy
575 .number_of_points_treshold,
576 None
577 );
578 let error = run(json!([{"op":"replace","path":format!("{}/completion_policy/number_of_points_treshold", p("m2")),"value":5}])).unwrap_err();
579 assert!(error.message.contains("automatic"));
580 let manual = run(json!([{"op":"replace","path":format!("{}/completion_policy/policy", p("m1")),"value":"manual"}])).unwrap();
581 assert_eq!(
582 manual.settings.modules[0].completion_policy,
583 CompletionPolicy::manual()
584 );
585 }
586
587 #[test]
588 fn certificates() {
589 let result = run(json!([{"op":"replace","path":format!("{}/certification_enabled", p("m2")),"value":true}])).unwrap();
590 assert!(result.settings.modules[1].certification_enabled);
591 }
592
593 #[test]
594 fn publishing_switches_are_read_only() {
595 for field in [
596 "is_draft",
597 "is_test_mode",
598 "slug",
599 "language_code",
600 "closed_at",
601 ] {
602 let error =
603 run(json!([{"op":"replace","path":format!("/course/{field}"),"value":true}]))
604 .unwrap_err();
605 assert_eq!(error.code, EditErrorCode::ReadOnlyField, "{field}");
606 assert!(error.message.contains("web UI"));
607 }
608 }
609
610 #[test]
611 fn misspelling_and_wrong_tool_teach() {
612 let error = run(json!([{"op":"replace","path":format!("{}/completion_policy/number_of_points_threshold", p("m1")),"value":5}])).unwrap_err();
613 assert!(error.fix.unwrap().contains("treshold"));
614 let error = run(json!([{"op":"replace","path":format!("{}/name", p("m2")),"value":"x"}]))
615 .unwrap_err();
616 assert_eq!(error.code, EditErrorCode::WrongTool);
617 let error = run(json!([{"op":"replace","path":"/course/ai_policy","value":"whatever"}]))
618 .unwrap_err();
619 assert_eq!(error.code, EditErrorCode::InvalidValue);
620 }
621
622 #[test]
623 fn removing_a_threshold_clears_it_and_deeper_paths_are_rejected() {
624 let result = run(json!([
625 {"op":"remove","path":format!("{}/completion_policy/number_of_points_treshold", p("m1"))},
626 ]))
627 .unwrap();
628 assert_eq!(
629 result.settings.modules[0]
630 .completion_policy
631 .number_of_points_treshold,
632 None
633 );
634 assert!(
635 run(json!([
636 {"op":"replace","path":format!("{}/completion_policy/policy/whatever", p("m1")),"value":"manual"},
637 ]))
638 .is_err()
639 );
640 }
641
642 #[test]
643 fn every_field_is_editable_or_read_only() {
644 let document = serde_json::to_value(sample()).unwrap();
645 let fields = |value: &Value| {
646 let mut keys: Vec<String> = value.as_object().unwrap().keys().cloned().collect();
647 keys.sort();
648 keys
649 };
650 let declared = |lists: &[&[&str]]| {
651 let mut keys: Vec<String> = lists.concat().into_iter().map(String::from).collect();
652 keys.sort();
653 keys
654 };
655 assert_eq!(
656 fields(&document["course"]),
657 declared(&[
658 &EDITABLE_COURSE_FIELDS,
659 &READ_ONLY_COURSE_FIELDS,
660 &["audiences"]
661 ])
662 );
663 assert_eq!(
664 fields(&document["modules"][0]),
665 declared(&[&EDITABLE_MODULE_FIELDS, &["id", "name"]])
666 );
667 }
668
669 #[test]
670 fn an_unknown_ai_policy_lists_the_policies() {
671 let error = run(json!([{"op":"replace","path":"/course/ai_policy","value":"whatever"}]))
672 .unwrap_err();
673 assert_eq!(
674 error.message,
675 "ai_policy is one of not-set, no-ai, planning-only, limited, full-use, required"
676 );
677 }
678
679 #[test]
680 fn several_audiences_are_added_from_a_list() {
681 let result = run(json!([
682 {"op":"add","path":"/course/audiences","value":[{"name":"A"},{"name":"B"}]},
683 ]))
684 .unwrap();
685 assert_eq!(result.settings.course.audiences.len(), 3);
686 }
687}