headless_lms_mcp/handlers/exercise/
patch.rs1use std::collections::BTreeMap;
4
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use headless_lms_models::{
8 error::TryToOptional,
9 exercise_slide_submissions,
10 library::agent_changesets::course_state::{CourseState, CourseView},
11 peer_or_self_review_configs, peer_or_self_review_submissions,
12};
13use serde::Serialize;
14use serde_json::json;
15use sqlx::PgConnection;
16use uuid::Uuid;
17
18use super::paths::{ExercisePart, call_index, group_ops, in_call, part_list};
19use super::review_config::{DroppedReviewSettings, check_review_config, dropped_settings};
20use super::{ExerciseDeadline, missing_exercise, open_course_with_exercise};
21use crate::access::connection;
22use crate::engine::{
23 CreatedIds, EditError, EditErrorCode, PatchTarget, Warning, WarningCode, page_content,
24};
25use crate::hints;
26use crate::ids::{IdGenerator, RandomIds};
27use crate::instruction_lists;
28use crate::staging::{self, WriteMode, WriteRequest};
29use crate::state::McpState;
30use crate::tools::args::{PatchExerciseArgs, ReviewConfigChoice};
31use crate::tools::{RequestContext, ToolError, ToolHandler, ToolOutcome};
32
33pub struct PatchExercise;
35
36fn parse_deadline(raw: Option<&str>) -> Result<Option<DateTime<Utc>>, EditError> {
39 raw.map(|raw| {
40 DateTime::parse_from_rfc3339(raw)
41 .map(|deadline| deadline.with_timezone(&Utc))
42 .map_err(|_| {
43 EditError::new(
44 EditErrorCode::InvalidValue,
45 "deadline is an RFC 3339 time with an offset, like \"2027-02-01T21:59:00Z\", or null to clear it",
46 )
47 })
48 })
49 .transpose()
50}
51
52fn deadline_warnings(
54 state: &CourseState,
55 exercise_id: Uuid,
56 deadline: DateTime<Utc>,
57) -> Vec<Warning> {
58 let mut warnings = vec![Warning::new(
59 WarningCode::OwnExerciseDeadline,
60 "an exercise's own deadline overrides its chapter's and is easy to miss when dates change; prefer the chapter deadline (patch_course_structure) and add own deadlines only when the teacher asks",
61 )];
62 let chapter = state
63 .exercise_page_id(exercise_id)
64 .and_then(|page| state.page(page)?.chapter_id)
65 .and_then(|chapter| state.chapters.iter().find(|c| c.id == chapter));
66 if let Some(chapter) = chapter {
67 let outside = match (chapter.opens_at, chapter.deadline) {
68 (Some(opens_at), _) if deadline < opens_at => Some(format!("opens at {opens_at}")),
69 (_, Some(end)) if deadline > end => Some(format!("has the deadline {end}")),
70 _ => None,
71 };
72 if let Some(outside) = outside {
73 warnings.push(Warning::new(
74 WarningCode::DeadlineOutsideChapterWindow,
75 format!(
76 "the deadline is outside the time the chapter is open: chapter \"{}\" {outside}",
77 chapter.name
78 ),
79 ));
80 }
81 }
82 warnings
83}
84
85async fn review_count(
87 conn: &mut PgConnection,
88 exercise_id: Uuid,
89) -> Result<Option<i64>, ToolError> {
90 Ok(
91 peer_or_self_review_submissions::count_by_exercise_ids(conn, &[exercise_id])
92 .await?
93 .get(&exercise_id)
94 .copied(),
95 )
96}
97
98async fn settings_dropped_for_course_default(
100 conn: &mut PgConnection,
101 view: &CourseView,
102 exercise_id: Uuid,
103) -> Result<Option<DroppedReviewSettings>, ToolError> {
104 let page_id = view
105 .exercise_page_id(exercise_id)
106 .ok_or_else(|| missing_exercise(view, exercise_id))?;
107 let page = view
108 .current_page_snapshots(conn, &[page_id])
109 .await?
110 .remove(&page_id);
111 let course_default =
112 peer_or_self_review_configs::get_course_default_cms_peer_review(conn, view.course.id)
113 .await
114 .optional()?;
115 Ok(page
116 .as_ref()
117 .and_then(|page| page.exercises.iter().find(|e| e.id == exercise_id))
118 .map(|own| dropped_settings(own, course_default.as_ref())))
119}
120
121#[derive(Serialize)]
122struct ExerciseSummary<'a> {
123 exercise: Uuid,
124 created: Vec<CreatedIds>,
125 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
126 labels: BTreeMap<String, Uuid>,
127 #[serde(skip_serializing_if = "Vec::is_empty")]
128 warnings: Vec<Warning>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 deadline: Option<ExerciseDeadline>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 review_config: Option<ReviewConfigChoice>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 dropped_review_settings: Option<DroppedReviewSettings>,
136 #[serde(skip_serializing_if = "Option::is_none")]
138 existing_review_count: Option<i64>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 course_default_review_config: Option<&'a str>,
141}
142
143#[async_trait(?Send)]
144impl ToolHandler<McpState> for PatchExercise {
145 type Args = PatchExerciseArgs;
146
147 async fn call(
148 &self,
149 state: &McpState,
150 ctx: &RequestContext,
151 args: PatchExerciseArgs,
152 ) -> Result<ToolOutcome, ToolError> {
153 let raw_ops = args.ops.clone().unwrap_or_default();
154 if args.name.is_none()
155 && args.deadline.is_none()
156 && raw_ops.is_empty()
157 && args.review_config.is_none()
158 {
159 return Err(ToolError::Edit(EditError::new(
160 EditErrorCode::MissingField,
161 "the call changes nothing; give name, deadline, ops or review_config",
162 )));
163 }
164 let name = args
165 .name
166 .as_deref()
167 .map(instruction_lists::checked_name)
168 .transpose()?;
169 let deadline = args
170 .deadline
171 .as_ref()
172 .map(|raw| parse_deadline(raw.as_deref()))
173 .transpose()?;
174 let groups = group_ops(&raw_ops)?;
175 let parsed = groups
176 .iter()
177 .map(|group| {
178 crate::engine::parse_ops(&group.raw, PatchTarget::PageContent)
179 .map_err(|e| in_call(e, &group.indexes))
180 })
181 .collect::<Result<Vec<_>, _>>()?;
182 let exercise_id = args.exercise.id();
183 let view = open_course_with_exercise(state, ctx, args.course.id(), exercise_id).await?;
184 let has_review_ops = groups
185 .iter()
186 .any(|group| group.part == ExercisePart::ReviewInstructions);
187 check_review_config(&view, exercise_id, args.review_config, has_review_ops)?;
188
189 let ids = RandomIds;
190 let mut after = view.staged.clone();
191 if let Some(name) = name
192 && let Some(record) = after.exercise_mut(exercise_id)
193 {
194 record.name = name;
195 }
196 let mut warnings = Vec::new();
197 if let Some(deadline) = deadline {
198 after.set_exercise_deadline(exercise_id, deadline);
199 if let Some(deadline) = deadline {
200 warnings.extend(deadline_warnings(&after, exercise_id, deadline));
201 }
202 }
203 match args.review_config {
204 Some(ReviewConfigChoice::Fork) => after.fork_review_config(exercise_id, ids.new_id()),
205 Some(ReviewConfigChoice::UseCourseDefault) => {
206 after.use_course_default_review_config(exercise_id)
207 }
208 Some(ReviewConfigChoice::EditCourseDefault) | None => {}
209 }
210
211 let mut created = Vec::new();
212 let mut labels = BTreeMap::new();
213 for (group, ops) in groups.iter().zip(&parsed) {
214 let list = part_list(&after, exercise_id, group.part)?;
215 let document = list
216 .document(&after)
217 .ok_or_else(|| missing_exercise(&view, exercise_id))??;
218 let result = page_content::apply(document, &|_| None, ops, &ids)
219 .map_err(|e| in_call(e, &group.indexes))?;
220 list.set_blocks(&mut after, &result.page.blocks);
221 for mut created_ids in result.created {
222 created_ids.op = call_index(&group.indexes, created_ids.op)?;
223 created.push(created_ids);
224 }
225 labels.extend(result.labels);
226 for mut warning in result.warnings {
227 warning.op = warning
228 .op
229 .map(|op| call_index(&group.indexes, op))
230 .transpose()?;
231 warnings.push(warning);
232 }
233 }
234 created.sort_by_key(|ids| ids.op);
235
236 let (existing_review_count, dropped_review_settings) = match args.review_config {
237 Some(ReviewConfigChoice::Fork) => {
238 let mut conn = connection(state).await?;
239 (review_count(&mut conn, exercise_id).await?, None)
240 }
241 Some(ReviewConfigChoice::UseCourseDefault) => {
242 let mut conn = connection(state).await?;
243 (
244 review_count(&mut conn, exercise_id).await?,
245 settings_dropped_for_course_default(&mut conn, &view, exercise_id).await?,
246 )
247 }
248 Some(ReviewConfigChoice::EditCourseDefault) | None => (None, None),
249 };
250 let has_task_ops = groups
251 .iter()
252 .any(|group| matches!(group.part, ExercisePart::Task(_)));
253 let is_answered = has_task_ops
254 && !exercise_slide_submissions::get_exercise_ids_with_submissions_by_course_id(
255 &mut *connection(state).await?,
256 view.course.id,
257 &[exercise_id],
258 )
259 .await?
260 .is_empty();
261 let edits_course_default =
262 after.course_default_review_config != view.staged.course_default_review_config;
263 let deadline_after = deadline.map(|_| ExerciseDeadline::of(&after, exercise_id));
264
265 let report = staging::commit(
266 state,
267 ctx,
268 &view,
269 after,
270 WriteRequest::new(
271 json!(&args),
272 format!("edit of exercise {exercise_id}"),
273 WriteMode::from_dry_run(args.dry_run),
274 ),
275 )
276 .await?;
277 let summary = ExerciseSummary {
278 exercise: exercise_id,
279 created,
280 labels,
281 warnings,
282 deadline: deadline_after,
283 review_config: args.review_config,
284 dropped_review_settings,
285 existing_review_count,
286 course_default_review_config: edits_course_default.then_some(
287 "edited: the change shows in every exercise that uses the course default review config",
288 ),
289 };
290 let mut result = report.into_result(&summary);
291 if let Some(count) = existing_review_count.filter(|count| *count > 0) {
292 result = result.with_hint(hints::review_config_switched_with_reviews(count));
293 }
294 if is_answered {
295 result = result.with_hint(hints::answered_task_instructions_edited());
296 }
297 Ok(result.into())
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn names_are_one_line_of_plain_text() {
307 assert_eq!(
308 instruction_lists::checked_name(" Loops ").unwrap(),
309 "Loops"
310 );
311 for invalid in ["", " ", "a\nb", "<b>Loops</b>"] {
312 assert!(
313 instruction_lists::checked_name(invalid).is_err(),
314 "{invalid:?}"
315 );
316 }
317 assert_eq!(instruction_lists::checked_name("x < y").unwrap(), "x < y");
318 }
319}