Skip to main content

headless_lms_mcp/staging/
page_writes.rs

1//! Full page snapshots for the pages a call changed, with the exercises that follow their blocks.
2
3use 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
25/// Full page snapshots for the changed pages of a state, and the live snapshots of the pages
26/// among them that the user has no staged changes to.
27pub struct PageWrites {
28    pub writes: Vec<StagedEntity>,
29    pub live_pages: HashMap<Uuid, PageSnapshot>,
30}
31
32/// Writes the changes of `after`'s exercise records since `before` into `bundle`, if its exercise
33/// has any, and tells whether it had. `page_id` is the page the bundle was read from.
34fn 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
66/// The course default review config with its questions, if `after` copies it to an exercise.
67async 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
92/// Builds full page snapshots for the changed pages of `after`. Exercises follow their blocks,
93/// across pages too; `copies` are created from their source exercises.
94pub 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(&copy.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}