Skip to main content

headless_lms_mcp/engine/
course_settings.rs

1//! `patch_course_settings`: course and module settings. Publishing switches stay read-only.
2
3use 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/// The settings document `get_course_settings` shows and `patch_course_settings` edits.
15#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct CourseSettings {
17    pub course: CourseSettingsCourse,
18    pub modules: Vec<ModuleSettings>,
19}
20
21/// The course's own settings. Its fields are [`EDITABLE_COURSE_FIELDS`],
22/// [`READ_ONLY_COURSE_FIELDS`] and `audiences`.
23#[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/// The course's AI policy with the names agents use; mirrors the models' `CourseAiPolicy`.
41#[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    /// Every policy's name as agents write it.
54    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/// A target audience of the course.
70#[derive(Debug, Clone, PartialEq, Serialize)]
71pub struct Audience {
72    pub id: Uuid,
73    pub name: String,
74}
75
76/// One module's settings. Its fields are `id`, `name` and [`EDITABLE_MODULE_FIELDS`].
77#[derive(Debug, Clone, PartialEq, Serialize)]
78pub struct ModuleSettings {
79    pub id: Uuid,
80    /// Shown for orientation; renamed with the structure tool.
81    pub name: Option<String>,
82    pub completion_policy: CompletionPolicy,
83    pub certification_enabled: bool,
84}
85
86/// Module completion. The `treshold` spelling matches the stored field names.
87#[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/// Whether a module completes on its own when its thresholds are met.
98#[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    /// The policy of a module completed by hand, which has no thresholds.
107    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/// The new state after a successful call. Nothing is stored until the caller stages it.
118#[derive(Debug, Clone, PartialEq)]
119pub struct SettingsResult {
120    pub settings: CourseSettings,
121    pub created: Vec<CreatedIds>,
122}
123
124/// Course fields agents may set.
125pub(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];
133/// Course fields agents see but only teachers change.
134pub(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];
142/// Module fields agents may set with this tool; the structure tool renames modules.
143pub(crate) const EDITABLE_MODULE_FIELDS: [&str; 2] = ["completion_policy", "certification_enabled"];
144/// The fields of a completion policy.
145pub(crate) const POLICY_FIELDS: [&str; 4] = [
146    "policy",
147    "number_of_points_treshold",
148    "number_of_exercises_attempted_treshold",
149    "requires_exam",
150];
151
152/// Applies `ops` in order to `doc`.
153pub 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
310/// `path` names no course setting agents can edit.
311fn 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
366/// Sets one policy field, or clears it with `None`, and checks the result. Owns the errors for
367/// unknown and misspelled field names.
368fn 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
436/// The value as a whole number from 0 to `i32::MAX`.
437fn 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}