headless_lms_mcp/handlers/exercise/
review_config.rs1use 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
20pub(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#[derive(Serialize)]
101pub(super) struct DroppedReviewSettings {
102 pub(super) question_count: usize,
103 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}