headless_lms_mcp/staging/
page_writes.rs1use std::collections::{HashMap, HashSet};
4
5use headless_lms_models::{
6 library::{
7 agent_changesets::{
8 StagedEntity,
9 course_state::{CourseState, CourseView, ExerciseEditError, ExerciseReviewConfig},
10 },
11 entity_snapshots::{
12 ContentEntityType, EntityKey, EntitySnapshot, ExerciseBundle, PageSnapshot,
13 },
14 },
15 peer_or_self_review_configs::{self, CmsPeerOrSelfReviewConfiguration},
16};
17use sqlx::PgConnection;
18use uuid::Uuid;
19
20use super::stale_read;
21use crate::engine::blocks::ExerciseCopy;
22use crate::ids::IdGenerator;
23use crate::tools::ToolError;
24
25pub struct PageWrites {
28 pub writes: Vec<StagedEntity>,
29 pub live_pages: HashMap<Uuid, PageSnapshot>,
30}
31
32fn edit_exercise(
35 before: &CourseState,
36 after: &CourseState,
37 course_default: Option<&CmsPeerOrSelfReviewConfiguration>,
38 ids: &dyn IdGenerator,
39 page_id: Uuid,
40 bundle: &mut ExerciseBundle,
41) -> Result<bool, ToolError> {
42 let exercise_id = bundle.exercise.id;
43 let (Some(before), Some(after)) = (before.exercise(exercise_id), after.exercise(exercise_id))
44 else {
45 return Ok(false);
46 };
47 if after.has_same_content(before) {
48 return Ok(false);
49 }
50 after
51 .apply_to(before, bundle, course_default, || ids.new_id())
52 .map_err(|error| match error {
53 ExerciseEditError::ChangedSinceRead => {
54 stale_read(&[EntityKey::new(ContentEntityType::Page, page_id)])
55 }
56 ExerciseEditError::NoCourseDefault => ToolError::InvalidArguments {
57 message: "the course has no default review config to copy".into(),
58 },
59 ExerciseEditError::UnsupportedReviewConfigChange => ToolError::Internal(format!(
60 "exercise {exercise_id} got a review config change staging can't write"
61 )),
62 })?;
63 Ok(true)
64}
65
66async fn course_default_to_copy(
68 conn: &mut PgConnection,
69 before: &CourseState,
70 after: &CourseState,
71) -> Result<Option<CmsPeerOrSelfReviewConfiguration>, ToolError> {
72 let copies = after.exercises_changed_since(before).iter().any(|id| {
73 matches!(
74 (before.exercise(*id), after.exercise(*id)),
75 (Some(before), Some(after))
76 if before.review_config == ExerciseReviewConfig::CourseDefault
77 && matches!(after.review_config, ExerciseReviewConfig::Own { .. })
78 )
79 });
80 if !copies {
81 return Ok(None);
82 }
83 Ok(
84 peer_or_self_review_configs::get_course_default_cms_peer_review_configuration(
85 conn,
86 after.course_id(),
87 )
88 .await?,
89 )
90}
91
92pub async fn page_writes(
95 conn: &mut PgConnection,
96 view: &CourseView,
97 after: &CourseState,
98 page_ids: &[Uuid],
99 copies: &[ExerciseCopy],
100 ids: &dyn IdGenerator,
101) -> Result<PageWrites, ToolError> {
102 let copy_sources: HashSet<Uuid> = copies.iter().map(|c| c.source_exercise_id).collect();
103 let written_pages: HashSet<Uuid> = page_ids.iter().copied().collect();
104 let mut source_pages: Vec<Uuid> = page_ids.to_vec();
105 if !copy_sources.is_empty() {
106 let mut is_source_page = written_pages.clone();
107 for page in &view.staged.pages {
108 let has_source = page
109 .content
110 .iter()
111 .any(|b| b.exercise_id().is_some_and(|id| copy_sources.contains(&id)));
112 if has_source && is_source_page.insert(page.id) {
113 source_pages.push(page.id);
114 }
115 }
116 }
117 let mut current = view.current_page_snapshots(conn, &source_pages).await?;
118 let course_default = course_default_to_copy(conn, &view.staged, after).await?;
119 let mut live_pages: HashMap<Uuid, PageSnapshot> = HashMap::new();
120 let mut bundles: HashMap<Uuid, ExerciseBundle> = HashMap::new();
121 let mut edited: HashSet<Uuid> = HashSet::new();
122 for page_id in &source_pages {
123 let Some(snapshot) = current.get(page_id) else {
124 continue;
125 };
126 for mut bundle in snapshot.exercise_bundles() {
127 if edit_exercise(
128 &view.staged,
129 after,
130 course_default.as_ref(),
131 ids,
132 *page_id,
133 &mut bundle,
134 )? {
135 edited.insert(bundle.exercise.id);
136 }
137 bundles.insert(bundle.exercise.id, bundle);
138 }
139 if view
140 .staged_row(EntityKey::new(ContentEntityType::Page, *page_id))
141 .is_none()
142 && written_pages.contains(page_id)
143 {
144 live_pages.insert(*page_id, snapshot.clone());
145 }
146 }
147 for copy in copies {
148 let source = bundles.get(©.source_exercise_id).ok_or_else(|| {
149 ToolError::Internal(format!(
150 "copied exercise {} wasn't found on any page",
151 copy.source_exercise_id
152 ))
153 })?;
154 let copied = source.copy_with_new_ids(copy.new_exercise_id, || ids.new_id());
155 bundles.insert(copy.new_exercise_id, copied);
156 }
157
158 let mut writes = Vec::new();
159 for page_id in page_ids {
160 let Some(record) = after.page(*page_id) else {
161 writes.push(StagedEntity::Delete(EntityKey::new(
162 ContentEntityType::Page,
163 *page_id,
164 )));
165 continue;
166 };
167 let snapshot = match current.remove(page_id) {
168 Some(base) if base.content == record.content => {
169 let mut snapshot = PageSnapshot {
170 chapter_id: record.chapter_id,
171 url_path: record.url_path.clone(),
172 title: record.title.clone(),
173 hidden: record.hidden,
174 order_number: record.order_number,
175 joins_page_language_group_id: record.joins_page_language_group_id,
176 ..base
177 };
178 if snapshot.exercises.iter().any(|e| edited.contains(&e.id)) {
179 let exercise_ids: Vec<Uuid> = snapshot.exercises.iter().map(|e| e.id).collect();
180 snapshot
181 .set_exercise_bundles(exercise_ids.iter().filter_map(|id| bundles.get(id)));
182 }
183 snapshot
184 }
185 _ => PageSnapshot::from_record_and_bundles(after.course_id(), record, &bundles)
186 .map_err(|missing| {
187 ToolError::Internal(format!(
188 "page {page_id} names exercises that weren't found: {missing:?}"
189 ))
190 })?,
191 };
192 writes.push(StagedEntity::Write(EntitySnapshot::Page(snapshot)));
193 }
194 Ok(PageWrites { writes, live_pages })
195}