Skip to main content

headless_lms_mcp/handlers/exercise/
review_config.rs

1//! Checking a `patch_exercise` call's review config choice, and what switching drops.
2
3use headless_lms_models::{
4    library::agent_changesets::course_state::{CourseView, ExerciseReviewConfig},
5    pages::CmsPageExercise,
6    peer_or_self_review_configs::CmsPeerOrSelfReviewConfig,
7};
8use serde::Serialize;
9use uuid::Uuid;
10
11use super::missing_exercise;
12use crate::engine::{EditError, EditErrorCode};
13use crate::tools::ToolError;
14use crate::tools::args::ReviewConfigChoice;
15
16pub(super) fn refused(message: impl Into<String>, fix: impl Into<String>) -> ToolError {
17    ToolError::Edit(EditError::new(EditErrorCode::FieldNotAllowed, message).with_fix(fix))
18}
19
20/// Refuses a `review_config` that doesn't fit the exercise or the review ops.
21pub(super) fn check_review_config(
22    view: &CourseView,
23    exercise_id: Uuid,
24    choice: Option<ReviewConfigChoice>,
25    has_review_ops: bool,
26) -> Result<(), ToolError> {
27    let exercise = view
28        .staged
29        .exercise(exercise_id)
30        .ok_or_else(|| missing_exercise(view, exercise_id))?;
31    let config = &exercise.review_config;
32    if has_review_ops && !exercise.has_reviews() {
33        return Err(refused(
34            "the exercise has neither peer nor self reviews, so its review instructions aren't shown",
35            "the teacher turns reviews on in the exercise's settings in the editor",
36        ));
37    }
38    let require_course_default = |message: &str| match view.staged.course_default_review_config {
39        Some(_) => Ok(()),
40        None => Err(refused(
41            message,
42            "the teacher sets one up on the course's default peer review page",
43        )),
44    };
45    match (choice, config) {
46        (Some(ReviewConfigChoice::Fork), _) if !has_review_ops => Err(refused(
47            "fork needs review ops: give the exercise its own review instructions only when they differ from the course default",
48            "add ops on /review_instructions, or leave review_config out",
49        )),
50        (Some(ReviewConfigChoice::Fork), ExerciseReviewConfig::Own { .. }) => Err(refused(
51            "the exercise already has its own review settings",
52            "leave review_config out to edit its own review instructions",
53        )),
54        (Some(ReviewConfigChoice::Fork), ExerciseReviewConfig::Unset) => Err(refused(
55            "the exercise has no review settings to copy the course default over",
56            "the teacher sets up reviews for it in the editor",
57        )),
58        (Some(ReviewConfigChoice::Fork), ExerciseReviewConfig::CourseDefault) => {
59            require_course_default("the course has no default review config to copy")
60        }
61        (Some(ReviewConfigChoice::UseCourseDefault), _) if has_review_ops => Err(refused(
62            "use_course_default drops the exercise's own review instructions, so review ops have nothing to edit",
63            "send the switch alone; edit the course default in another call with review_config edit_course_default",
64        )),
65        (Some(ReviewConfigChoice::UseCourseDefault), ExerciseReviewConfig::Own { .. }) => Ok(()),
66        (Some(ReviewConfigChoice::UseCourseDefault), _) => Err(refused(
67            "the exercise has no review settings of its own to drop",
68            "leave review_config out",
69        )),
70        (Some(ReviewConfigChoice::EditCourseDefault), ExerciseReviewConfig::Own { .. }) => {
71            Err(refused(
72                "the exercise has its own review settings, so editing the course default doesn't change what it shows",
73                "leave review_config out to edit its own review instructions",
74            ))
75        }
76        (Some(ReviewConfigChoice::EditCourseDefault), _) if !has_review_ops => Err(refused(
77            "edit_course_default needs review ops",
78            "add ops on /review_instructions",
79        )),
80        (Some(ReviewConfigChoice::EditCourseDefault), ExerciseReviewConfig::CourseDefault) => {
81            require_course_default("the course has no default review config")
82        }
83        (Some(ReviewConfigChoice::EditCourseDefault), ExerciseReviewConfig::Unset) => Err(refused(
84            "the exercise doesn't use the course default review config",
85            "leave review_config out",
86        )),
87        (None, ExerciseReviewConfig::CourseDefault) if has_review_ops => Err(refused(
88            "the exercise uses the course default review config, which other exercises share",
89            "set review_config: edit_course_default to edit it for every exercise using it (preferred), or fork to give only this exercise its own copy",
90        )),
91        (None, ExerciseReviewConfig::Unset) if has_review_ops => Err(refused(
92            "the exercise has no review settings to hold review instructions",
93            "the teacher sets up reviews for it in the editor",
94        )),
95        (None, _) => Ok(()),
96    }
97}
98
99/// What switching to the course default drops of the exercise's own review settings.
100#[derive(Serialize)]
101pub(super) struct DroppedReviewSettings {
102    pub(super) question_count: usize,
103    /// Settings whose own value differs from the course default's.
104    pub(super) differs_from_course_default: Vec<&'static str>,
105    pub(super) has_review_instructions: bool,
106}
107
108pub(super) fn dropped_settings(
109    exercise: &CmsPageExercise,
110    course_default: Option<&CmsPeerOrSelfReviewConfig>,
111) -> DroppedReviewSettings {
112    let own = exercise.peer_or_self_review_config.as_ref();
113    let differs = match (own, course_default) {
114        (Some(own), Some(default)) => own.settings_differing_from(default),
115        _ => Vec::new(),
116    };
117    DroppedReviewSettings {
118        question_count: exercise
119            .peer_or_self_review_questions
120            .as_ref()
121            .map_or(0, Vec::len),
122        differs_from_course_default: differs,
123        has_review_instructions: own.is_some_and(|own| own.review_instructions.is_some()),
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::test_fixtures::id;
131
132    fn view() -> CourseView {
133        let state = crate::test_fixtures::course_with_exercises();
134        CourseView {
135            course: crate::test_fixtures::course_row(),
136            live: state.clone(),
137            staged: state,
138            changeset: None,
139            staged_entities: Default::default(),
140        }
141    }
142
143    #[test]
144    fn review_config_choices_must_fit_the_exercise_and_the_ops() {
145        let view = view();
146        let (default_user, own) = (id("ex"), id("own"));
147        let refusals = [
148            (default_user, None, true),
149            (default_user, Some(ReviewConfigChoice::Fork), false),
150            (
151                default_user,
152                Some(ReviewConfigChoice::UseCourseDefault),
153                false,
154            ),
155            (
156                default_user,
157                Some(ReviewConfigChoice::EditCourseDefault),
158                false,
159            ),
160            (own, Some(ReviewConfigChoice::EditCourseDefault), true),
161            (own, Some(ReviewConfigChoice::Fork), true),
162            (own, Some(ReviewConfigChoice::UseCourseDefault), true),
163        ];
164        for (exercise, choice, has_review_ops) in refusals {
165            assert!(
166                check_review_config(&view, exercise, choice, has_review_ops).is_err(),
167                "{exercise} {choice:?} {has_review_ops}"
168            );
169        }
170        let allowed = [
171            (
172                default_user,
173                Some(ReviewConfigChoice::EditCourseDefault),
174                true,
175            ),
176            (default_user, Some(ReviewConfigChoice::Fork), true),
177            (default_user, None, false),
178            (own, None, true),
179            (own, Some(ReviewConfigChoice::UseCourseDefault), false),
180        ];
181        for (exercise, choice, has_review_ops) in allowed {
182            assert!(
183                check_review_config(&view, exercise, choice, has_review_ops).is_ok(),
184                "{exercise} {choice:?} {has_review_ops}"
185            );
186        }
187
188        let mut without_reviews = view;
189        without_reviews
190            .staged
191            .exercise_mut(own)
192            .unwrap()
193            .needs_peer_review = false;
194        assert!(check_review_config(&without_reviews, own, None, true).is_err());
195    }
196}